Skip to content

chore: sweep the Batch B backlog — security, CI gates, ranking tests, honesty fixes - #46

Merged
raymondchins merged 12 commits into
mainfrom
chore/roadmap-backlog-sweep
Jul 27, 2026
Merged

chore: sweep the Batch B backlog — security, CI gates, ranking tests, honesty fixes#46
raymondchins merged 12 commits into
mainfrom
chore/roadmap-backlog-sweep

Conversation

@raymondchins

Copy link
Copy Markdown
Owner

Works the open backlog end to end. 11 commits, each independently reviewable.

What landed

Area Change
Benchmark honesty Disclose the D+F skew (Excl. D+F column: 89.8% / 96.4% / 73.1%, 93.7% pooled), reconcile the blast-radius row with EVAL.md, validate chars/4 against a real tokenizer
Security Neutralise C0/DEL control sequences in content-search output on both surfaces; document the two content-search guards
CI Windows + macOS added to the test matrix; typecheck and coverage gates added
Correctness --symbols N no longer claims a count it did not return; Gemini global install on Windows no longer writes to a path nothing reads; a malformed config can no longer leave a half-installed skill; JSONC comment loss is now reported
Tests Ranking order assertions, env isolation, release-hygiene gates
Housekeeping One walkSources() instead of two copies; CODE_OF_CONDUCT.md

475 → 490 tests. Typecheck clean. Coverage 93.77% lines / 76.13% branches.

Two items were refuted, not implemented

  • Prune rankSymbols cross-product — measured on 5 repos (49–409 files). Largest edge list 2,533; most definers for one identifier 17. Pruning at defCount>20 drops 0 edges on every repo; pruning low enough to matter would delete 78.7% of zod's graph including real identifiers.
  • Memory-ceiling warning — built, measured, reverted. Under a forced 150 MB ceiling a probe firing every iteration logged one sample before the OOM, reading 42% of the limit. The process dies inside a single getExportedDeclarations(), finer than one loop iteration. No sampling rate or threshold reaches it. agentmap.mjs is byte-identical to before the attempt.

Declined, with reasoning recorded in ROADMAP

changesets (two lockstep tests cover both named symptoms), ESLint (checkJs covers the correctness class), FUNDING.yml (maintainer's call), a neutral .agentmap/ cache path (permanent dual-read path for a cosmetic gain), and an eval step in CI (it clones upstream repos).

Known gap, stated rather than papered over

Where a platform refuses file symlinks, the realpathSync branch of the entry guard is uncovered. Verified by negative control: disabling that branch fails the five symlink tests while the non-canonical-path test still passes, because Node resolves argv[1] before setting it. The comments say so.

Why this is a PR

AGENTS.md requires it for code changes — and Windows has never run this suite, so CI here is the actual verification.

…h EVAL

The 98.3% headline is carried by two of its seven rows (repo dump and blast
radius). Only the repo-dump skew was disclosed. Adds the excl-D+F column to
every run — 89.8% ai-chatbot, 96.4% zod, 73.1% taxonomy, 93.7% pooled — so the
routine-case figure is readable without recomputing it.

Also reconciles the two files that looked like they contradicted each other:
RESULTS.md prices scenario D against an agent that cats all 65 dependents,
EVAL.md prices --relates against grep -l. Both are true; the cheaper baseline
is cheaper because it is 59.9% precise. Stated from both sides.

Validates chars/4 against o200k_base for the first time on the three pinned
eval repos: absolute error is 1.1-12.5% (wider than the +/-10% claimed), but
the published saved-% moves only 0.03-0.08pp. Caveat corrected to match. The
tokenizer was installed outside the repo; package.json is unchanged.
…h output

Content-search lines are the only raw repository bytes agentmap echoes back;
everything else it prints is its own metadata. git grep -I skips binary files,
so what survives is a TEXT file with escapes embedded on purpose: ESC[2J blanks
the terminal, and a cursor-up run can overwrite the file:line prefix so a hit
appears to come from a file it did not.

C0 controls and DEL are now replaced with U+FFFD inside contentSearch(), so
prose and --json get the same sanitised bytes. Tab and newline are kept -
stripping tabs would mangle every tab-indented repo. Doing it at the source
rather than at the two print sites matters because a --json consumer that
parses and echoes a line is exposed to the same trick; MCP's JSON.stringify
protects the model, not that consumer.

Test asserts both surfaces and is proven to fail with the sanitiser stubbed
out. A second test pins the tab carve-out.

Also documents the two content-search guards in SECURITY.md - the MCP data
fence was implemented but never written down.
The 477 tests had only ever run on ubuntu, while the code carries real
per-platform branches (win32 paths in skills/install.mjs, path separators
through the resolver) and the docs target Windows. Node versions still fan out
on Linux only; Windows and macOS get one pinned version each via include, so
this adds two jobs rather than turning a 3-version matrix into nine.

Guards the one file that cannot work there: post-commit-hook.test.mjs drives a
POSIX /bin/sh script by spawning sh directly. Git for Windows runs that hook
through its own bundled bash, so the hook is not what is uncovered on win32 -
the harness is. Skipped with that reason attached rather than reshaped into
something that passes without executing anything.

install-smoke stays Linux-only and now says why in the file: every step is a
bash script with absolute /tmp paths, and a half-ported PowerShell version that
quietly skips a step is worse than a stated gap.
…installs

Four separate correctness gaps, all of the same shape: the tool reported
success while doing something other than what it said.

--symbols N: map.json persists only the top 80, so --symbols 200 printed
"top 200 ranked symbols" above at most 80 rows. n beyond what was persisted now
re-ranks from the cached file map - the same ts-morph-free recompute --map
--focus already does - so 200 returns 200 where the repo has them. Verified on
zod: 200 requested, 200 returned. Where a repo genuinely ranks fewer, the header
says so and --json carries requested/shown/truncated.

Gemini global install on Windows wrote to ~/.agents/, which is the Amp/legacy
convention; Gemini CLI resolves global config under homedir()/.gemini on every
platform and never reads .agents. Two files written where nothing loads them,
reported as installed. The win32 branch is gone - one path everywhere.

settings.json with a non-object "hooks" threw ".some is not a function" partway
through a multi-platform install, after earlier platforms were already on disk.
Now validated with an error naming the file and the key, and installSkill()
preflights every platform in dry-run mode before the first write, so a bad
config leaves the repo untouched instead of half-installed.

JSONC comments were silently deleted on rewrite, since JSON.stringify cannot
round-trip them. Still deleted - a real splice needs a JSONC writer - but now
the user is told which file to check.

5 tests, README updated for the --symbols contract change.
None of the three produced code, and two of them should not.

Prune rankSymbols cross-product: refuted on five repos (49-409 files, including
a Next.js app, the shape most likely to duplicate export names). Largest edge
list is 2,533; the most definers any identifier attracts is 17. Pruning at
defCount>20 drops zero edges everywhere, and pruning low enough to matter would
delete 78.7% of zod's graph including real identifiers. Two of the item's
premises were already false: default is excluded from references, and
identMul's RARE_PENALTY already discounts the case it wanted removed.

Memory ceiling: the rescoped heapUsed sampler was built, measured and reverted.
Under a forced 150MB ceiling on zod, a probe firing every iteration logged
exactly ONE sample before the OOM - the process dies inside a single
getExportedDeclarations(), finer than one loop iteration - and that last sample
read 42% of the limit, because V8 collects harder until it gives up. No rate and
no threshold can catch it. A guard that never fires still reads as protection,
so it is gone; agentmap.mjs is byte-identical. The measured envelope and the
--max-old-space-size remedy go to README Troubleshooting instead.

Post-commit hook: the lock half already shipped (atomic mkdir, 10-minute orphan
clear, process-tree kill). The incremental half stays gated on Tier 2 becoming
default-on - it is still experimental with a known reparse tail, and this hook
runs on every commit in every consumer repo.
…ge gates

Ranking quality had no guard. determinism.test.mjs proves two builds agree and
that a known file appears somewhere in hubs — invert the hub comparator and both
assertions still pass. Proven: with .sort() reversed, determinism reports 2/2
green while the new ranking-quality suite fails 3 of 5. Fixture is a constructed
star (in-degrees 5/2/0) so first place, monotonicity and leaf-vs-hub are all
knowable in advance, plus the same check one level down on --symbols.

Env isolation: the two --global tests resolved paths against whatever $HOME the
runner had, and now go through runWithHome() and assert the fake home appears in
the output. git() no longer inherits the developer's ~/.gitconfig or
/etc/gitconfig — GIT_CONFIG_GLOBAL/SYSTEM point at a path that does not exist,
rather than /dev/null, which is not a path on the Windows runner just added.

Typecheck gate: the roadmap assumed typescript already came via ts-morph. It
does not - @ts-morph/common vendors it with no tsc bin - so this adds typescript
and @types/node as DEV dependencies. The near-zero-deps rule governs runtime
deps and the tarball; neither ships (package.json files is an allowlist, and
npm pack --dry-run confirms). strict is off with a measured reason recorded in
jsconfig.json: strict reports 505 errors, 272 of them 'annotate this parameter'.
Non-strict reported 2, both real drift - a JSDoc @type for PLATFORMS that had
fallen behind the object, and a dynamic import TS could not follow. Both fixed.
Verified jsconfig.json does not perturb agentmap's own map: byte-identical
with and without it.

Coverage floor uses node --test's built-in coverage, not c8 - one less dep.
Measured 93.77% lines / 76.12% branches; floors set at 90/70 so the gate is a
regression alarm rather than a target that reddens on ordinary work. Own CI job
pinned to Node 24 because the threshold flags do not exist on Node 20.

ESLint deliberately NOT added - reasoning recorded in ROADMAP.
The onboarding matrix, uninstall table and troubleshooting section already
existed - that roadmap item's anchors were stale. What was actually wrong or
absent:

The Gemini row claimed the nudge fires on AfterTool. It does not: the hook is
registered on BeforeTool and emits a top-level systemMessage. What Gemini drops
on BeforeTool is hookSpecificOutput.additionalContext, which is why the nudge
used to vanish - the row had inverted the cause and the cure.

Four troubleshooting rows added, each for a failure with no visible cause:
nvm (the hook and the agent do not source nvm.sh, so node is absent from a PATH
that works fine in your shell); the OOM, with the measured envelope showing repo
size is the wrong axis - a 252-file app peaks at 683MB against 756MB for 4,000
dependency-free files, because the .d.ts closure dominates; stale skill
detection, which --doctor already reports and nothing documented; and 0-files,
stated precisely as git ls-files --cached --others --exclude-standard, so the
gitignore case is obvious rather than mysterious.

Competitor table: every row now links to its project, Repomix's agent-loop cell
corrected from 'No' to 'MCP server (no auto-refresh, no nudge)', and an as-of
date with an invitation to file drift as a bug.
The recursive tree walk existed twice - sourceFingerprint() and makeProject()'s
non-git .vue fallback - identical except for what each does with a file, with
every safety rule restated in both copies. Four of those rules are load-bearing
and non-obvious (depth cap, per-directory try/catch, lstat-not-stat, skip
symlinks), so two copies meant a fix could land in one and not the other.
Extracted as walkSources(dir, onFile) with the reasoning written down once.

Verified rather than assumed: 43 vue-sfc tests green (the fallback walk is what
they exercise), a repo containing a circular AND a dangling symlink still
terminates with exit 0, and a non-git .vue tree still resolves its import edge.

Two other housekeeping items were already done and only the checkbox was stale:
the dead statSync import is gone, and readPackageVersion is one definition with
two callers, not the four divergent copies recorded. CI actions were already
SHA-pinned.

Adds CODE_OF_CONDUCT.md - links the Contributor Covenant rather than copying it,
and states explicitly that being told a patch is wrong or that a number does not
reproduce is the process working, since this repo closes roadmap items as
refuted in writing. No FUNDING.yml: sponsorship handles are the maintainer's
call, not something to invent.
ROADMAP proposed release-please or changesets to fix the recurring
missing-CHANGELOG-entry problem and the lockfile-version drift. Both are
directly checkable, so this closes the item with two tests instead of a
dependency tree and a changeset file on every PR.

The CHANGELOG gap was real and completely uncovered: nothing anywhere asserted
that the shipped version has release notes. package.json always holds the LAST
released version, so the check holds continuously rather than only at release
time - bumping without writing the section now fails in npm test, not after the
notes are already public.

The lockfile check exists because npm ci does NOT catch this: it validates the
dependency tree, not the root package's own version, which is how a lockfile
declaring the previous version in two places shipped once already.

Negative control: with package.json forced to 9.9.9, 5 of the 8 lockstep tests
fail, including both new ones.
The status table said Batch 3 and Batch B were not started. Batch 3 had 4 of 7
already done; Batch B is now 20 done, 4 partial, 0 untouched.

Records what the sweep actually found, because it changes how this file should
be read. 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, the SHA-pinned actions - and their file:line anchors
had drifted, so a finding here can postdate its own fix. Two items were refuted
by measurement rather than implemented, and one dependency premise was simply
false: typescript does not come via ts-morph.

Decisions recorded so they are not reopened from scratch: no changesets (two
lockstep tests cover both named symptoms without a dependency tree or per-PR
ceremony), no ESLint (checkJs covers the correctness class; the rest is style),
no FUNDING.yml (the maintainer's call), no neutral .agentmap/ cache path (a
cosmetic gain bought with a permanent dual-read path, on a tool whose central
invariant is never serving a stale map), and no eval step in CI (it clones
upstream repos, so it would make every PR depend on third-party availability).

Each refuted or declined item carries its measurement or its reasoning, not just
a verdict.
Adding windows-latest to the matrix is worthless if the suite cannot run there,
and dishonest if the gaps are not named.

bin-symlink tests now probe whether the platform can create a file symlink at
all, by trying it, rather than branching on process.platform - an elevated
Windows runner can, and there is no reason to give up the coverage when it can.
Where it cannot, they skip with a reason: npm ships .cmd shims there, so argv[1]
IS the target and the guard's fast path settles it.

The realpathSync branch is then genuinely UNCOVERED on such a platform, and the
comments now say so. That correction came from a negative control: disabling the
realpath comparison fails the five symlink tests while the new non-canonical-path
test still passes, because Node resolves argv[1] before setting it and the
string-equality fast path answers first. The first draft of that test claimed to
cover the branch. It does not, and a test file asserting coverage it does not
have is the exact failure this whole sweep has been about.

What the new test does cover, everywhere: invocation through a path a human or a
wrapper composed rather than one npm generated. It asserts OUTPUT, never status,
because exit 0 with empty stdout is how the original bug hid for 12 releases.

Last POSIX-ism gone: core.hooksPath pointed at /dev/null, which works on Windows
only by accident (git looks for a \dev\null directory and finds none). Now the
same nonexistent-path sentinel used for GIT_CONFIG_GLOBAL/SYSTEM.
Comment thread test/version-lockstep.test.mjs Fixed
Both were caught by the gates added in this branch, on their first real run.

CodeQL (high, js/incomplete-sanitization): the CHANGELOG check built a RegExp
from VERSION escaping only '.', which leaves every other metacharacter and the
backslash itself unescaped. Not exploitable - VERSION is pinned to bare semver
by the test above it, from our own package.json - but a half-escape is exactly
the thing that gets copied somewhere it does matter. Now parses each heading and
compares strings, so nothing needs escaping. Negative control re-run: forcing
9.9.9 still fails it, and the diagnostic now lists real versions since (?!#)
stops ### subheads reporting as '#'.

Windows: 'importing agentmap.mjs still executes nothing' passed a bare drive
path to dynamic import - ERR_UNSUPPORTED_ESM_URL_SCHEME, protocol 'd:'. The
file:// template next to it was equally broken there. pathToFileURL handles the
drive letter and separators everywhere. Pre-existing; it took a Windows runner
to surface it, which is the point of adding one.

Also worth recording: the runner CAN create symlinks, so all five symlink tests
ran rather than skipping, and the new non-canonical-path test passed there too.
@raymondchins
raymondchins merged commit 9d62fd1 into main Jul 27, 2026
13 checks passed
@raymondchins
raymondchins deleted the chore/roadmap-backlog-sweep branch July 27, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants