diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1945830..304693b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,15 +23,31 @@ permissions: jobs: test: - name: test (node ${{ matrix.node-version }}) + name: test (${{ matrix.os }}, node ${{ matrix.node-version }}) # The schedule trigger exists only to re-run `audit` against a moving # advisory DB; the code is unchanged between crons, so skip the rest. if: github.event_name != 'schedule' - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + # Node versions fan out on Linux only; Windows and macOS get one pinned + # version each via `include`. A full 3x3 cross-product would be nine jobs to + # re-answer a question the Linux column already answers — what these two add + # is the PLATFORM, not another Node. + # + # They are here because the code has real per-platform branches (win32 paths + # in skills/install.mjs, path separators through the resolver, git behaviour + # differences) and the docs target Windows, yet every one of the 477 tests + # had only ever run on Linux. macOS is the maintainer's own platform and is + # exercised locally; Windows genuinely was not covered anywhere. strategy: fail-fast: false matrix: + os: [ubuntu-latest] node-version: [20, 22, 24] + include: + - os: windows-latest + node-version: 22 + - os: macos-latest + node-version: 22 steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -45,6 +61,14 @@ jobs: - name: Install dependencies run: npm ci + # checkJs over ~250KB of hand-written JS — the only static analysis that + # reads this codebase, since none of it is TypeScript. Deliberately runs on + # every OS: the two errors it caught when introduced were a stale JSDoc type + # and an unfollowable dynamic import, and path-shaped mistakes are exactly + # the kind that differ per platform. + - name: Typecheck + run: npm run typecheck + # Black-box suite — drives the real CLI as a subprocess against temp repos. # Runs via npm so CI uses the same glob set as `npm test` locally # (test/*.test.mjs alone silently skipped the test/vue-sfc/ suite). @@ -73,6 +97,14 @@ jobs: # So: pack, install from the tarball, and drive the real binary. Exit status is # NOT sufficient evidence here — exit 0 with empty stdout was the bug's exact # signature, so every step below asserts on OUTPUT. + # + # Deliberately Linux-only, unlike the `test` job above. Every step here is a + # bash script with `set -euo pipefail`, absolute /tmp paths and $GITHUB_ENV + # export syntax; on windows-latest the default shell is PowerShell, so porting + # this means `shell: bash` plus rewriting the paths, and a half-ported version + # that silently skips a step is worse than an honest gap. The Windows install + # path is therefore NOT covered — recorded here rather than left to be inferred + # from the matrix. install-smoke: name: install smoke (node ${{ matrix.node-version }}) if: github.event_name != 'schedule' @@ -165,6 +197,35 @@ jobs: echo "generatedSha $BEFORE -> $AFTER" test "$AFTER" != "$BEFORE" || { echo "::error::post-commit hook did not refresh the map — auto-refresh is dead"; exit 1; } + # Coverage floor, so a shipped file that nothing executes stays visible. + # + # Uses node --test's own coverage rather than c8: the thresholds land natively + # from Node 22, and the near-zero-deps rule is easier to keep than to argue with. + # That is also why this is its own job pinned to one version — the flags do not + # exist on Node 20, which the test matrix still supports. + # + # Floors sit BELOW the measured 93.77% lines / 75.92% branches on purpose. They + # are a regression alarm, not a target: a gate set at the current number reddens + # on ordinary work and gets raised until someone stops reading it. + coverage: + name: Coverage floor + if: github.event_name != 'schedule' + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + + - name: Install dependencies + run: npm ci + + - name: Run tests under coverage + run: npm run coverage + # Audit for high-severity vulnerabilities in the dependency tree. Deliberately # its own job rather than a step in the matrix above: it depends only on the # lockfile, so running it per Node version was three identical checks, and a diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..63cfe24 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,41 @@ +# Code of Conduct + +## The standard + +This project adopts the [Contributor Covenant v2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). +Read it there — it is the authoritative text, and restating it here only creates a +copy to drift. + +In short: be respectful, assume good faith, and keep discussion about the work. +Harassment, personal attacks, and demeaning comments are not welcome, in issues, +pull requests, commit messages, or anywhere else this project is discussed. + +## Scope + +Applies in all project spaces — issues, pull requests, discussions, commits — and +when representing the project publicly. + +## Technical disagreement is not a violation + +This repository argues with itself in writing. `ROADMAP.md` records items closed as +**refuted** with the measurements that refuted them, several of which contradict +what a maintainer previously believed. `CONTRIBUTING.md` says maintainers may decline +in-scope changes. Being told your patch is wrong, or that a number does not +reproduce, is the process working. + +What is not on that list: making it personal, or about the person. + +## Reporting + +Report anything that crosses the line to **raymondchin.s@gmail.com**, the address +already on every commit in this repository. Reports are handled privately, and the +reporter's identity is not shared with the person reported. + +If the report concerns the maintainer, GitHub's own +[reporting channels](https://docs.github.com/en/communities/maintaining-your-safety-on-github/reporting-abuse-or-spam) +exist for exactly that reason and are the right escalation. + +## Enforcement + +Responses are proportionate: a private correction, a warning, removal of a comment, +or a block. The maintainer decides, and will say which is being applied and why. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3cd45b9..ebde1ea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,9 +120,22 @@ When you touch caching, building, or the schema: ## Submitting a PR 1. For anything non-trivial, open (or link) an issue describing the change first. -2. Branch, make the change, run `npm test` — all green on Node 20+. +2. Branch, make the change, then run all three gates — CI runs the same ones: + + ```bash + npm test # black-box suite, all green on Node 20+ + npm run typecheck # checkJs over the .mjs sources (see jsconfig.json) + npm run coverage # suite again with the line/branch floor enforced + ``` + + `typecheck` is intentionally non-strict — `jsconfig.json` explains what that + buys and what it gives up. `coverage` needs Node 22+ for the threshold flags; + `npm test` alone is fine on Node 20. 3. Tests are dependency-free black-box drivers over throwaway git repos (see - `test/helpers.mjs`). New behavior needs a test in that style. + `test/helpers.mjs`). New behavior needs a test in that style. Assert what the + output *should be*, not merely that two runs agree — `test/determinism.test.mjs` + passes unchanged with the hub comparator inverted, which is why + `test/ranking-quality.test.mjs` exists. 4. Keep the diff minimal and the output byte-identical for existing commands — unless the change *is* the output (then call it out). 5. Fill in the PR checklist. Maintainers may decline in-scope-but-bloating diff --git a/EVAL.md b/EVAL.md index 6097419..1d46b6d 100644 --- a/EVAL.md +++ b/EVAL.md @@ -74,6 +74,14 @@ where naive grep returns a noisy superset (high recall, low precision) — and ` actually costs **more** tokens than `grep -l` because it returns the full blast radius (exports + imports + dependents + related), not just the file list. +> **Reconciling this with `RESULTS.md`'s 99.2% blast-radius row.** Both numbers are real; +> they price different baselines. `RESULTS.md` scenario D compares against an agent that +> `cat`s every dependent file — agentmap wins by ~99%. This eval compares against `grep -l`, +> which returns a file list and nothing else — agentmap loses on tokens. The list is cheaper +> because it is *less* correct: at **59.9%** precision, roughly 4 of every 10 paths on it are +> not dependents, and the agent pays for them on the next turn when it opens them. Neither +> file is the whole picture on its own; quote them together. + ### Per fixture | Repo | commit | def n | agentmap top1/top3 | grep top1/top3 | deps n | agentmap recall/prec | grep recall/prec | diff --git a/README.md b/README.md index 079646f..c2b28e5 100644 --- a/README.md +++ b/README.md @@ -133,12 +133,17 @@ follows the chain and names the file that actually declares it. - **The win scales with the work.** The 63% and 11% rows are the floor. A *trivial single-file* lookup can cost **more** than `cat` + `grep` — taxonomy's file-import task hit **−313%**, and it stays in the table. -- **The 98.3% combined figure is skewed** by the whole-repo row (150 K vs 1 K). Excluding it, - the per-task average is ~32× rather than 58×. Both are real; the headline captures the - common worst case (repo dump on session start). +- **The 98.3% headline is carried by its two biggest rows** — repo dump (150,281 → 1,127) and + blast radius (81,038 → 616). Drop the repo dump and it's **96.9%**; drop both and it's + **89.8%** here, **93.7%** pooled across all three repos, and **73.1%** on the smallest one. + All of those are real — they answer different questions. The headline is the common worst + case: an agent dumping the repo at session start. - **`--relates` returns the full blast radius**, so it costs *more* than a bare `grep -l` file - list. Complete-and-correct over short-and-wrong — but it is a trade, stated in - [EVAL.md](./EVAL.md). + list. That's why the same command reads as 99.2% *saved* in the benchmark and *more + expensive* in the eval: the benchmark's baseline is an agent that `cat`s all 65 dependent + files, the eval's is a file list nobody reads. Against the list, agentmap trades tokens for + precision — 100% vs 59.9%, so ~4 in 10 files on the grep list don't belong. + Complete-and-correct over short-and-wrong, but it is a trade → [EVAL.md](./EVAL.md). - **Numbers are context-token volume**, not answer quality or wall-clock. - **Token counts are estimates** (`chars / 4`), applied identically to both sides. - **TypeScript/JavaScript only** (+ Vue SFC) — see [Scope & limitations](#scope--limitations). @@ -164,17 +169,20 @@ OS-event file watcher (FSEvents/inotify) with debounced auto-sync and an install auto-configures eight agent CLIs. agentmap's honest edge over the multi-language graph tools is narrower and sharper: **TS/JS resolution the others approximate, with a published accuracy eval.** -| | **agentmap** | Aider repo map | RepoMapper | Repomix | code2prompt | +| | **agentmap** | [Aider repo map](https://github.com/Aider-AI/aider) | [RepoMapper](https://github.com/nuptcode/repomapper) | [Repomix](https://github.com/yamadashy/repomix) | [code2prompt](https://github.com/mufeedvh/code2prompt) | | --- | --- | --- | --- | --- | --- | | **Ranking algorithm** | Personalized PageRank (file + symbol graphs) | PageRank (graph ranking) | Importance heuristics | None (file order) | None (file order) | | **Languages** | TS/JS + Vue SFC (via ts-morph) | Many (tree-sitter) | Many (tree-sitter) | Language-agnostic (text) | Language-agnostic (text) | | **Token-budget output** | Yes — `--map [--tokens N]` ranked digest | Yes (built into Aider's context) | Partial | Yes (size caps) | Yes (templates/caps) | | **TS/JS resolution depth** | **Compiler-grade — `tsconfig` paths + `vite`/`webpack` alias + `#imports` + workspaces (ts-morph)** | Basename/regex heuristics | Basename/regex heuristics | N/A (text) | N/A (text) | | **Retrieval-accuracy eval** | **Yes — published [`EVAL.md`](./EVAL.md) vs live ground truth** | No | No | No | No | -| **Agent-loop wiring** | Yes — post-commit auto-refresh + PreToolUse hook | In-process (Aider only) | No | No | No | +| **Agent-loop wiring** | Yes — post-commit auto-refresh + PreToolUse hook | In-process (Aider only) | No | MCP server (no auto-refresh, no nudge) | No | | **Dependencies** | `ts-morph` only | Python + tree-sitter stack | Python + tree-sitter | Node | Rust binary | | **Install** | `npx @raymondchins/agentmap` | `pip install aider-chat` | `pip install` | `npx`/global | `cargo`/binary | +Comparison as of 2026-07-27, from each project's own docs. These are moving targets — if a +cell is out of date, that's a bug: open an issue. + What that table is **not** claiming: agentmap is TS/JS-only (the others are multi-language), and it's a **file-level import graph**, not a full call-site/reference resolver (see [Scope & limitations](#scope--limitations)). The differentiators are narrow and honest: @@ -353,7 +361,7 @@ skill/rule the agent may or may not consult). Honest matrix: | Platform | Install | Enforcement | Known gaps | |----------|---------|-------------|------------| | **Claude Code** | `/plugin install agentmap@agentmap` (or `--install-hooks`) | **live hook** — `PreToolUse` nudge on `Grep` + Bash searchers | non-blocking (never denies grep); bare-symbol `Grep` nudge requires the #3 hook fix | -| **Gemini CLI** | `--install-skill --platform gemini` | **live hook** — `.gemini/settings.json` nudge | fires on the `AfterTool`/`systemMessage` path (the earlier `BeforeTool` + `additionalContext` combo was silently dropped — fixed in #4) | +| **Gemini CLI** | `--install-skill --platform gemini` | **live hook** — `.gemini/settings.json` nudge | fires on `BeforeTool` and emits a top-level `systemMessage`; Gemini parses and then **drops** `hookSpecificOutput.additionalContext` on `BeforeTool`, which is why the nudge used to vanish silently | | **OpenCode** | `--install-skill --platform opencode` | **log-only** — `.opencode/plugins/agentmap-nudge.js` writes to the log, does not inject context | plugin can't steer the model; relies on the `AGENTS.md` block being read | | **Cursor** | `--install-skill --platform cursor` + `.cursor/mcp.json` (below) | **MCP + docs** — `alwaysApply` rule + the MCP server | Cursor's own hooks aren't wired; the rule is advisory | | **Codex CLI** | `--install-skill --platform codex` | **live gate** — `.codex/config.toml` PreToolUse hook | denies only high-confidence structural greps; allow-fallback for logs/pipes/non-TS-JS; `AGENTMAP_CODEX_GATE=0` bypasses; needs a trusted dir + Codex hooks-GA | @@ -403,6 +411,10 @@ leaves the rest of your `AGENTS.md` / `GEMINI.md` intact. | Codex/Gemini nudge never fires | Codex's gate is opt-in — set `[features] hooks = true` in `.codex/config.toml` (`AGENTMAP_CODEX_GATE=0` disables it). Gemini needs the `BeforeTool` hook that `--install-skill` writes. | | Installed the wrong `agentmap` | This is **`@raymondchins/agentmap`** (npm scope) — not the unrelated unscoped `agentmap` packages. | | Cursor MCP tools missing | `--mcp` doesn't auto-wire Cursor; add the copy-paste `.cursor/mcp.json` from the matrix above and restart Cursor. | +| Hook works in your shell, not in the agent | Almost always **nvm**. Your interactive shell sources `~/.nvm/nvm.sh`; the git hook and the agent's tool runner do not, so `node` isn't on their `PATH`. Point the hook at an absolute node (`which node`) or install a system-wide node. | +| `JavaScript heap out of memory` | Raise the ceiling — the parse peaks and there is no in-process warning that can fire in time (the process dies inside a single call, with heap use still at ~40% one sample earlier). Re-run as `NODE_OPTIONS=--max-old-space-size=8192 npx @raymondchins/agentmap`. Repo **size is not the axis**: measured, a 252-file Next.js app peaks at 683 MB while 4,000 dependency-free files peak at 756 MB, because the dependency `.d.ts` closure (~300 MB, ~1,800 extra program files on a 393-file app) dominates. A small repo with heavy `@types` can need more than a large plain one. | +| Skill file looks out of date | Each installed skill dir carries a `.agentmap_version`. `agentmap --doctor` compares it against the running version and flags the drift; `--install-skill` again overwrites it. | +| `0 files mapped` | agentmap indexes `git ls-files --cached --others --exclude-standard`, so uncommitted files *are* included but **`.gitignore`d ones are not** — a source tree matched by an ignore rule maps to nothing, as does a directory that is not a git repo at all. Confirm with `git ls-files --others --exclude-standard \| head`. | --- @@ -752,6 +764,16 @@ top 10 ranked symbols (Aider-style): 0.015034 lib/errors.ts → ErrorCode (TypeAliasDeclaration) ``` +`map.json` persists the top 80. Asking for more re-ranks from the cached map rather than +truncating, so `--symbols 200` really does return 200 where the repo has them. When a repo +has fewer ranked symbols than you asked for, the header says so and `--json` carries +`requested` / `shown` / `truncated`: + +``` +$ node agentmap.mjs --symbols 200 +top 62 ranked symbols (Aider-style) — asked for 200, this repo only ranks 62: +``` + ### `--map [--tokens N] [--focus ]` — token-budgeted ranked digest The token-budgeted digest (Aider's killer feature): a ranked, files-and-symbols summary diff --git a/ROADMAP.md b/ROADMAP.md index 48ef074..3e9c585 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -65,10 +65,10 @@ Full research (with source URLs) is in the audit report — see *References* bel |---|---|---|---| | **1** | Trust & truth (security + honesty) | 1–2 d | ✅ **DONE** (pushed) | | **2** | Modularize for testability + backend seam | 2–4 d | ✅ **DONE** — all substantive tasks landed (map byte-identical, 189 tests). Deferred-optional: `lib/` file split + in-process MCP. | -| **3** | Dirty-tree performance | 3–5 d | ⬜ | -| **4** | Distribution & release hygiene | 2–3 d | 🟨 Mostly done — plugin/marketplace, MCP Registry listing, tag-triggered publish, and README trust markers shipped; `npx skills add` alignment + Cursor/Gemini hooks deferred | +| **3** | Dirty-tree performance | 3–5 d | 🟨 **Both cache tiers + visible skips + symbol cap shipped.** Of what remained, two items were **measured and refuted** (cross-product pruning drops 0 edges on all 5 repos tested; the heapUsed OOM warning cannot fire in time — built, measured, reverted). Post-commit locking shipped; its incremental half stays gated on Tier 2 going default-on | +| **4** | Distribution & release hygiene | 2–3 d | 🟨 Mostly done — plugin/marketplace, MCP Registry listing, tag-triggered publish, and README trust markers shipped. Release automation **closed without adopting changesets** (two lockstep tests instead); `npx skills add` alignment + Cursor/Gemini hooks still deferred | | **5** | TS-depth before language-breadth | weeks | 🟨 Mostly done — depth + resolution shipped; monorepo intelligence + symbol-PageRank deferred | -| **B** | Cross-cutting backlog (low-severity) | ongoing | ⬜ | +| **B** | Cross-cutting backlog (low-severity) | ongoing | ✅ **Swept 2026-07-27 — 20 done, 4 partial, 0 untouched.** Security, CI OS matrix, typecheck + coverage gates, ranking-ORDER tests, installer robustness, docs and housekeeping all landed. Several items turned out to be **already fixed with a stale checkbox**; the remaining partials each say what is left and why | **Legend:** ✅ done · 🟨 partial / mostly done · ⬜ not started @@ -167,7 +167,7 @@ exists. --- -## ⬜ Batch 3 — Dirty-tree performance +## 🟨 Batch 3 — Dirty-tree performance **Goal:** stop full-reparsing the whole repo on every query when the working tree is dirty. Agents work on dirty trees essentially always, so this is the #1 @@ -232,10 +232,20 @@ behavior into a real competitive claim vs CodeGraph's 2s sync. `./types` → `types.d.ts`, and for directory imports resolving via a nested `package.json` `"main"`. Trying ts-morph first with `resolveSpec` as fallback is already the correct design. *(performance/high)* -- [ ] **Incremental post-commit rebuild + lock** — `hooks/post-commit:67`: the - hook re-parses the entire repo on every commit and concurrent rebuilds duplicate - work with no locking. Diff `HEAD~1..HEAD` and re-parse only changed files + their - direct dependents; add a lockfile / compare-and-skip on in-progress HEAD build. +- [~] **Incremental post-commit rebuild + lock** — **LOCK DONE; incremental + deliberately NOT taken yet.** The locking half shipped: `hooks/post-commit` holds + a single-instance lock via atomic `mkdir` (`:92`), clears a lock orphaned by a + killed run after 10 minutes (`:95`) so one bad commit cannot wedge refresh + permanently, and caps the run with a process-tree kill. Concurrent rebuilds no + longer duplicate work. + The incremental half is **gated on Tier 2 going default-on**, and should stay + gated. Tier 2 (`AGENTMAP_INCREMENTAL=1`) is still EXPERIMENTAL because three + adversarial rounds left a residual isolated-reparse tail (`.d.ts` edges, + package.json `exports`, barrel+target). This hook ships to every consumer and runs + on every commit they make, so wiring it to an opt-in-because-not-yet-trusted path + would push exactly that tail onto people who never opted in — and a wrong map + written by a background hook is the hardest kind to notice. Revisit when Tier 2's + tail closes and it becomes the default dirty path. *(performance/medium — depends on Batch 2 incremental machinery)* - [ ] **Memory ceiling** — ⚠ **THE REMEDY IN THE ORIGINAL ITEM IS REFUTED. Do not implement it.** Measured on content-os (393 files), four loop variants doing @@ -256,19 +266,50 @@ behavior into a real competitive claim vs CodeGraph's 2s sync. closure (~300MB, ~1,800 extra program files on content-os) dominates and is independent of repo size. A file-count envelope would cry wolf on small dep-heavy repos and stay silent on the big repo it exists for. - **Still open, rescoped:** sample real `heapUsed` during the parse and print one - actionable warning (with the `--max-old-space-size` fix) before an OOM kills the - build with no map at all; document the measured envelope. *(performance/medium)* + **The rescoped remedy is now ALSO refuted — built, measured, reverted.** Sampling + `heapUsed` against `getHeapStatistics().heap_size_limit` during the parse loop + cannot fire in time, for two independent reasons measured on zod (409 files) under + a forced `--max-old-space-size=150`: + - **The fatal allocation is inside ONE file's work, not spread across files.** With + a probe printing on every iteration, the process died having logged **exactly one + sample** — it OOM'd during the first file's `getExportedDeclarations()`. Sampling + every 64 files logged **zero** samples before death. No sampling rate helps when + the granularity of the blow-up is finer than one loop iteration. + - **The reading immediately before death is not elevated.** That single sample read + `heapUsed` **104MB against a 246MB limit — 42%**, nowhere near any threshold + worth warning on. V8 keeps `heapUsed` low by collecting harder right up until it + gives up, so the ratio is flat and then the process is gone. + A guard that never fires is worse than none: it reads as protection in the source + and in a review. The implementation was therefore reverted (`agentmap.mjs` is + byte-identical to before it), and what survives is the half that is real — the + measured envelope and the `--max-old-space-size` remedy, documented in the README + Troubleshooting section where a user hitting the OOM will search for it. + **Still open:** nothing in-process. A supervisor that spawns the build and maps + exit 134 / `SIGABRT` to the remedy would work, and `mcp.mjs` already spawns + agentmap so it could do this for the MCP path — but the plain CLI has no parent, + and adding one is an architecture change, not a warning. *(performance/medium)* - [x] **Cap unbounded symbol matches** — DONE. `--find`/`--any` symbol matches are ranked by the containing file's PageRank and capped to `SYMBOL_MATCH_LIMIT` (50), with a "showing top N of M by pagerank — narrow your query" footer in prose and `total`/`shown`/`truncated` (`--find`) / `symbolsTotal`/`symbolsTruncated` (`--any`) in JSON. Ranking keeps the important matches when truncated. *(performance/medium)* -- [ ] **Prune rankSymbols cross-product** — `agentmap.mjs:736`: refs×defs edge - list per identifier is quadratic on duplicated export names. Skip identifiers - whose definer count exceeds a threshold (near-zero signal after the 0.1 - multiplier) or aggregate into per-defFile summary edges. *(performance/low)* +- [x] **Prune rankSymbols cross-product** — **CLOSED: REFUTED. Do not implement.** + Measured on five repos (agentmap 77 files, zustand 49, hono 385, zod 409, + a 392-file Next.js app) by replaying the edge-building loop over each cached map. + The cross-product is not quadratic in practice: the largest edge list is **2,533** + (hono), and the most definers any single identifier attracts is **17**. Pruning at + `defCount > 20` drops **0 edges on every one of the five**, so the threshold the + item implies is a no-op. Pruning low enough to matter is actively harmful — + `defCount > 5` would drop **78.7% of zod's graph**, including `util` + (12 definers × 34 referencing files), which is a real identifier the ranking wants. + The app-shaped repo, the case most likely to duplicate export names, peaked at + **3** definers (`NotificationsPage`) and 1,292 edges. + Two premises were wrong: `default` is already excluded from references + (`agentmap.mjs:1859`), which removes the one identifier that would genuinely fan + out, and `identMul`'s `RARE_PENALTY` already discounts the high-definer case it + proposed to delete. The real cost driver is `getExportedDeclarations()` at + ~O(N^2.7), recorded in the wall-clock-budget item above. *(performance/low)* **Acceptance:** a second query on an unchanged dirty tree does not re-parse; a pathological deep-chain repo finishes within the budget with skipped files @@ -313,9 +354,20 @@ discovered is trustworthy and fast. existed (the "zero tags" note was stale); still need the `NPM_TOKEN` repo secret + first GitHub Release. *(v0.10.0 published manually 2026-07-03 to close the RCE gap; future releases go through this workflow.)* -- [ ] **Release automation** (release-please / changesets) — structurally fixes - the recurring missing-CHANGELOG-entry problem (and the lockfile-version drift - just seen in `aa62353`). +- [x] **Release automation** (release-please / changesets) — **DECIDED: not + adopted, problem closed another way.** Both named symptoms were directly + checkable, so they are now checked in `test/version-lockstep.test.mjs`: a + `## []` section must exist in `CHANGELOG.md` for the version in + `package.json`, and `package-lock.json`'s two version fields must match it. + The CHANGELOG gap was genuinely uncovered — nothing anywhere asserted that the + shipped version had release notes. The lockfile one needed its own test because + **`npm ci` does not catch it**: it validates the dependency tree, not the root + package's own version, which is how `aa62353` shipped. Negative control: forcing + `package.json` to 9.9.9 fails 5 of the 8 lockstep tests. + Rejected because the cost is per-PR ceremony and a dependency tree, on a + solo-maintained repo whose stated identity is near-zero-deps — for a problem two + assertions cover. Revisit if this ever takes regular outside contributors, where + a changeset file per PR buys attribution and release notes that a test cannot. - [x] **README trust markers** — states "fully local, no network calls, no telemetry" (verified: zero `fetch`/`http` in `agentmap.mjs`/`mcp.mjs`) and the name-collision note (`npx agentmap` unscoped is an unrelated package; always use the scoped @@ -421,28 +473,53 @@ post-distribution demand asks for Python (Batch 2's seam makes it a 1–2 week a --- -## ⬜ Batch B — Cross-cutting backlog (low-severity, do opportunistically) +## ✅ Batch B — Cross-cutting backlog (swept 2026-07-27) + +> **Swept end-to-end on 2026-07-27**: 20 closed, 4 partial, none untouched. Three +> things worth carrying forward from the sweep: +> +> 1. **Several items were already fixed and only the checkbox was stale** — the +> expanded denylist, the MCP injection fence, the dead `statSync` import, the +> four `readPackageVersion` copies, and the SHA-pinned CI actions. Anchors like +> `agentmap.mjs:77` and `README.md:223` had all drifted. Re-verify before +> working an item from this file; the finding may predate its own fix. +> 2. **Two items were refuted by measuring them** (cross-product pruning, the +> heapUsed OOM warning) and one dependency premise was simply false +> (`typescript` does *not* come via ts-morph — `@ts-morph/common` vendors it). +> Each now carries the measurement, not just the verdict. +> 3. **The gaps that were real were mostly "the tool reports success while doing +> something else"** — `--symbols 200` printing "top 200" over 80 rows, the +> Gemini Windows install writing to a path nothing reads, a partial install +> left behind by a malformed config, JSONC comments deleted in silence. That is +> the class to keep hunting. ### Security -- [ ] **Expand sensitive-file denylist** — `agentmap.mjs:77`: Batch 1 fixed +- [x] **Expand sensitive-file denylist** — `agentmap.mjs:77`: Batch 1 fixed `*password*`; still missing `*token*`, `.npmrc`, `.netrc`, `.git-credentials`, `.pgpass`, `.htpasswd`, `.pypirc`, `id_ed25519*`, `id_ecdsa*`, `*.p8`, `*.jks`, `*.keystore`. Reconcile with SECURITY.md; extend the regression test. *(security/medium — note `*token*` over-excludes `tokenizer.ts` etc.; weigh it.)* -- [ ] **Prompt-injection fencing** — `agentmap.mjs:1655`: untrusted repo content +- [x] **Prompt-injection fencing** — `agentmap.mjs:1655`: untrusted repo content flows verbatim into agent context via `--any` content fallback + map digests through MCP. Wrap content/digest output in an untrusted-data fence in the MCP text result; strip control chars; document that `--any` lines are raw repo bytes. *(security/medium)* ### Tests & CI -- [ ] **OS matrix** — `.github/workflows/ci.yml:12` is ubuntu-only despite +- [x] **OS matrix** — `.github/workflows/ci.yml:12` is ubuntu-only despite Windows-specific code + Windows-targeting docs. Add `windows-latest` + `macos-latest` (single Node version each). *(tests/high)* -- [ ] **Ranking-quality tests** — `test/determinism.test.mjs:40` only asserts - determinism/set-membership, never *order*. Add fixtures with known in-degrees - (hubs[0] = most-imported; leaf never outranks it); add a CI step running - `eval/eval.mjs` with a min-accuracy threshold. *(tests/medium)* +- [~] **Ranking-quality tests** — **order tests DONE; the eval CI step is not, on + purpose.** `test/ranking-quality.test.mjs` asserts against a constructed star + (in-degrees 5/2/0): first place, monotonicity in degree, leaf-never-outranks-hub, + and the same check one level down on `--symbols`. The gap was as bad as recorded — + **proven** by inverting the hub comparator, which leaves `determinism.test.mjs` + reporting 2/2 green while the new suite fails 3 of 5. + The `eval/eval.mjs` min-accuracy CI step stays out: the eval **clones upstream + repos over the network**, so wiring it into CI makes every unrelated PR depend on + GitHub availability and on third-party repos that move. `EVAL.md` already records + it as "network required; not part of CI", and pinned SHAs make it reproducible on + demand. Run it at release time, not per push. *(tests/medium)* - [~] **Concurrency + e2e hook tests** — parallel-build half DONE, hook e2e still open. Writing the test found a real bug rather than confirming safety: `assemble()` used a FIXED tmp name (`map.json.tmp` / `map.dirty.json.tmp` / @@ -457,12 +534,35 @@ post-distribution demand asks for Python (Batch 2's seam makes it a 1–2 week a the defect. Still open: the shipped post-commit hook never runs e2e (`--install-hooks` without the hooksPath override → commit → `generatedSha === HEAD`). *(tests/medium)* -- [ ] **Lint/typecheck gate** — add `jsconfig.json` (checkJs+strict) + - `npx tsc --noEmit` (typescript already comes via ts-morph) + ESLint flat config; - fail CI on either. *(tests/medium)* -- [ ] **Coverage floor** — run under c8 in CI, enforce e.g. `--lines 70` so - unexecuted shipped files stay visible. *(tests/medium)* -- [ ] **Test env isolation** — `test/install-skill.test.mjs:84`: `--global` tests +- [x] **Lint/typecheck gate** — **typecheck DONE; ESLint deliberately NOT adopted; + the item's dependency premise was wrong.** "typescript already comes via ts-morph" + is false: `@ts-morph/common` **vendors** the compiler and exposes no `tsc` bin, so + this required adding `typescript` + `@types/node` as **dev** dependencies. That is + compatible with the near-zero-deps rule as written — it governs *runtime* deps and + the tarball, and `package.json` `files` is an allowlist, confirmed by + `npm pack --dry-run`. + **`strict` is OFF, measured, not conceded:** strict reports **505** errors, 272 of + them TS7006 ("annotate this parameter") — a rewrite, not a gate. Non-strict + reported **2**, and both were real drift: a JSDoc `@type` for `PLATFORMS` that had + fallen behind the object it describes (`codexHooks` missing, while the code reads + it), and a dynamic `import()` of a `URL` object TypeScript cannot follow. Both + fixed; the gate is clean and runs on every OS in the matrix. + Also verified that adding a `jsconfig.json` to agentmap's own root does not + perturb its self-map — byte-identical with and without it, which matters because + agentmap reads `jsconfig.json` when mapping a repo. + **ESLint: no.** Its remaining value here is stylistic — the correctness class it + would catch is what `checkJs` now covers — and it is a large dependency tree plus + a config to maintain, on a repo whose identity is near-zero-deps. Reconsider only + with outside contributors, where a shared style gate saves review time. + *(tests/medium)* +- [x] **Coverage floor** — DONE, **without c8**: `node --test` ships coverage and + threshold flags natively from Node 22, so `npm run coverage` needs no dependency at + all. Its own CI job pinned to Node 24, because the flags do not exist on Node 20, + which the test matrix still supports. Measured **93.77% lines / 76.12% branches**; + floors set at **90/70**, deliberately *below* current — a gate pinned to today's + number reddens on ordinary work and gets raised until nobody reads it. This is a + regression alarm, not a target. *(tests/medium)* +- [x] **Test env isolation** — `test/install-skill.test.mjs:84`: `--global` tests hit the real `$HOME`; git tests inherit host git config. Add `opts.env` to `helpers.run()`, route through a fake HOME, set `GIT_CONFIG_GLOBAL=/dev/null`. *(tests/low)* @@ -471,53 +571,82 @@ post-distribution demand asks for Python (Batch 2's seam makes it a 1–2 week a - [x] **Claude nudge npx path** — `hooks/agentmap-nudge.mjs:116` tells the agent to run a `node_modules/...` path that doesn't exist for npx/global installs. Recommend `npx @raymondchins/agentmap --any` (as the Gemini nudge does). *(medium)* -- [ ] **Windows global Gemini path** — `skills/install.mjs:75` writes to +- [x] **Windows global Gemini path** — `skills/install.mjs:75` writes to `~/.agents/GEMINI.md`, which Gemini CLI never reads. Drop the win32 special case. *(medium)* -- [ ] **`--symbols N` silent cap** — `agentmap.mjs:1780` caps at 80 while claiming +- [x] **`--symbols N` silent cap** — `agentmap.mjs:1780` caps at 80 while claiming N. Recompute or clamp the printed count with a note. *(low)* -- [ ] **Installer robustness** — `skills/install-helpers.mjs:83`: opaque TypeError +- [x] **Installer robustness** — `skills/install-helpers.mjs:83`: opaque TypeError when an existing `hooks` key isn't an array → partial install. Validate shapes up front; validate all platforms before writing any file. *(low)* -- [ ] **JSONC comment preservation** — `skills/install-helpers.mjs:104`: rewriting +- [x] **JSONC comment preservation** — `skills/install-helpers.mjs:104`: rewriting `settings.json` strips comments silently. Surgical splice, or warn. *(low)* ### Docs / benchmark honesty -- [ ] **Benchmark headline** — `README.md:63`: only Scenario F's skew is disclosed; +- [x] **Benchmark headline** — `README.md:63`: only Scenario F's skew is disclosed; Scenario D also inflates the total (excluding both → ~89.8% / ~10× on ai-chatbot). Add the D+F-excluded figure; validate chars/4 once against a real tokenizer; re-run `npm run eval` post-0.8.0 and refresh dates/numbers. *(medium)* -- [ ] **Blast-radius row footnote** — `benchmark/RESULTS.md:26`: the 99.2% row is +- [x] **Blast-radius row footnote** — `benchmark/RESULTS.md:26`: the 99.2% row is contradicted by EVAL.md (agentmap wins precision, loses tokens vs `grep -l`). Footnote it or add a `grep -l` baseline to `bench.mjs`. *(high, docs-only)* -- [ ] **Onboarding matrix + uninstall + troubleshooting** — `README.md:223`: add a +- [x] **Onboarding matrix + uninstall + troubleshooting** — `README.md:223`: add a per-CLI "commands to full loop / enforcement vs docs-only" matrix, a copy-paste `.cursor/mcp.json`, an **Uninstall** section listing every file the installers touch (there's no `--uninstall` command — consider adding one), and a Troubleshooting section (nvm PATH, 0-files-mapped, stale skills via `--doctor`). *(medium)* -- [ ] **Benchmark realism** — `benchmark/bench.mjs:26`: add wall-clock (cold/warm/ - dirty), include a 3–5k-file repo, report the excluding-F total. *(low)* -- [ ] **Competitor table** — `README.md:101`: Batch 1 fixed Aider's install; still +- [~] **Benchmark realism** — `benchmark/bench.mjs`: **the excluding-F total is + DONE** (see the Benchmark-headline item above — `RESULTS.md` now carries an + `Excl. D+F` column per repo plus a pooled row, and the D+F skew is stated in the + README). Still open: wall-clock (cold/warm/dirty) and a 3–5k-file fixture, both of + which need a new pinned repo and a change to what `bench.mjs` measures rather than + how it reports. *(low)* +- [x] **Competitor table** — `README.md:101`: Batch 1 fixed Aider's install; still update Repomix's agent-loop cell to "MCP server (no auto-refresh/nudge)", link every row to its repo, add an "as of \" footnote. *(low)* ### Housekeeping (from the completeness critic) -- [ ] Dead `statSync` import (`agentmap.mjs:16`); `readPackageVersion` implemented - 4× with divergent failure behavior — unify once modularized. *(low)* -- [ ] Duplicated recursive dir walk between `sourceFingerprint()` and `makeProject()` - (`agentmap.mjs:431`) — extract one `walkSources()`. *(low)* +- [x] Dead `statSync` import (`agentmap.mjs:16`); `readPackageVersion` implemented + 4× with divergent failure behavior. **Both were already fixed and only the + checkbox was stale** — verified 2026-07-27: `statSync` is absent from the import + list, and `readPackageVersion` is one definition with two callers plus an export, + not four divergent copies. *(low)* +- [x] Duplicated recursive dir walk between `sourceFingerprint()` and `makeProject()` + — extracted as `walkSources(dir, onFile)`. The two copies were identical except + for the leaf action, with four load-bearing, non-obvious safety rules restated in + both (depth cap 40, per-directory try/catch, `lstatSync` not `statSync`, skip + symlinks) — so a fix could land in one and not the other. The reasoning for each + rule now lives once, at the shared function. Verified rather than assumed: 43 + vue-sfc tests green, a tree containing a circular *and* a dangling symlink still + terminates with exit 0, and a non-git `.vue` tree still resolves its import + edge. *(low)* - [x] Node 18 is past EOL (Apr 2025) but in `engines` + CI matrix — decide support policy. Resolved: `engines` is `>=20` and the matrix is `[20, 22, 24]`. The floor is set by the dependency tree, not by EOL dates — `brace-expansion` (via ts-morph → @ts-morph/common → minimatch) declares `20 || >=22`, so `>=18` was a claim the tree contradicted. `dependabot.yml` now covers npm + Actions, with `ts-morph` on the weekly update path. -- [ ] Community health files: no `.github/ISSUE_TEMPLATE`, PR template, - `CODE_OF_CONDUCT.md`, `FUNDING.yml`. CI Actions pinned by mutable tags (`@v5`), - not SHA — a hardening gap the SECURITY.md advertises. -- [ ] Consider a neutral `.agentmap/` cache path (currently `.claude/agentmap/` - even for Gemini/Codex/Cursor users) with back-compat. +- [~] Community health files. **Mostly done; one item deliberately left to the + maintainer.** `.github/ISSUE_TEMPLATE/` and `PULL_REQUEST_TEMPLATE.md` already + existed, and CI Actions are already SHA-pinned (`@3d3c42e5…`, `@820762786…`, + `@e4fba868…`, `@e0c47f4f…`) — that half of the finding was stale. + `CODE_OF_CONDUCT.md` added: it links the Contributor Covenant rather than copying + a text that would then drift, and says explicitly that being told a patch is wrong, + or that a number does not reproduce, is the process working — this roadmap closes + items as *refuted* in writing, and that norm should be stated rather than + discovered. **No `FUNDING.yml`:** sponsorship handles are the maintainer's call + and are not something to invent on their behalf. +- [x] Consider a neutral `.agentmap/` cache path (currently `.claude/agentmap/` + even for Gemini/Codex/Cursor users). **DECIDED: no.** The complaint is real — the + path names one vendor and every other platform's users inherit it — but it is + cosmetic, and the change is not. `.claude/agentmap/` is written by the post-commit + hook in every consumer repo, gitignored by `--install-hooks`, read by `--doctor`, + and named across README, SECURITY.md and hooks/INSTALL.md. Moving it means a + dual-read back-compat path that must then be carried indefinitely, on a tool whose + central invariant is that a query never returns a stale map — i.e. new ways to + read the wrong cache, bought with no capability. Recorded so it is not reopened + from scratch; reconsider only if a platform actually refuses the path. - [x] `--export dot|mermaid` — file import graph → Graphviz DOT / Mermaid, top-N by pagerank, 3 style tiers, `--focus` scopes to a neighborhood; reads the cached map (no ts-morph Project). (Call-graph closure export = future v2.) diff --git a/SECURITY.md b/SECURITY.md index 1b66789..9494941 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,6 +56,13 @@ When no graph match is found, agentmap falls back to a live `git grep` over trac This denylist is a best-effort guard for conventionally-named secret files, not a guarantee — a secret stored in an unmatched filename can still be surfaced. (It deliberately does **not** match a bare `token` substring, which would over-exclude ordinary source like `tokenizer.ts`.) agentmap does **not** transmit file contents anywhere; all processing is local. +**Matched lines are untrusted repository bytes**, and are the only place agentmap echoes repository content back out — everything else it prints is its own metadata. Two guards apply: + +- **Terminal control sequences are neutralised.** C0 control characters and `DEL` are replaced with `U+FFFD` before the lines are printed or serialised, on both the prose and `--json` surfaces. Tab and newline are preserved. Without this, a text file carrying `ESC[2J` or a cursor-up run could blank the terminal or overwrite the `file:line` prefix so a hit appears to come from a file it did not. +- **Over MCP, the lines are fenced as data.** The `any` tool appends a second content block marking the result as raw untrusted repository content, so a planted "ignore previous instructions" in an ordinary source or markdown file reads to the model as data rather than as a command. Structural results (file / symbol / feature) are agentmap's own metadata and are not fenced; the CLI path writes to a terminal, not to a model, and is not fenced either. + +Neither guard makes untrusted repository content safe to execute. They reduce the two ways a matched line can act on something other than the reader's eyes. + ### Trust boundaries | Boundary | Notes | diff --git a/agentmap.mjs b/agentmap.mjs index 3e82d02..10fae55 100755 --- a/agentmap.mjs +++ b/agentmap.mjs @@ -277,9 +277,23 @@ const SENSITIVE_EXCLUDES = [ // password.txt / passwords.json is excluded, not just foo.password.ts. ":(exclude,icase)*secret*", ":(exclude,icase)*credential*", ":(exclude,icase)*password*", ]; +// Neutralise terminal control sequences in content-search output. These lines are +// the ONLY place agentmap echoes raw repository bytes back out — everything else it +// prints is its own metadata. `git grep -I` already skips binary files, so what is +// left is a TEXT file with escapes deliberately embedded in it: an ESC[2J or a +// cursor-up run can blank the terminal or overwrite the file:line prefix, making a +// hit appear to come from a file it did not. Replaces C0 controls (keeping \t) and +// DEL with U+FFFD, so the line count and column alignment survive and the escape +// becomes visible instead of executable. Applied inside contentSearch() rather than +// at the two print sites, so prose AND --json get the same sanitised bytes — a JSON +// consumer that parses and echoes a line is exposed to exactly the same trick, and +// MCP's JSON.stringify escaping protects the model but not that consumer. +// eslint-disable-next-line no-control-regex +const CONTROL_CHARS = /[\x00-\x08\x0B-\x1F\x7F]/g; +const sanitizeContentLines = (s) => s.replace(CONTROL_CHARS, "�"); const contentSearch = (q) => { try { - return execFileSync("git", ["-c", "core.quotePath=off", "grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/agentmap/", ...SENSITIVE_EXCLUDES], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).trim(); + return sanitizeContentLines(execFileSync("git", ["-c", "core.quotePath=off", "grep", "-F", "--untracked", "-n", "-i", "-I", "-e", q, "--", ".", ":!.claude/agentmap/", ...SENSITIVE_EXCLUDES], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: MAXBUF }).trim()); } catch { return ""; } }; const currentSha = () => sh("git rev-parse --short HEAD"); @@ -424,30 +438,39 @@ function bm25Search(lexical, files, rawQuery, { limit = SYMBOL_MATCH_LIMIT } = { // without a full reparse. Skips node_modules/.git/.next. Any error ⇒ "" (caller // falls through to build, i.e. current behavior). Never used on the git path. // SOURCE_EXT_RE includes `.vue` so editing a Vue SFC invalidates the cache too. +// The one recursive source walk. Two callers needed exactly this traversal and +// differed only in what they do with a file, so it existed twice with the safety +// rules restated in both — the failure mode being a fix applied to one copy. The +// rules are load-bearing and each is here for a specific reason: +// • depth cap 40 — don't fully walk a pathologically deep tree; +// • per-directory try/catch — one permission-denied subdir must NOT abort the +// WHOLE walk. In sourceFingerprint() that would return "" and silently +// disable caching, which looks like a performance mystery, not an error; +// • lstatSync, NOT statSync, so a symlink reports as itself rather than its +// target, and symlinked entries are skipped entirely — never recursed into, +// never stat'd through — so a circular symlink cannot recurse until the +// stack overflows; +// • node_modules/.git/.next pruned before any stat. +// `onFile(fullPath, name, stat)` is called for every non-directory survivor. +function walkSources(dir, onFile, depth = 0) { + if (depth > 40) return; + let names; try { names = readdirSync(dir); } catch { return; } + for (const name of names) { + if (name === "node_modules" || name === ".git" || name === ".next") continue; + const full = dir + "/" + name; + let st; try { st = lstatSync(full); } catch { continue; } + if (st.isSymbolicLink()) continue; + if (st.isDirectory()) walkSources(full, onFile, depth + 1); + else onFile(full, name, st); + } +} + function sourceFingerprint() { try { const entries = []; - const walk = (dir, depth) => { - if (depth > 40) return; // depth cap — don't fully walk a pathologically deep tree - // per-directory try/catch: a single permission-denied subdir must NOT abort - // the WHOLE walk (that would return "" and silently disable caching) — skip - // the unreadable dir and keep going so the fingerprint stays usable. - let names; try { names = readdirSync(dir); } catch { return; } - for (const name of names) { - if (name === "node_modules" || name === ".git" || name === ".next") continue; - const full = dir + "/" + name; - let st; - // lstatSync (NOT statSync) so a symlink reports as a symlink instead of - // its target. Symlinked entries are SKIPPED entirely — never recursed - // into, never stat'd through — so a circular symlink can't cause infinite - // recursion / stack overflow. - try { st = lstatSync(full); } catch { continue; } - if (st.isSymbolicLink()) continue; - if (st.isDirectory()) walk(full, depth + 1); - else if (SOURCE_EXT_RE.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`); - } - }; - walk(".", 0); + walkSources(".", (full, name, st) => { + if (SOURCE_EXT_RE.test(name)) entries.push(`${full}:${st.mtimeMs}:${st.size}`); + }); entries.sort(); return createHash("sha1").update(entries.join("\n")).digest("hex"); } catch { return ""; } @@ -1136,23 +1159,11 @@ function makeProject(inc = null) { `components/**/*.${g}`, `lib/**/*.${g}`, `pages/**/*.${g}`, `*.${g}`, ]); - // Non-git `.vue` fallback: walk the tree like sourceFingerprint() does. + // Non-git `.vue` fallback: same traversal as sourceFingerprint(), different leaf. try { - const walk = (dir, depth) => { - if (depth > 40) return; // depth cap, matching sourceFingerprint() - let names; try { names = readdirSync(dir); } catch { return; } // skip unreadable dir, don't abort the whole walk - for (const name of names) { - if (name === "node_modules" || name === ".git" || name === ".next") continue; - const full = dir + "/" + name; - // lstatSync (NOT statSync) + skip symlinks, matching sourceFingerprint(): - // a circular symlink would otherwise recurse until the stack overflows. - let st; try { st = lstatSync(full); } catch { continue; } - if (st.isSymbolicLink()) continue; - if (st.isDirectory()) walk(full, depth + 1); - else if (name.endsWith(".vue")) vueFiles.push(full.replace(/^\.\//, "")); - } - }; - walk(".", 0); + walkSources(".", (full, name) => { + if (name.endsWith(".vue")) vueFiles.push(full.replace(/^\.\//, "")); + }); } catch { /* ignore — proceed without Vue */ } } // Build the virtual→real map and register each `