Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"name": "kensai",
"description": "Multi-agent code review pipeline with adversarial falsification, orchestrated by a stateful MCP server",
"source": "./claude/kensai",
"version": "0.1.7",
"version": "0.1.8",
"license": "MIT",
"keywords": [
"code-review",
Expand Down
2 changes: 1 addition & 1 deletion claude/kensai/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "kensai",
"version": "0.1.7",
"version": "0.1.8",
"description": "Multi-agent code review pipeline with adversarial falsification, orchestrated by a stateful MCP server",
"author": {
"name": "xobotyi",
Expand Down
2 changes: 1 addition & 1 deletion claude/kensai/mcp/review/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ src/
│ └── CLAUDE.md
├── repofs/ — scoped filesystem facade composing all sub-modules
│ ├── CLAUDE.md
│ ├── git/ — thin git CLI abstraction with diff annotation
│ ├── git/ — thin git CLI abstraction with diff annotation; gitignore/ matcher submodule
│ ├── rg/ — ripgrep CLI abstraction for content search
│ ├── pathindex/ — tree-structured file index with fuzzy/glob search
│ └── lineiter/ — streaming line iterator with pluggable binary detection
Expand Down
2 changes: 1 addition & 1 deletion claude/kensai/mcp/review/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gaijin/kensai-review-mcp",
"version": "0.1.7",
"version": "0.1.8",
"description": "Stateful MCP server for the kensai multi-agent code review pipeline",
"license": "MIT",
"author": {
Expand Down
89 changes: 31 additions & 58 deletions claude/kensai/mcp/review/src/repofs/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,21 @@ Single entry point for all filesystem and search operations within a project roo

## API

| Export | Purpose |
| -------------------------------- | ---------------------------------------------------------------- |
| `RepoFs.open(root, signal?)` | Async factory. Builds file index and validates git in parallel |
| `.root` | Absolute path to repository root |
| `.git` | `Repo` instance for git operations |
| `.index` | `PathIndex` instance — O(1) lookup, fuzzy search, glob filter |
| `.resolve(path)` | Normalize any path to POSIX relative from root. Throws on escape |
| `.fileExists(path)` | Check file existence via index |
| `.dirExists(path)` | Check directory existence via index |
| `.readFile(path, opts?)` | Read text file with binary detection, line windowing, byte cap |
| `.listDir(path, opts?)` | List directory entries from index tree with depth control |
| `.findFiles(pattern, opts?)` | Fuzzy search file paths via PathIndex |
| `.globFiles(opts?)` | Glob-filter indexed paths via PathIndex |
| `.grep(pattern, opts?, signal?)` | Content search via ripgrep scoped to root |
| `.flushReadPaths()` | Return and clear paths from successful readFile calls |
| `RepoFsError` | Error class for scoped filesystem failures |
| Export | Purpose |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `RepoFs.open(root, signal?)` | Async factory. Builds gitignore-aware index (pure filesystem walk) and validates git in parallel |
| `.root` | Absolute path to repository root |
| `.git` | `Repo` instance for git operations |
| `.index` | `PathIndex` instance — O(1) lookup, fuzzy search, glob filter. Gitignore-filtered walk; review layer reconciles changed files via `add()` |
| `.resolve(path)` | Normalize any path to POSIX relative from root. Throws on escape |
| `.fileExists(path)` | Check file existence via index |
| `.dirExists(path)` | Check directory existence via index |
| `.readFile(path)` | Validate via index, return raw bytes. Lazily fills the entry's size; lstat fallback for unindexed paths |
| `.findFiles(pattern, opts?)` | Fuzzy search file paths via PathIndex |
| `.globFiles(opts?)` | Glob-filter indexed paths via PathIndex |
| `.grep(pattern, opts?, signal?)` | Content search via ripgrep scoped to root |
| `.flushReadPaths()` | Return and clear paths from successful readFile calls |
| `RepoFsError` | Error class for scoped filesystem failures |

## Path Resolution

Expand All @@ -34,61 +33,35 @@ Single entry point for all filesystem and search operations within a project roo
5. Returns the POSIX relative path from root (index-compatible)

All index lookups, error messages, and read tracking use the resolved relative path.
The only place that needs the absolute path is `createReadStream` in `readFile`.
The only place that needs the absolute path is `fsp.readFile` (and the fallback `lstat`) in `readFile`.

## File Reading

Validation chain (all index-based, no stat calls):
`readFile` validates and returns raw bytes — line windowing, binary probing, and length caps live in
the `read_file` tool (`src/toolsets/fs/read_file.ts`):

1. `resolve()` — containment
2. `index.get()` — existence, rejects dirs and symlinks by entry type
3. Binary extension check
4. Byte limit enforcement (when no explicit `lineLimit`)
5. Streaming read via `LineIter` with binary probe and per-line byte cap
2. `index.get()` — rejects non-file entries (dirs, symlinks) by type
3. Unindexed paths fall back to the filesystem: first segment must not be in `WALK_EXCLUDE_DIRS`,
`lstat` must report a regular file (symlinks rejected)
4. `fsp.readFile` — fills the index entry's lazy `size` from the buffer length
5. Records the read path for instruction resolution via `flushReadPaths()`

Returns `ReadResult` with `lines` (num, content, fullLen), `truncated`, `empty`, `pastEof`.
Records successful reads for future instruction resolution via `flushReadPaths()`.

### ReadFileOptions

| Option | Default | Purpose |
| ----------------- | ------- | ------------------------------------------------- |
| `lineOffset` | 1 | Start line (1-based) |
| `lineLimit` | 2000 | Max lines returned |
| `lineLengthCap` | 1024 | Per-line byte cap before truncation |
| `binaryProbeSize` | 512 | Bytes scanned for null byte detection |
| `byteLimit` | 256 KB | Reject files over this without explicit lineLimit |
| `signal` | — | AbortSignal for cancellation |

## Directory Listing

Traverses the PathIndex tree — no filesystem I/O at list time.
Index is built once at `open()` and reflects the state at that point.

Returns `ListResult` with `entries` (path, name, isDir, isSymlink, size) and `truncated`.
Entries sorted by name within each level. Excluded directories are listed but not descended into.

### ListDirOptions

| Option | Default | Purpose |
| -------------- | ---------------------------------------------------- | ----------------------------------------- |
| `maxDepth` | 1 | Recursion depth. 1 = direct children only |
| `maxEntries` | 1000 | Maximum entries before truncation |
| `skipDotfiles` | false | Omit entries starting with "." |
| `excludeDirs` | vendor, node_modules, .git, dist, build, **pycache** | Skip descent into these directories |
Directory listing has no RepoFs method — the `list_dir` tool (`src/toolsets/fs/list_dir.ts`)
traverses `.index.dir()` directly.

## Containment

`resolve()` prevents path escape via textual containment: `path.relative(root, abs)` must not
start with `..`. Symlinks are safe because PathIndex does not traverse them — symlinked entries
appear in the index with `type: "symlink"` and `readFile` rejects them by type.
appear in the index with `type: "symlink"` and `readFile` rejects them by type. The `readFile`
fallback for unindexed paths uses `lstat` and rejects non-regular files, so a gitignored symlink
cannot bypass containment.

## Dependencies

- `node:fs` (createReadStream)
- `node:fs/promises` (stat — only in `open()`)
- `node:path` (resolve, relative, posix.normalize, posix.basename, posix.dirname, posix.extname)
- `node:fs/promises` (stat in `open()`, readFile + fallback lstat in `readFile`)
- `node:path` (resolve, relative, posix.normalize)
- `../git/git.ts` (Repo)
- `../lineiter/lineiter.ts` (LineIter, ErrBinaryContent)
- `../pathindex/pathindex.ts` (PathIndex, FilterOptions, IndexEntryDir)
- `../pathindex/pathindex.ts` (PathIndex, WALK_EXCLUDE_DIRS, FilterOptions)
- `../rg/rg.ts` (grep, GrepOptions, GrepResult)
1 change: 1 addition & 0 deletions claude/kensai/mcp/review/src/repofs/git/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

Thin abstraction over the local git CLI. Ported from Go `git/repo.go` and `gittoolset/annotate.go`.
Foundation for `git-diff`, `git-changed-files`, `git-log` tools.
Contains the `gitignore/` submodule (pattern matching for walk-time filtering) — see its `CLAUDE.md`.

## API

Expand Down
37 changes: 37 additions & 0 deletions claude/kensai/mcp/review/src/repofs/git/gitignore/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# gitignore

Gitignore pattern matching ported from the Go reference implementation. Parses `.gitignore` syntax into compiled
matchers and chains them hierarchically for walk-time filtering.

## API

| Export | Purpose |
| ------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `parse(content, dir)` | Parse a `.gitignore` body into a `Matcher`. `dir` is the file's directory relative to root (`""` for root) |
| `parsePattern(line, dir)` | Compile one gitignore line. Returns `null` for blanks, comments, invalid patterns |
| `Matcher` | Immutable pattern list with optional parent chain |
| `.match(relPath, isDir)` | `true` if the path is ignored. Last-match-wins; own patterns checked before parent chain |
| `.append(...patterns)` | New `Matcher` layered on top of this one |
| `.withParent(parent)` | Rechain onto a new parent; collapses to `parent` when this matcher is empty |
| `MatchResult`, `Pattern` | Pattern interface and per-pattern match result (`no-match` / `exclude` / `include`) |

## Semantics

- Full gitignore syntax: negation (`!`), dir-only trailing `/`, anchoring (leading or interior `/`), `**` globs,
character classes, escaped `#` / `!` / trailing space
- `[!abc]` character classes normalized to picomatch's `[^abc]`
- Globs match dotfiles; braces and extglobs are literal — gitignore has no brace expansion
- Basename patterns (no slash) match at any depth below the defining directory; patterns with a slash are anchored to
it (compiled with a `dir/` prefix)
- Nested `.gitignore` scoping is by construction: consumers consult a directory's matcher chain only for paths under
that directory

## Consumers

- `pathindex` — walk-time filtering: per-directory `.gitignore` chained onto the parent matcher, `.git/info/exclude`
seeds the root chain

## Dependencies

- `picomatch` — glob compilation
- `node:path/posix` (basename)
102 changes: 102 additions & 0 deletions claude/kensai/mcp/review/src/repofs/git/gitignore/gitignore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from "vite-plus/test";

import { Matcher, parse, parsePattern } from "./gitignore.ts";

describe("parse", () => {
it("parses mixed content", () => {
const content = ["# comment", "", "*.log", "build/", "!important.log", " ", "doc/**/*.pdf"].join("\n");

const m = parse(content, "");

expect(m.match("debug.log", false)).toBe(true);
expect(m.match("build", true)).toBe(true);
expect(m.match("build", false)).toBe(false);
expect(m.match("important.log", false)).toBe(false);
expect(m.match("doc/ref/manual.pdf", false)).toBe(true);
expect(m.match("main.go", false)).toBe(false);
});

it("parses with dir scoping", () => {
const content = "build/output.log\n**/test\n";
const m = parse(content, "src/pkg");

expect(m.match("src/pkg/build/output.log", false)).toBe(true);
expect(m.match("build/output.log", false)).toBe(false);
expect(m.match("src/pkg/test", true)).toBe(true);
expect(m.match("src/pkg/a/b/test", true)).toBe(true);
});
});

describe("Matcher — last match wins", () => {
it("negation overrides earlier exclude", () => {
const p1 = parsePattern("*.log", "")!;
const p2 = parsePattern("!important.log", "")!;
const m = new Matcher([p1, p2]);

expect(m.match("important.log", false)).toBe(false);
expect(m.match("debug.log", false)).toBe(true);
expect(m.match("main.go", false)).toBe(false);
});
});

describe("Matcher — append", () => {
it("child overrides parent immutably", () => {
const p1 = parsePattern("*.log", "")!;
const parent = new Matcher([p1]);

const p2 = parsePattern("!important.log", "")!;
const child = parent.append(p2);

expect(parent.match("important.log", false)).toBe(true);
expect(child.match("important.log", false)).toBe(false);
});

it("append on empty returns new matcher", () => {
const p1 = parsePattern("*.log", "")!;
const m = new Matcher([]);
const result = m.append(p1);

expect(result).not.toBe(m);
expect(result.match("debug.log", false)).toBe(true);
});

it("append with no patterns returns same instance", () => {
const p1 = parsePattern("*.log", "")!;
const m = new Matcher([p1]);
expect(m.append()).toBe(m);
});
});

describe("Matcher — withParent", () => {
it("child overrides parent", () => {
const parent = parse("*.log\n", "");
const child = parse("!important.log\n", "");
const chained = child.withParent(parent);

expect(chained!.match("important.log", false)).toBe(false);
expect(chained!.match("debug.log", false)).toBe(true);
});

it("empty child returns parent", () => {
const parent = parse("*.log\n", "");
const child = new Matcher([]);
const result = child.withParent(parent);

expect(result).toBe(parent);
});

it("child with null parent works", () => {
const child = parse("*.log\n", "");
const result = child.withParent(null);

expect(result).not.toBeNull();
expect(result!.match("debug.log", false)).toBe(true);
});
});

describe("Matcher — empty", () => {
it("empty matcher matches nothing", () => {
const m = new Matcher([]);
expect(m.match("anything", false)).toBe(false);
});
});
55 changes: 55 additions & 0 deletions claude/kensai/mcp/review/src/repofs/git/gitignore/gitignore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import path from "node:path/posix";

import { type MatchResult, type Pattern, parsePattern } from "./pattern.ts";

export type { MatchResult, Pattern };
export { parsePattern };

export class Matcher {
readonly #patterns: readonly Pattern[];
readonly #parent: Matcher | null;

constructor(patterns: Pattern[], parent: Matcher | null = null) {
this.#patterns = patterns;
this.#parent = parent;
}

match(relPath: string, isDir: boolean): boolean {
return Matcher.#walk(this, relPath, isDir);
}

static #walk(start: Matcher, relPath: string, isDir: boolean): boolean {
const name = path.basename(relPath);

for (let cur: Matcher | null = start; cur != null; cur = cur.#parent) {
for (let i = cur.#patterns.length - 1; i >= 0; i--) {
const result = cur.#patterns[i]!.match(relPath, name, isDir);
if (result === "exclude") return true;
if (result === "include") return false;
}
}

return false;
}

append(...patterns: Pattern[]): Matcher {
if (patterns.length === 0) return this;
return new Matcher(patterns, this);
}

withParent(parent: Matcher | null): Matcher | null {
if (this.#patterns.length === 0) return parent;
return new Matcher([...this.#patterns], parent);
}
}

export function parse(content: string, dir: string): Matcher {
const patterns: Pattern[] = [];

for (const line of content.split("\n")) {
const p = parsePattern(line, dir);
if (p) patterns.push(p);
}

return new Matcher(patterns);
}
Loading
Loading