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 `