codeweb changelog — every release, in lock-step
codeweb follows Keep a Changelog and Semantic Versioning. New capabilities, papers, and fixes are recorded here and shipped as tagged GitHub releases — never lost in commit history.
[Unreleased]
Fixed
- The mcp-registry workflow now chains off the release workflow via
workflow_run(gated on
success) — its previous release: published trigger could never fire, because events created
with GITHUB_TOKEN don't trigger workflows. codeweb's first registry listing
(io.github.GhostlyGawd/codeweb, v0.12.0) was published by manual dispatch; future releases
republish automatically.
[0.12.0] - 2026-07-25
Added
- MCP-registry publishing is automated (
.github/workflows/mcp-registry.yml): every
published release now submits server.json to registry.modelcontextprotocol.io via GitHub
Actions OIDC — no browser login, no stored secret. The server name and the npm package's new
mcpName field carry the canonical io.github.GhostlyGawd/codeweb identity (casing matched
to the namespace's existing registry listings); this release is the first whose npm tarball
ships the mcpName ownership handshake the registry requires.
[0.11.0] - 2026-07-25
Added
- A terminal-editorial redesign of the whole site (owner-approved mock). Near-black paper,
JetBrains Mono everywhere, a Doto dot-matrix wordmark and numerals, square corners, film
grain, viewfinder frame — and one accent, spent only on meaning. The homepage opens on the
live axios map with merge() pre-selected and its 56-function blast radius lit in
chartreuse; the map itself went dithered (pixel-square nodes, stippled edges, HUD crosshairs).
Benchmark stats render as pixel-block meters (grep 44% vs codeweb 74%). Fonts are
self-hosted, same-origin; the social-preview card matches.
- The report UI itself is redesigned to the same system. Domain bubbles became dithered
square blocks with their symbol count set inside; edges are straight stippled strokes with an
accent "blast" state, so a selection's impact reads at a glance; the selected node wears the
accent with a viewfinder frame. The masthead is a mono-caps HUD (pixel glyph, boxed tabs,
square search and buttons), findings badges are monochrome (accent = high-confidence only),
and every place a hue used to carry data — domain slots, matrix cells, treemap density — now
uses a luminance ramp, which survives every kind of color-vision deficiency by construction.
Dark is the default; light is an explicit paper mode and auto follows the OS. Labels get a
deterministic screen-space collision cull, so dense graphs stop rendering as label mush.
- A README banner in the brand language (
assets/brand/banner.png): Doto wordmark, pixel
glyph, caps tagline, and a dithered mini-map with one accent-selected node. The pipeline
SVGs (demo.svg, pipeline.svg) and the homepage stage illustrations are redrawn in the
same square/stipple/single-accent language.
- An unlisted npm-downloads dashboard at
/downloads.html: daily download counts of
@ghostlygawd/codeweb over time, fetched client-side from npm's public API and drawn as a
dependency-free SVG. Built with the site but excluded from the sitemap and linked from nowhere.
Changed
- One ratified identity, on every surface (
CHARTER.md, new at the repo root, with a
CLAUDE.md pointer so agents read it first). The job line — "Your agents break less code
and burn fewer tokens." — now leads the README, the site homepage, tagline, and og titles,
and the npm / plugin / marketplace / MCP-registry / GitHub-About listings; check-consistency
reads the line from the charter and fails any of those surfaces if it drifts. External review
is billed as a feature note, not a second mode. The support copy drops the "AI bills from
benchmarking" story, ruled fabricated in the charter's interview (C7): sponsoring supports
the project and sponsors get featured README/site placement, and the enterprise offer
softens to an email doorway — no price, no SLA claim. docs/ROADMAP.md is rewritten to the
charter's Now/Next/Not-now with dated corrections, and the two strategy documents carrying
the fabricated cost premise now open with correction banners.
- The funnel leads with the trial. A "Try it on your repo" section (the npx one-liner) sits
directly under the README hero, and the homepage hero carries the same command — seeing your
own repo mapped is the pitch. The axios case study is demoted from a bold callout to a plain
link under the screenshots. The README masthead drops the hero graphic and the decorative
engine/MCP/sponsor badges: a text wordmark, the tagline, and three badges (CI, npm, license).
- Every screenshot and the live demo now show the redesigned report. The demo is the same
committed axios graph re-rendered through the new template (274 product symbols, 8 domains),
the five README/site screenshots are re-shot from it, the blast-radius caption now states
what the committed graph actually contains (27 call edges into AxiosError — the old "58
users" figure doesn't match this artifact), and the injected demo nav matches the HUD
(caps, square, no pills).
- Plain-language pass over the whole marketing funnel. The README, homepage, and plugin
listing now state what codeweb is instead of performing the problem — the rhetorical-question
opener and "Today…" windup are gone, and the proof bullets carry spec-sheet labels
(Correctness / Duplication / Speed at scale / Agent lift / Limits). Study apparatus left the
pitch too: the hero's grep-vs-codeweb benchmark table, the "referee"/"oracle" framing, and
the outbound vite/axios repo links are gone or moved behind the evidence ledger — the pitch
says what you get; the research page says how it was measured. The proof section is now
"Benchmarks": benefit-first bullets (callers found 44%→74%, impact answers at 1/126th the
tokens, renamed-duplicate detection text search can't do) with no meta-talk about our own
rigor — the 32/33 pre-registration story lives on the research page. Engineering properties
left the marquee: the DETERMINISTIC · READ-ONLY · ZERO-DEPENDENCY strip is gone from the hero
(README and hero.svg), and the plugin/marketplace listings say "27 MCP tools" — read-only and
zero-dep stay where the worry arises, in install and trust copy. Shorter sentences,
stats stated as outcomes ("+0.31 recall" now reads "with grep agents found 44% of a
function's callers; with codeweb, 74%. Your agents break code they don't see"), and the
MCP tool list regrouped by when agents meet each tool (which also
surfaces codeweb_explain, previously undocumented in the README). Density is now gated:
marketing paragraphs cap at ~55 words, enforced by tests/copy-density.test.mjs, with the
voice rules recorded in CONTRIBUTING.md. And the README is a landing page again: its
reference half — outputs, per-tool docs, the capability table, the full MCP tool groups, the
component tree — moved to docs/reference.md, linked per-job from the README (which drops
from ~650 to ~330 lines). Two overclaims corrected:
the homepage no longer calls 16k+ graphs "measured green" (the expand-all receipt says
green:false at that scale), and the pre-registration page no longer says the full study
re-runs in CI (CI re-measures the standing budgets; the study re-runs via bench/run-all.mjs).
The stale "286-test suite" figure is dropped or time-qualified everywhere it appeared.
- The regression gate is ONE verdict with a declared strictness knob.
gateVerdict(graph-ops)
now backs diff/CI, simulate, the post-edit hook, codemod, and review --gate; payloads carry
verdict.check (orphan-gate vs call-caller-preflight), exported symbols that lost every
in-edge are LISTED and flagged exempt instead of silently dropped, and every doc states the real
rule. simulate/codemod speak for their own check instead of borrowing the gate's.
run.mjsjoins the fleet's stream contract: results (the done block, or the new--json
one-liner) on stdout, progress on stderr; wrong-path targets exit 2, not the stage-failure code.
codeweb_context:bodies: "windows"|"full"replacesfull's second meaning —fullis
the unabridged-list switch only. Agents wanting whole caller bodies pass bodies:"full".
- One pagination dialect over MCP:
offsethonored on risk/hotspots/break_cycles,
nextOffset beside every remaining, reading_order surfaces its truncation, and the MCP layer
now rejects unknown argument names and non-boolean booleans the way the CLI always did.
- The shared parser coaches: did-you-mean on unknown flags,
--flag=valueaccepted, and the
no-map cause+remedy is APPENDED to a tool's usage instead of replaced by it.
- One name per concept: "domain" everywhere the report/brief/og said "areas"; deadcode's heading
hedges up front and shows the annotate --suppress door; "blast radius" defined at the lens,
settings, and optimize surfaces.
Fixed
find_similar.countis the true match total again (it reported the capped length), with
more.remaining when capped.
- Three product-page tool cards rendered the literal word "undefined" (data key drift); the site
build now fails on a desc-less tool.
- The CI gate action stamped "structural regressions" over setup failures — exit 2 now names the
shallow-checkout cause and remedy, ci-gate forwards the child's stderr tail, and the quickstart
bins exit 2 (never the regression code) on old Node.
codeweb_mapfailures keep the extractor's escapes-first explanation instead of a beheaded
stderr tail recommending a flag the tool cannot pass.
- The pre-edit hook no longer returns
permissionDecision: "allow"— it is advisory context only. - The SessionStart hook says one line (once per workspace) on an unmapped repo with source,
instead of the silence that read as "the plugin doesn't work".
- annotate refuses to invent a fresh
.codeweb/in an unmapped cwd; refresh/fitness/placement/
codemod resolve graphs through the one loader.
- Docs that lied outside the consistency sweep (README's gitignored "bundled sample", agent-tools'
stale tool count, tests/README's Node floor, the runbook's test count, marketplace.json's
version) — corrected and swept from now on; new docs/cli.md tables every bin, flag,
environment variable, and exit code; new CONTRIBUTING.md.
site/data/product.json's (currently unrendered)elevatorstill said "24 deterministic MCP
query tools" — corrected to 27, and check-consistency now scans every prose string in the
site data file so this class self-catches.
[0.10.0] - 2026-07-23
Changed
- **The last hand-rolled CLI flag loops now route through the shared
parseArgs(spec)(lib/cli.mjs),
so every front door enforces the one #24 unknown-flag policy — reject with usage, exit 2, never a
silent positional.** explain.mjs and diff.mjs were the live bug: a no-else `for (const t of
argv) { if (t === '--json') … else if (!t.startsWith('-')) pos.push(t) }` swallowed a typo instead
of erroring, so explain g.json sym --jsno exited 0 as if the run were clean; both now exit
2 with unknown flag: --jsno and the usage. brief.mjs/coverage.mjs/stats.mjs already
died on an unknown flag but still hand-rolled the loop (now one spec apiece), and
bench-ts-engine.mjs swallowed an unknown flag into its target positional — a stray --engine
became the path (exit 1 "target not found"), the original #24 shape — now exit 2 with usage.
Accepted flags, positionals, and --help (exit 0) are preserved exactly;
tests/cli-unknown-flags.test.mjs pins the failing→passing exit codes and the surviving legit
surface. (round 2, finding #39)
- **The post-edit structural-regression hook now extracts IN-PROCESS instead of spawning a child
node process — the last residual term of the hook fast-path floor.** Enabled by #40 making
extract-symbols importable with a side-effect-free import, the hook lazily
import()s runExtract (after the inert-fire guard, so a no-op fire still pays nothing) and calls
it directly, killing the child node boot plus the fragment stringify(child)+JSON.parse(hook)
round-trip across the process boundary. The symbol-set delta/splice against the #18a baseline
fragment is runExtract's own warm-cache machinery (WS-D's #17 name-delta path) — the hook adds no
splice or invalidation logic, so the in-process fire runs exactly what IE-EQUIVALENCE proved
byte-identical. Measured at the 16.8k-symbol class (median-of-5, this box): no-change fire
1,089 ms → 698 ms (the forced-spawn path vs in-process on the same corpus; 0 extractor child
processes vs 1, strace-verified), under the 700 ms floor and down from #18a's 889 ms row. A
fail-open crash ladder keeps the guarantee: CODEWEB_HOOK_INPROC=0 forces the old spawn (the
rollback lever), and any throw from the lazy import or runExtract triggers one spawn fallback,
bumped as hookInprocFallbacks so a silent divergence is ledger-visible. additionalContext is
byte-identical across both transports (path-parity test). (round 2, finding #18b)
- **The symbol extractor is being decomposed into testable, importable pieces (the tracked residual
of round-1 #25, now actually being closed).** Stage 1: per-file call/ref/inherit derivation —
deriveFileEdges, the precision gate (alias > same-file > unique-in-package, drop-ambiguous, plus
the short-name / closure-local / role / rb-php filters) — moved verbatim out of the 1,400-line
orchestrator into scripts/lib/edge-derive.mjs as a createEdgeDeriver(ctx) factory
(import-resolve's proven template: explicit injected context, zero module-global reach-back). The
free-variable context was re-derived against the *current* engine, not the spec-time table:
injected byName, pkgOf, roleFor (the #10 ref role-gate), resolveFileMember,
closureLocalIds (the WS-D-review magnet fix), legacyFallback; KEYWORDS/parseSignature/
isTestFile/buildInnermostIndex are the lib's own pure-module imports; idFile (the id→file
split) is now defined there as one truth and imported back. Edge derivation is now unit-testable
in-process at function-call speed (tests/edge-derive.test.mjs, no spawn) while staying
byte-identical to the old inline function — proven by IE-EQUIVALENCE at 40 trials and a full
self-map cold+warm byte-cmp. Stage 2: the two remaining global-resolution passes joined the same
lib as pure functions — markPublicApi (the v10 public-API entrypoint walk, now doing no fs: the
orchestrator injects readPkg + the statted sources map and applies the returned ids-to-stamp,
order-safe because the walk never reads pub) and resolveTypedIntents (Java/C# typed-receiver
dispatch), which mechanically retires the finding's named shadowing smells (rel loop-var vs the
rel() fn, a files local vs the global). Stage 3: the orchestrator now exposes
runExtract(opts) and its import is side-effect-free — argv parsing, every process.exit, and
the --out/stdout writes moved to a main() reached only through the repo's proven
import.meta.url guard idiom; the guard-path exits became ExtractError throws with
byte-identical message text (usage / bad-engine / not-found / empty-tree / zero-symbols), which
main() prints and exits on. Run state that was module-global is now runExtract locals; the WASM
parser engines stay memoized process-wide (the load *promise* is single-flighted, so concurrent
first-calls don't double-init). import('extract-symbols.mjs') now parses no argv, writes no
files, and never exits — the precondition for the in-process hook (#18b) and for running the
extractor under node:test's concurrent subtests. Stage 4a: the starter trio of spawn→in-process
test conversions — incremental-edges (the IE-EQUIVALENCE property sweep, the suite's dominant
wall term: its ~330-490 child launches at CI depth become function calls, cutting the test's own
wall ~4.5×, 12.9 s → 2.85 s at 40 trials — every assertion identical, the edged N/M reads moved
from stderr to the returned banner, the fragment byte-compares to JSON.stringify(fragment), and
a new IE-INPROC-PARITY spawn keeps the CLI surface pinned byte-for-byte), plus call-apply-chain
and test-edges (extractor in-process, their query.mjs spawns untouched). Stage 4b: the
import/edge-precision + language-extraction family (18 more files) went in-process too, clearing
the plan's bar — 22 extractor-invoking spawn sites retired (the convertible subset dropped
80→58), and the dynamic extractor child-process launches during the incremental-edges run drop
from 116 to 1 at 10 trials (≈382→1 at 40-trial CI depth). Non-extractor spawns (query.mjs,
context-pack.mjs) and the CLI-surface owners (cli-front-door, empty-target) stay spawn-based.
(round 2, finding #40)
- **"Expand all symbols" no longer freezes the main thread: the force sim now seeds compactly, has a
real long-range term, and runs in interruptible slices so no single task blows a frame — plus the
receipt that judges it is measured honestly.** The old anneal packed every symbol onto its area
bubble (radius-14 hatch) and repelled only within a 3×3 grid neighborhood, so the ONLY way the
layout could spread was an accidental ±185k-px explosion whose first step was one ~1.2s synchronous
task at 16.8k. Now symbols seed on a golden-spiral phyllotaxis hatch (near-equilibrium spacing), a
Barnes-Hut-lite far-field monopole (per-cell mass + center of mass, pushing every node from each
non-adjacent cell, with a 2×CUT coarsening latch when live cells exceed 4096) gives repulsion its
missing long-range term, and gStep became gStepChunk(deadline) — a cursor-driven state
machine that accumulates forces across slices with the grid/aggregate snapshot frozen per logical
step and exactly ONE integrate + alpha-decay per completed step, so slicing changes only WHEN work
runs, never the arithmetic (bitwise-equal to a whole-step run — pinned in the lab). gTick and the
reduced-motion loop drive it against their budgets; motion-safe settles now redraw at most once per
second as discrete progress stills. The result at 16.8k (node sim lab, bench/experiments/report-sim-lab.mjs):
the worst single uninterruptible task drops from 774 ms → 93 ms (≤ the 250 ms no-freeze floor) and
the ±185k spread compacts to ~230k px, while the per-step settled cost stays ~270 ms — the far
field is O(n·cells) and reaching the ≤50 ms/frame primary target at this scale needs a hierarchical
tree (a documented floor, per the finding's fallback: no task > 250 ms + interactive, not a pass).
The receipt (__codewebStage.expandAll) was rewritten from an unstable 10-frame sample that straddled
the explosion (508→116→37 ms across back-to-back calls) to a run-to-settle returning
{settledMsPerFrame, maxSingleStepMs, totalSettleMs, steps}, and report-scale.mjs gates on both
settledMsPerFrame ≤ 50 and maxSingleStepMs ≤ 250. Layout stays a pure, bitwise-deterministic
function of (graph, interaction sequence) — seeded, no Math.random, insertion-order iteration.
(round 2, finding #35)
- **The interactive graph's draw loop got substantially cheaper per frame: theme colors are read once
per draw, edges stroke in exact style batches, and labels are screen-space-gated and capped.** Every
labeled node used to call getComputedStyle (up to one per symbol — 16.8k reads on a full 16.8k
draw); cvColors() is now hoisted to one call at the top of gDraw (theme flips already redraw, so
it stays in sync). Edges stroked one beginPath+stroke each (104k strokes at 16.8k); they now
group into ≤432 exact style buckets keyed state|bubble-pair|min(weight,72) — alpha saturates at
weight 29 and width at 72, so the key reproduces the old per-edge stroke/width byte-for-byte (a
50k-edge property test asserts equality), and each bucket strokes once. Labels used a world-space
radius gate (nd.r > 7.5, so a big node lost its label when zoomed out) with no cap; they now use a
screen-space gate (nd.r * cam.k > 7.5) and a per-frame cap of 300, picked by a position-independent
rank (bubbles > selection/search hits > screen radius, tie-break by id) so they never flicker across
anneal frames. The search highlight set is computed once in refreshHits (stored id/domain Sets) and
reused by gDraw instead of rescanning the active node set every frame. __codewebStage.drawOnce()
times one full draw; report-scale.mjs measures it, judges it (drawOnceOk, <100 ms), and launches
Chromium with chromiumSandbox:false when running as root. The draw helpers ship as pure functions,
node-tested via the template-extraction pattern. (round 2, finding #37)
- **The editor CodeLens recomputes far less on every save: blast radius uses the canonical
pointer-queue walk, the blast memo now persists across graph refreshes, and the extension only
activates in workspaces that actually hold a codeweb graph.** blastOf (lens-core.js) walked the
BFS frontier with queue.shift() — O(frontier) per pop, the same O(n²) cliff graph-ops's
impactCountOf already fixed (19.9s→0.3s on a 240k closure) — and rebuilt a [...callIn, ...inheritIn]
array on every visit; it now mirrors impactCountOf exactly (index-pointer queue, direct Set
iteration), with byte-identical results (verified: hub blast 17600 = 17600). `buildLensIndex(graph,
prevIndex)` carries every memo entry whose blast provably cannot have moved — the invalid set is the
forward closure of the call/inherit edge delta's to endpoints over the new graph — so a save that
touches a few edges no longer reruns every symbol's transitive closure (refresh() stopped clearing
the cache; a full-file pass is warm-0.23 ms vs cold-hundreds-of-ms on a dense graph). package.json
activationEvents moved from onStartupFinished (every window) to `workspaceContains:**/.codeweb/
graph.json` plus the two commands as an explicit palette fallback. Property test: for randomized
graph pairs mutating 1–5 edges AND adding/removing nodes with their edges, the carried index returns
the same blast as a cold rebuild for every id; a counting test proves untouched ids are reused, not
recomputed. (round 2, finding #38)
- **
report.htmlno longer embeds the four per-node fields and the per-edge field the template never
reads.** The self-contained report inlines the graph into a <script id="graph-data"> payload the
browser parses on load, but report-template.html's read-set is `id,label,domain,kind,role,file,
line,loc,exports,summary per node and from,to,weight per edge (grep-verified: node kind` is read
at :248/:269/:424/:427, o.kind is OVERLAP kind, and edge kind / node `t3,signature,complexity,
maxDepth are never read). The embed now strips t3,signature,complexity,maxDepth per node and kind`
per edge into fresh objects — graph is never mutated, so graph.json on disk and the map-time
sidecars keep every field for the editor lens, the MCP query tools, and the hooks (the strip is
embed-only). On the 16.8k loaded corpus (AST tier, tree-sitter present) this drops report.html
from 13.88 MB to 9.97 MB (−28.2%): ~4.1 MB stripped = ~1.2 MB t3 (present on 13.4k/17.6k nodes,
short fns so avg 7.5 stmt-hashes) + ~2.9 MB signature/complexity/maxDepth/edge-kind. On
statement-heavy code the per-function t3 fingerprints (arrays emitted for ≥6-statement bodies) grow
and dominate — codeweb's own scripts/ (avg t3 len 18) strips 32.8% of the embed; the ≥40% target
assumed such corpora. The dead domSummary builder (report-template.html:228, zero readers) is
deleted. report.html stays byte-deterministic across rebuilds (verified: two independent rebuilds
of the 16.8k report are byte-identical). (round 2, finding #36)
- **The prescribed per-edit loop is now in-process:
codeweb_diffserves from the cached graph and
codeweb_stats no longer spawns a child. codeweb_diff booted node and parsed two** graphs per
call (131–136 ms at 1.2k; ~500 ms at 16k) in the refresh→diff sequence the INSTRUCTIONS prescribe per
edit; codeweb_stats spawned a child (92–95 ms) to read a ~200-byte stats.json the server already
had the reader for. diff's comparison + #28 rename detection are lifted verbatim into a new
scripts/lib/diff-core.mjs (diffGraphs(before, after, {names, bIx?, aIx?}) → {payload, code});
diff.mjs becomes load → diffGraphs → emit (exit codes and --json/text bytes unchanged, asserted).
The MCP fast path parses the caller's before snapshot fresh (never inserted into the graph cache —
its FIFO eviction would drop the hot live graph), reads the after side from cachedGraph (re-stat +
reload on change), and — I6 — awaits the after-workspace writer tail first, so a concurrent refresh
lands before the diff reads (it does not reopen #30) and a cancel while it awaits suppresses the
reply; any throw falls back to the spawned diff.mjs, whose stderr becomes the errResult (never
invented text). codeweb_stats mirrors the brief fast path via a new receiptPayload in
lib/stats.mjs (the one place the empty-note literal lives; stats.mjs --json serves it too).
Byte-for-byte parity with both CLIs is asserted (MD1/MD2/MD2b for diff; empty + non-empty ledgers
for stats; missing-graph errResult identical to the spawn path). After this, refresh is the loop's
only child. (round 2, finding #33)
- Read-only MCP tools on one workspace now run concurrently (reader/writer queue split). Stage 1
of the queue redesign kept full per-workspace serialization; the 12+ read-only spawned tools
inherited write-ordering they don't need, so behind one slow advisor every other spawned tool on
that graph queued (hotspots-behind-risk measured ≈ risk+hotspots, the head-of-line shape prior-#19
fixed only for the in-process fast paths). Tools are now classified writer vs reader: writers keep
the FIFO chain (I1) and additionally wait for readers enqueued before them (I3, the conservative
rule — a spawned reader makes multiple workspace reads and a writer landing mid-read would hand it a
torn state, so today's linearization is preserved); readers order after any earlier-queued writer
(I2) then run under a global cap of 3 concurrent children (I4, FIFO waiters). Measured: risk solo
152 ms, hotspots solo 149 ms, the two concurrent 146 ms (≈ max, not the ~301 ms sum) at 445
symbols. READER_CAP=1 restores full serialization (the rollback lever). The aggressive
"writers skip readers" variant was reviewed and rejected (torn multi-artifact reads). Scenarios
S6 (reader overlap), the cap test (never 4 concurrent), and the writer-barrier test
(end(reader₁) ≤ start(writer) < start(reader₂)) pin I2/I3/I4 by mechanism. (round 2, finding #32)
Fixed
- **
codeweb_refreshnow regenerates the sidecar trio, so the hook / find_similar fast paths keep
their floor for the whole session.** refresh.mjs rewrote graph.json but never touched
brief.json, index-lite.json, or similar-index.json — and all three stamp on graph mtime+size,
so one refresh invalidated every one until the next full map (measured: session-brief 63–70 →
106–127 ms, pre-edit 73–81 → 98–135 ms, find_similar --body 605 → 1,104 ms). Since the server's
INSTRUCTIONS prescribe codeweb_refresh after every edit, every real session lost the floor within
minutes and never got it back. The map-time sidecar writer is now ONE lib (scripts/lib/sidecars.mjs,
writeSidecars): build-report.mjs calls it (byte-identical outputs to the old inline block,
asserted) and refresh calls it after its atomic graph write. brief + index-lite are pure functions of
the graph and are always rewritten; similar-index is rebuilt when meta.root is readable (via the
current v2 capped-shingle builder — closing the #26 dup-check-pool staleness that also depended on
this) and otherwise REMOVED so a stale one is never silently served. All three share ONE stat stamp
and are written strictly after the graph rename, so a crash between the two leaves only stamp-
mismatched sidecars (every loader falls back to null → the live path) — a fresh-stamped-wrong-content
sidecar is impossible by ordering. Coexists with WS-D's hook-baseline.json (write order: graph →
hook-baseline → trio). Scenario S5 asserts freshness by MECHANISM (stamps match statSync, not
wall-clock). (round 2, finding #25)
- **MCP cancellation is honored, and malformed / batch JSON-RPC frames get a real answer instead of a
silent hang.** handle() dropped every id-less message, so notifications/cancelled was ignored —
a wrong codeweb_map burned up to 300s unstoppable and still replied. Non-object frames (42,
"x", null) and JSON-RPC batch arrays destructured to id === undefined and were dropped with no
response, so a conforming 2024-11-05/2025-03-26 client that sent a batch hung forever. Now: an
id→child map (owned by runChild) lets notifications/cancelled SIGKILL the in-flight child (or
mark a still-queued job so it never spawns) and suppress its reply while still releasing the queue
slot / drain counter; a non-object frame answers -32600 Invalid Request; a batch array is handled
by minimal per-member fan-out — each member is processed as if it arrived alone and its response is
emitted as its own NDJSON line (an empty array is a single -32600). Line-framed rather than
array-collected responses is a documented, adjudicated tradeoff (a collector would deadlock on a
cancel-suppressed member, which gets no response); every by-id-correlating client — every driver in
this repo — is unaffected. Scenarios S4 (cancel kills the child, no reply, ping still answers)
and S7 (malformed + batch frames) pin it. (round 2, finding #34)
- **The MCP queue is now keyed by WORKSPACE DIRECTORY, so refresh→diff ordering actually holds (and
map/autoRefresh joined the queue).** The per-workspace serialization the queue comment promised did
not hold: codeweb_diff is graphless, so it queued under a shared '(graphless)' slot while
codeweb_refresh queued under the graph *file* path — fired together (which Claude Code does
routinely, and the server's own INSTRUCTIONS prescribe per edit), diff completed *before* refresh
and gated against pre-refresh bytes (can pass a real regression or flag a phantom one). Two other
spawn sites bypassed the queue entirely: codeweb_map and the internal auto-refresh ran outside it,
so two maps on one out dir interleaved stage-by-stage and an auto-refresh could race an explicit
refresh into two concurrent extracts on one scan cache. Redesigned as ONE per-workspace-directory
queue (dirname(resolve(graph.json)) for graph tools, resolve(out) for map, dirname(after) for
diff via a new queueFrom) with a single runChild wrapper owning spawn/timeout/settle-once/trace
for every child. handleMap gains the settle-once guard it never had (a spawn failure there used to
double-reply and double-decrement the drain counter — now exactly one reply, verified). Auto-refresh
skips when the workspace already has a writer queued/in-flight (writersPending, read race-free in
the same readline drain) and otherwise enqueues as a writer. Stage 1 preserves full per-workspace
serialization; reader concurrency lands separately (#32). Opt-in CODEWEB_MCP_TRACE=1 emits an
NDJSON queue-event stream on stderr (default silent) for the by-mechanism scenario suite (S2/S3/I7).
(round 2, findings #30 #31)
- A dying child no longer crashes the whole MCP server (unhandled EPIPE on child stdin).
codeweb_find_similar is the one tool that writes to a child's stdin (the candidate body); when
the body exceeded the 64KB pipe buffer and the child exited before draining it (bad graph path,
arg-validation die(2), the 120s SIGKILL), the flush raised EPIPE with no 'error' listener and
Node killed the server — every tool dead, the request unanswered (repro'd: 1 MB body + bad
graph → exit 1 at ~241ms). The async conversion introduced it; the pre-conversion spawnSync({input})
had contained it. Fixed by attaching child.stdin.on('error', …) before writing and wrapping
stdin.end() in try/catch (covering the async EPIPE, the synchronous ERR_STREAM_DESTROYED, and a
null stdin after spawn failure). The failed call is now a normal isError result and a subsequent
request answers. The server's *own* stdout was never at risk — it inherits lib/cli.mjs's
process.stdout EPIPE→exit(0) handler via an import side effect (a duplicate listener would be dead
code); scenario S1 pins both halves (child-stdin EPIPE and client-closes-stdout). Also dropped
the dead spawnSync import left by the async conversion. (round 2, finding #29)
break-cycles.mjsanddiff.mjsare searchable again (raw NUL bytes removed). Both
product files embedded literal NUL bytes as template-literal key separators, so file, grep,
and ripgrep classified them as binary — invisible to every code search (three separate audits
hit it this round, and one edit to break-cycles left it binary). The four raw bytes are now
the '\x00' escape (the graph-ops/extract-symbols idiom — same runtime string, so break-cycles
and diff output stays byte-identical, verified against the pre-fix binaries on a cyclic
fixture in both --json and text mode). A new lint tripwire, tests/no-control-bytes.test.mjs,
fails naming any tracked .mjs file containing a raw byte below 0x09 (\t/\n/\r
unaffected), so the class stays closed. (round 2, finding #41)
- Retargeting a re-export barrel no longer leaves stale call edges in warm extracts. Flipping
a forward (export { x } from './a.js' → './b.js', or a Python from-import chain) changes
ZERO symbols, so the shipped engine's wholesale reuse gate (symbol signature + per-file content
hash) replayed unchanged consumers' cached edges still aimed at the OLD chain target — found by
#17's extended IE generator (5/40 trials red under the pre-delta engine), reproduced
deterministically by IE-REX-FLIP, and re-verified red against the workstream-entry tree. Both
reuse gates (edges and binds) now also require an unchanged re-export landscape (rexSig
canonical-table hash, plus a Python (module, name)-membership belt for edge-time chains).
Landed inside #17 part 1 (8582b4f); recorded here so the truth fix is visible outside the
perf entry. (round 2, finding #17 — WS-D review note)
- Bare-name fallback edges can no longer target another file's closure-locals. The
unique-in-package fallback resolved a bare name to ANY package-unique symbol — including
functions nested inside other functions, which no other file can lexically reach. The measured
magnet was real: graph-ops' dep loop variables ref-resolved into import-resolve's private
dep closure and fabricated a graph-ops ↔ import-resolve cycle the structural gate blocked
(31 such edges existed on the self-map, including the scanJsReExports → graph-ops:map half of
that same cycle). The fallback now filters closure-local targets (positive containment on
function/method ranges; PHP/Ruby exempt — their nested definitions become reachable at runtime;
same-file resolution untouched). Nesting is now part of the resolution landscape, so a
wrap-into-closure edit invalidates consumers: the eligibility bit annotates symbolSig and both
name-delta label maps, pinned by a warm-vs-cold byte-equality test that fails without the
annotation. Derivation-semantics change ⇒ SCANNER_VERSION 16 → 17 (one cold rebuild).
(round 2, WS-D review — the #10 short-name guard's documented residual, closed at any length)
- The JS masker now lexes regex literals.
maskJstracked strings/templates/comments but not
regex literals, so a quote inside the ubiquitous escaping-helper pattern (replace(/…/g) with a
quote in the regex) desynced its string state — bodies ran to EOF, absorbing neighbors and
fabricating call edges from the absorbed code — and a backtick inside a regex flipped template
state (an odd count blanked the rest of the file: codeweb's own lib/complexity.mjs extracted
as 5 nodes / 0 edges, and 13 of 25 deadcode "safe" items on the self-map were false positives).
Regex literals are now recognized with the standard prev-significant-token heuristic (escape-
and char-class-aware; division and JSX close tags unaffected) and their interiors blanked like
strings — including inside ${} interpolations, where a {2} quantifier corrupted brace
matching. The String.fromCharCode authoring convention that worked around the blind spot is
deleted (ts-engine.mjs string regexes, Ruby mask regexes) and now serves as a live self-test;
tests/maskjs-regex-literals.test.mjs pins the two reproduced corruptions plus a self-map
regression on complexity.mjs. (perf-quality finding 1)
codemod --writecan no longer corrupt source while reporting success. Three reproduced
failure modes closed: (a) an unresolvable/ambiguous --into is a hard exit 2 instead of being
used verbatim (pre-fix, canonLabel became undefined, BOTH definitions were deleted, and loser
tokens — including a string literal — were rewritten to the literal text undefined, with exit 0
and applied: true); (b) token rewrites are now gated on the column-preserving mask — comment
mentions are left as stale prose and counted, and a label inside a string/regex literal refuses
the whole write (a name in a value can be load-bearing); (c) imports that named a loser are
repointed at the canonical's file (pre-fix import { canon } from './loser.mjs' survived —
valid-looking, broken at runtime, invisible to the structural gate), importers-without-calls are
now part of the rewrite set, and the post-write gate additionally asserts the canonical itself
survived re-extraction. The maskers moved to scripts/lib/masking.mjs (importable — the start of
finding 25's decomposition) with a keepValues mode for the live/value/comment classification.
(perf-quality finding 2)
- Workspace artifacts are crash-safe, and the stage memo validates before reusing. Every
graph/fragment/sidecar/report writer now goes through atomicWrite (same-dir temp + rename —
readers see old bytes or new bytes, never a truncated half-write; the reproduced kill window was
the MCP server SIGTERM-ing its refresh child at 60s mid-writeFileSync), and run.mjs's stage
memo refuses to reuse outputs whose graph.json no longer parses (pre-fix, a corrupt workspace
got "stages reused (fragment unchanged)" forever — the natural recovery path preserved the
corruption). tests/atomic-writes.test.mjs pins both. (perf-quality finding 3)
- Freshness stamps can no longer be false-fresh. The extractor stats each file BEFORE reading
and re-stats after (re-reading on mismatch; a file that won't hold still gets a never-fresh
stamp) — pre-fix a file modified between read and stat carried a fresh stamp over stale bytes,
permanently. Stamps now carry the content sha1 (meta.sources: {s,m,h}), and checkStaleness
gained a verify tier — CODEWEB_VERIFY_FRESHNESS=1 (or {verify:true}) sha1-compares content
where stats match, catching the reproduced mtime-preserving bypass (rsync -a, tar -x,
git-restore-mtime, SOURCE_DATE_EPOCH builds) across every consumer: MCP auto-refresh, query
stale-annotations, hooks. Stat-only stays the default fast path. (perf-quality finding 4)
- Byte-determinism, delivered.
generatedAtwas the sole byte-difference between two
identical runs; the report embed also carried per-file/dir mtime stamps its template never
reads. report.html now strips generatedAt/sources/dirs alongside root (byte-identical
across runs and across fresh checkouts, no pinning needed), and graph.json honors
SOURCE_DATE_EPOCH so CI can byte-compare complete workspaces. graph.json keeps all three
fields (brief reads generatedAt; staleness reads the stamps). (perf-quality finding 5)
Performance
- Migration leftovers swept, plus a
trend --gitfast path. Seven small chores from the
lib-extraction migration: (1) dead writeFileSync imports dropped from overlap/build-report/
coverage/refresh; (2) coverage.mjs writes the annotated graph COMPACT (was the last
pretty-printed graph.json — 20.6 → 13.0 MB); (3) the stranded "THE body reader" jsdoc moved back
above sourceReader; (4) lib/import-resolve.mjs re-indented to uniform 2-space (the two verbatim-
moved blocks were left at column 0 / column 2 — git diff -w empty, extract-equivalence suites
green); (5) lib/stats.mjs + lib/annotations.mjs writes now go through atomicWrite (crash-safe,
bytes/formatting unchanged, best-effort try/catch preserved); (6) a staleNote on
bench/results/scale-typescript.json (v0.9.0 pre-fix; real re-run is WS-G #35). (7) **trend fast
path:** run.mjs gains --stages through-overlap (extract + cluster + overlap only; any other
value exits 2 — no silent typo path; a partial run NEVER writes the stage memo, so a partial
workspace can't satisfy a later full run's reuse check), and trend.mjs --git now reuses ONE
workspace dir across commits (scan-cache persistence; worktree churn stays per-commit) running each
commit through-overlap. A reused-ws belt accepts a commit's row only when the loaded
graph.meta.target equals that commit's sha7, so a failed commit's row is never misattributed to a
stale graph left in the shared workspace. Metrics are byte-identical to the pre-fix full-pipeline
path; measured on a 2-commit 680-file repo: per-commit ~7.5 → ~4.6 s (extract-dominated, the
optimize+report stages skipped). (round 2, finding #42)
diffrename detection is indexed, memoized, and honest about its cap. The removed×added
rename matcher called graph.nodes.find twice per candidate pair (an O(nodes) scan inside an
O(removed×added) loop) and re-shingled every added body once per removed node — 1,733 ms of
rename-detection overhead on a 14,964-node graph with 195 relabeled nodes. The logic moved into a
pure, ambient-state-free detectRenames() (WS-F #33 lifts it verbatim) that uses the already-built
byId indexes, hoists each old body's skeleton out of the inner loop, and memoizes each new body's
skeleton once for the whole pass (null bodies memoized too, so an unreadable body is not re-read per
removed node and still routes to the span-shape fallback). Bodies are capped at BODY_LINE_CAP lines
before shingling like overlap/dup-check — a >400-line rename's similarity can shift to the overlap
answer BY DESIGN (a >400-line rename still detects; test-covered), while every short-body fixture pin
stays byte-identical. Detection above RENAME_CAP (200) nodes on either side is now surfaced: an
additive nodes.renameCheck = { skipped, removed, added, cap } field plus one text-mode line name
the skip (absent when both sides fit, and when a side is empty nothing was skippable) — the field is
documented in diff.mjs's header and breaks neither the graphless MCP codeweb_diff passthrough nor
the graph-ops-based hook gate. Measured min-of-3 at 14,964 nodes: 195-rename overhead 1,733 → 30 ms;
renamed[] byte-identical to the pre-fix binary (195 pairs). (round 2, finding #28)
campaignruns its three advisors concurrently and stops cloning the graph it owns. The
worklist composer spawned optimize, deadcode, and break-cycles with three BLOCKING spawnSync
calls (wall = sum of the three children), then planCampaign structuredCloned the whole graph
defensively. The advisors now run under async spawn + Promise.all (wall ≈ max child + compose):
each child's stdout is collected while its stderr is drained and discarded (matching spawnSync's
collect-and-discard — an undrained stderr pipe deadlocks a child at ~64 KB), the exit code is
ignored exactly as before, a spawn error or non-JSON stdout degrades to the empty default, and
Promise.all's array order fixes the composition order so campaign's own stdout is byte-identical
regardless of which child finishes first. planCampaign gains clone (default true); campaign
passes clone:false because it owns the freshly-parsed graph and normalizeGraph is idempotent
additive default-filling that never touches meta (every other caller keeps the safe clone).
Pinned by CMP-CONCURRENT-STABLE (single-line JSON, byte-stable across runs, meta.target survives
the in-place normalize) and the unchanged CMP-DELTA-EQUIV property. Measured min-of-3 at 14,964 nodes:
campaign wall 1.65 → 0.89 s, payload cmp-identical to the pre-fix binary. (round 2, finding #27)
- **
dup-checkserves its comparison pool from the map-time similar-index sidecar (no per-call
re-shingle of the whole repo).** The review/PostToolUse duplication gate re-read and re-shingled
every non-test function body on every invocation — 374 ms for a single changed symbol at 14,964
nodes (1,804 ms for 50), a repeated tax inside the agent edit loop. incrementalOverlap now takes
an optional similarIndex: pool members with a sidecar record use the stored shingle-set size for
the exact size-ratio precut BEFORE any read, and survivors intersect by iterating the stored set
against the live candidate — the integer intersection count makes round6 sim bit-equal to the
live Jaccard, so the reported set is byte-identical (verified live == sidecar on 200 corpus ids and
by the DC-SIDECAR-EQ equivalence test, which includes a CRLF file and a >400-line pool body for
reader/cap parity). Changed ids always shingle live (their bodies are the new code under judgment);
a stale/absent sidecar falls back to the live path unchanged. review.mjs passes
loadSimilarIndex(abs). Measured min-of-3 at 14,964 nodes: 1 changed symbol 374 → 6 ms (62×);
50 changed 1,804 → 1,115 ms. Surfaced behavior change: BODY_LINE_CAP (400) is now one exported
const in lib/shingles.mjs (imported by overlap, dup-check, find-similar, diff, and the sidecar
builder), and buildSimilarIndex + find-similar's live path now cap node bodies to their first 400
lines like overlap/dup-check always did — so a >400-line body's find-similar ranking changes to the
overlap/dup-check answer, BY DESIGN. Surfaced three ways: find-similar's JSON payload gains
bodyLineCap, both file headers document it, and the candidate text (--body/--stdin/
--signature) stays UNCAPPED. The sidecar schema is bumped 1 → 2 (one SIMILAR_VERSION const,
stamped by the builder and checked by the loader) so stale uncapped v1 sidecars are rejected, never
served. (round 2, finding #26)
- Overlap's LSH stops paying the singleton-bucket tax (~85% of Signal B). At 15.7k nodes the
64-band twin pass built ~1M ~25-char string keys into one map ending at ~950k buckets, 99.3%
singletons — then sorted and walked all of them, while actual similarity confirmations were
~30 ms. Two tiers, each landed behind its own proof: (1) sort/walk only multi-occupancy buckets
(singletons hit the < 2 continue before the cap check, so they touch NOTHING — no side
effects to preserve); (2) numeric band-hash bucketing in the new lshCandidatePairs
(lib/minhash.mjs — one walk for both signals, band-major for map locality): rows fold to a
uint32, each bucket verifies its first-insert row tuple, true hash collisions spill
deterministically to a per-band side map keyed by the exact tuple string — grouping stays
exactly the string-key grouping provably, and legacy string keys are materialized ONLY for
multi-occupancy groups, so the pair sequence handed to the confirm chain equals the legacy
global string-sort walk order by construction (order is load-bearing: byLabelPair keeps the
first twin per label pair at a slice rank where sim ties are routine). Pinned by the
LSH-PAIRS-EQ property (220 seeded cases deep-equal a verbatim string-key reference — the pairs
ARRAY pins order; buckets/skippedBuckets asserted) and byte-identical graph.json + overlap.md +
stdout vs the pre-fix binary on the 15k corpus and the self-map (both tiers separately).
Measured min-of-3 at 14,964 nodes: Signal-B twin-enumeration mark 2,724 → 1,234 (tier 1) →
400 ms (6.8×); Signal-C 238 → 118 ms; overlap stage wall 3.37 → 0.93 s. (round 2,
finding #24)
- **
break-cyclesverifies cuts on the SCC-induced pseudo-graph instead of re-running whole-graph
Tarjan per candidate.** A dense SCC (no single-pair cut works) tried every candidate, and each
trial filtered ALL graph edges + recomputed file cycles over the whole graph — 19.5 s (22.8 s on
the finding's box) for one dense 60-file SCC inside a 93k-edge graph, inherited by campaign
(20.3 s), and gate-invisible because break-cycles was absent from the bench and the bench corpus
is acyclic by construction. Verification now runs the SAME fileCycles on a per-cycle
pseudo-graph (one node per file, one edge per surviving in-SCC pair, from a pair-witness table
counting ALL four cycle kinds — the ref subtlety: candidates stay STRUCTURAL/severable, but a
pair alive only via ref must survive a structural cut). The verdict is exact, not
approximate — removal only touches in-SCC pairs and an outside router would itself have been in
the SCC — pinned by a 150-case seeded property (all four kinds, duplicate edges, ref-closed
cycles) deep-equal against a frozen whole-graph oracle, plus the CB-REF-ALIVE scenario (a
STRUCTURAL-only witness table goes red there — mutation-checked during the build). The cut
logic moved to graph-ops.cheapestCuts behind a byte-identity pin (json + text, fixture +
cyclic corpus), and CYCLE_KINDS is now one exported truth for fileCycles, the merge
simulator, and the witness table. Measured min-of-3: embedded dense SCC 19.5 s → 0.35 s;
campaign on the same graph 20.3 s → 1.58 s; payloads byte-identical. bench/all.mjs gains a
breakCycles advisor row plus a cyclic section over a new pinned dense-SCC corpus
(writeCyclicCorpus: double-ring construction makes "no single-pair cut breaks it" guaranteed),
factor-gated so the whole-graph re-verify class can never ship unseen again. (round 2,
finding #23)
reading-orderanswers at the budget instead of ordering the whole graph first. The greedy
"fewest unemitted callees" loop recounted every remaining node's unemitted callees per emitted
item and applied the budget only after the COMPLETE order existed — 48.8 s (75.9 s on the
finding's box) for the default 40-item answer at a 15k-node corpus, on track to hit the MCP
server's 120 s spawn timeout at 30k. The greedy choice depends only on the emitted set, so the
loop now exits at the budget with live unemitted-callee counters (decremented through a
reverse-caller map; self-call edges keep their own +1 until emission), swap-pop removal, and an
index-pointer queue in the symbol-scope closure — first-N byte-identical to the full order's
prefix, pinned by a 200-case seeded property against a frozen copy of the old implementation
(all three scope kinds, self-calls and cycles included) plus cmp against the pre-fix binary at
budget 40 AND full order on the 15k corpus. Budget normalization is pinned at the lib boundary
(finite budgets truncate like the old slice; NaN/±Infinity mean full order). Measured min-of-3:
48.8 s → 0.37 s at budget 40 (full order 6.4 s). bench/all.mjs gains a readingOrder
advisor row + readingOrderMs on the loaded corpus, budget-gated at 0.5× the regex-extract
baseline, so this can never regress invisibly again. (round 2, finding #22)
- The post-edit hook stops re-verifying an unchanged baseline (hook-baseline sidecar). On
every edit the hook JSON.parsed the multi-MB baseline graph and recomputed its file cycles and
caller index — map-time artifacts that cannot have changed since map time. run.mjs (after
the stage run; on the reuse path only when missing/stale) and refresh.mjs (from the JSON
string it just wrote — free) now persist hook-baseline.json beside graph.json: version,
graph stamp (size + mtime) + byte sha1, cycle keys, and caller counts, via the new
baselineSummary/regressionsAgainstSummary split in graph-ops (structuralRegressions is
their composition — one truth, existing callers untouched). The hook consumes it when the
stamp — or, on stamp mismatch, the hash (identical-bytes rewrites re-validate; the bytes read
are shared with the fallback parse, one read) — matches, and falls back to today's path
otherwise. Fail-open at every seam, pinned by five BDD scenarios including the
poisoned-sidecar proof (a baseline cycle key removed from the sidecar is reported as new —
only the sidecar could say that) and both-corrupt silent-exit-0. Both write points are
best-effort try/catch. Measured min-of-3 at the 16.8k corpus: no-change hook fire 1,167 →
889 ms (the < 1.5 s floor holds with margin); the in-process-extraction residual is #18b,
deferred to WS-H and pointed to from hook-fastpath-floor.md's revisit triggers. (round 2,
finding #18a)
- Adding one function no longer re-derives the whole repo (name-delta invalidation). A
symbol-set change used to fail the edge/binding caches for EVERY file — the canonical agent
edit (add a function) cost a full re-read + re-mask + re-derive (2.1× the no-change floor
measured at the 16.8k-symbol corpus). The caches now compute the NAME DELTA — labels whose
sorted definition-id lists differ between the cached and live symbol tables — and re-derive
only files that could see it: a file's cached edges replay iff its content hash is unchanged,
its candidate-name set (every identifier reaching the edge resolver, recorded pre-resolution)
is disjoint from the delta, and its import BINDING replayed (the hardening conjunct: aliased
imports and namespace member calls ride the bind rule — original imported names plus a
content-hash check over every file the bind consulted, re-export walks and dead ends
included). Wholesale transitions stay wholesale: package-boundary changes, file add/delete,
re-export-landscape moves (rexSig, with a Python (module, name)-membership belt for edge-time
chains), rules changes, migration, --full/verify mode — and the kill-switch
CODEWEB_NAME_DELTA=0 restores the old wholesale mechanism outright (identical bytes, only
wall-time moves). Proven by the extended IE-EQUIVALENCE property harness at full CI depth (40
trials, both env legs) with warm-vs-cold fragment BYTE equality at every mutation step, plus
new incrementality assertions: add-one-function re-edges only the edited file + its
bind-coupled importer while a crafted disjoint file replays; a def rename of an imported name
re-edges the importer. Measured min-of-3 at 16.8k: add-one-function 1,326 → 710 ms — 1.27× the
same-session noop floor (bar ≤ ~1.3×, was 2.12×); body-edit unchanged (771 → 759 ms); delete
1,279 → 703 ms; rename 1,329 → 708 ms; the colliding-name add (a name every file calls) stays
honestly near-wholesale at 1,127 ms. at 29.4k: add-one-function 2,294 → 1,277 ms (1.22× noop,
was 2.14×). Hook add-one-function end-to-end 1,909 → 1,027 ms. (round 2, finding #17)
- The warm no-change floor sheds its four avoidable terms. (a) The scan cache no longer
stores syms (symbols sat in it three times over) and interns per-file edges as an id table +
[from,to,kind] triples instead of verbose objects — the edge term shrank 57 %, the whole
cache 32–38 % depending on engine tier (measured 18.9 → 12.8 MB at the 16.8k-symbol bench
corpus; the spec's ≥ 40 % estimate assumed syms was a full third of the symbol bytes — the
measured decomposition shows 6 %, with nodes dominating). A content-hash hit now replays the
FULL cached product set (nodes/ranges/dyn/ast/typed intents, entry carried forward with a
refreshed stamp) instead of rebuilding nodes from cached syms, gated on rulesSig like the stamp
tier. Cache format change ⇒ SCANNER_VERSION 15 → 16 (the WS-B/C/D ladder); any older cache is
discarded for one cold rebuild — pinned by a planted-poison migration test proving byte-equal
output to no-cache. (b) --out fragments are written compact (22.7 → 14.2 MB at 16.8k; every
reader JSON.parses) and an unchanged fragment is never rewritten — size + sha1 equality skips
the write and the banner says (unchanged). (c) run.mjs's stage memo now records
{size, sha1} per output and validates all five before reuse (size first, so truncation never
hashes) — replacing the old graph.json-only full-parse belt (330 ms at 13.9 MB) with a 36 ms
read-back that also catches parseable byte-tampering and guards the four outputs that had no
belt at all (new S5/S6 scenarios); SOURCE_DATE_EPOCH joins the memo key so a changed epoch is
never served stale bytes. Warm no-change pipeline at the 16.8k corpus: 825 → 677 ms min-of-3.
IE-EQUIVALENCE now additionally asserts warm-vs-cold fragment BYTE equality at every mutation
step — the shared oracle the name-delta work (#17) reuses. (round 2, finding #19)
- Per-file edge derivation is no longer quadratic in symbols-per-file.
enclosing()linearly
scanned ALL of a file's ranges on every call-site match (profiled: addEdge 50.8 % self on an
8,000-function file — the monorepo hub-file shape), and the method-owner attribution re-scanned
ranges-so-far per method. Both are now index lookups: an innermost-range-per-line array built in
one O(lines + R log R) stack sweep (lookup O(1)), and a live open-class stack across the
line-sorted build loop (amortized O(1) per method). Behavior-identical by construction and by
proof: a 300-trial property suite embeds the old linear scans verbatim as oracles — duplicate
starts, overlaps, degenerate ranges included — and the 8k-fn fixture extract is byte-identical
before/after in both engine tiers. Measured min-of-3 against the workstream-entry tree: the
8k-fn single-file extract 1,514 → 734 ms (2.06×) on the regex tier (3,210 → 2,393 ms with the
AST tier, which is parse-dominated); the 800-file corpus is unchanged-to-faster (1,257 →
1,162 ms). (round 2, finding #21)
maskJslexes ~3–5× faster, byte-identically. The masker ran a compiled-regex.test()
per character (word-class tracking) plus a one-char string append per character — ~27–30 MB/s
on real mixes, on the hottest path in the engine (every cold/changed file, and the whole repo
after any symbol-set change until the name-delta work lands). Two changes, zero byte diffs:
the word-class test is a charCode range check, and each lexer state now span-copies whole runs
of plain text up to its next special character (special sets re-derived from the post-WS-B
state machine and documented in place), folding the run into the significant-char state with
one backward walk that reproduces the old per-char accumulation exactly. Measured min-of-3:
26.8 → 135.8 MB/s (5.1×) on the 800-file bench corpus, 30.2 → 95.0 MB/s (3.2×) on this repo's
own 229 mask-eligible files. Byte-identity is pinned by a committed oracle test that embeds
the pre-change masker verbatim and compares outputs over every mask-eligible repo file, a
loaded bench-corpus tree, and adversarial fixtures, in all mode combinations — which is also
why this ships with NO scanner-version bump. (round 2, finding #20)
- Tree-sitter parse trees are freed. web-tree-sitter has no FinalizationRegistry, and none of
the engine's 8 parse sites called tree.delete() — every cold/changed-file extract on a default
install leaked WASM pages for the process lifetime (measured: 1,312MB vs 217MB peak RSS on an
11MB corpus; large-file repos risked OOM). All sites now free in try/finally; output is
byte-identical and ~9% faster from reduced GC pressure. A static tripwire test pins parse-site
count == delete-site count so a new parse can't quietly reintroduce the leak. (perf-quality
finding 6)
- The JS/TS AST tier extracts in ONE cursor traversal.
extractJsTswalked the full tree
three times (methods, Type-3 fingerprints, dispatch) via per-child JS↔WASM crossings — profiled
at 62% of AST extract self-time — and cyclomaticExact re-parsed every regex-owned body slice
from scratch for exact complexity (28%). One TreeCursor pass now collects everything: ancestor
stacks replace upward parent-walks, dispatch candidates resolve after the walk against the
complete method tables, and decisions are tallied per start row so a symbol's exact complexity
is 1 + sum(extent rows) — the identical decision set the slice re-parse counted, with zero
re-parses. Verified output-identical on a full corpus (393 nodes, 983 edges, every node field
equal); ~1.5x wall on a small corpus where WASM init dominates, larger on big ones. atomicWrite
also learned to write through non-regular files instead of renaming over them. (perf-quality
finding 7)
- Python re-export resolution is a table lookup, and every file is masked once.
pyReExportResolve re-masked the whole module and re-scanned its from-imports on EVERY
invocation — once per unresolved pkg.member(...) call site and per imported name, measured at
70% of a Python-corpus extract — and maskPy/maskJs ran up to 4x/2x per file across the
symbol/extent/import/edge scans. Each module's re-export table and each (module, name)
resolution are now memoized (first-valid-binding-wins preserves the old scan order; nulls cache
only from depth-0 calls so a depth-truncated null can't mask a resolvable chain), and a shared
per-file mask cache serves all five scan sites. Byte-identical fragments on a 261-file
re-export corpus (1,460 edges, zero field diffs). (perf-quality finding 8)
- Blast radius de-quadratic'd —
riskis 31x faster at 20k nodes.impactOfdequeued with
shift() (O(frontier) per pop) and materialized+sorted closures its biggest caller threw away;
risk then ran it once per node — measured 19.8s at 20k nodes/60k edges, scaling ~quadratically.
Now: index-pointer BFS in impactOf (and the extractor's pub-API walk), a count-only
impactCountOf, and allBlastCounts — Tarjan-condense the dependents graph, then propagate 64
source-components per pass with word masks (O(V·E/64) time, O(V) memory, dangling edge sources
participate exactly as in the BFS). risk: 19,784ms → 630ms with byte-identical payloads
(blast sums equal); a property test pins all three implementations to each other on random
graphs. (perf-quality finding 9)
- The STAMP TIER: a no-change warm extract reads zero files. Even on 100% cache hits, warm
extraction re-read, re-hashed, re-split and re-masked every byte (the cache recovered ~15%; at
16k symbols a no-change re-map spent ~3.7s on pure re-verification — the floor under the
post-edit hook and MCP auto-refresh). The scan cache now stores every per-file product — nodes
with extents/signatures/complexity, ranges, dynamic flag, raw re-export table, import bindings —
and an mtime+size stamp: a matching stamp reuses all of it with ONE stat and NO read (measured:
760ms cold → ~125ms warm on a 63-file corpus, banner scanned 0/63 … ast: idle). Correctness
gates: role-override changes invalidate the tier (rulesSig); re-export tables gate on the file
list (fileSig); bindings gate on symbol set + file list (bindSig) and lazily re-read exactly the
files a landscape change invalidates. The tier trusts the same stamps checkStaleness trusts;
CODEWEB_VERIFY_FRESHNESS=1 or --full forces the read+hash path. Verified byte-identical
against the no-cache oracle and the 40-trial incremental-equivalence property suite.
(perf-quality finding 10)
parseSignaturecompiles zero regexes. It built a freshRegExpfrom the (mostly unique)
symbol name for every function/method node — 717ms self / 27% of a 2.9s regex-path extract in
profile; V8's regex cache never helps unique sources (micro: 8,400 unique-name constructions =
527ms vs 1.6ms for the scan floor). Replaced with an indexOf scan performing the identical
match — same left boundary, same optional = [async] [function[*] [id]] group semantics
(async still requires trailing whitespace, exactly as async\s+ did), first completing
occurrence wins. Zero fragment diffs on the A/B corpus. (perf-quality finding 11)
- ctags runs once per cold extract, not once per file. The ctags engine spawned one
execFileSync per file inside the main loop (≥0.9s of pure spawn floor per 600 files measured
with a no-op shim; ~10x with real ctags option parsing — minutes of process churn at repo
scale). Cold runs now tag the whole list through ONE process (-L -, list on stdin, bucketed by
the JSON path field); warm runs keep the per-file spawn (misses are few, and one small spawn
beats re-tagging the repo); an untouched warm run spawns nothing (stamp tier). Same graceful
ladder on failure: batch → per-file → regex scanner. Shim-verified: exactly one batch on cold,
zero on no-change warm, exactly one per-file on a one-file edit. (perf-quality finding 12)
- The bench gate now guards every stage and every advisor, under real load. The only timing
gate used to measure the stage-REUSE path (which skips overlap/optimize entirely) — a 10x
regression in any post-graph stage passed CI, which is exactly how the quadratic risk loop
shipped. Now: (a) per-stage wall times parse from run.mjs's own stderr and each gates as a
factor of the regex-extract baseline (budgets note observed values so headroom stays honest);
(b) a pinned deterministic loaded corpus (bench/lib/loaded-corpus.mjs: every function a twin
candidate, 15 planted same-name clusters, ~1,260 candidates so LSH engages on its own — the old
synthetic corpus produced 0 candidates and timed an idle engine) with gates that the planted
clusters are FOUND and the LSH banner fired; (c) risk/deadcode/hotspots/campaign each timed and
factor-gated, and a crashed advisor is itself a violation (observed: risk 0.18x — the
pre-finding-9 loop would have measured ~20x and failed on arrival). The report expand-all bench
row (d) lands with the finding-21 layout rewrite it measures. (perf-quality finding 13)
- campaign's merge chain stopped cloning the graph per candidate. The cumulative pre-flight
ran applyEdit (a whole-graph structuredClone) plus full file-SCC for every ready merge —
289ms/candidate at 20k nodes, the exact pattern optimize's Spec O-1 had already replaced. The
pair-witness delta simulator now lives in graph-ops (createMergeSimulator, one truth) with
chaining commits (commit/commitDelete) so deletes and accepted merges advance the same
table; optimize rides it too (its CODEWEB_OPT_SIM=clone escape and equivalence tests intact).
Measured: 100 candidates at 20k nodes/60k edges = 20,757ms → 729ms (28.5x) with identical
accept decisions; a 120-case property oracle replays the historical clone chain verbatim and
requires step-for-step plan equality. (perf-quality finding 14)
- The edit/PR duplication gate prefilters by set size and caps bodies like overlap does.
incrementalOverlap ran full Jaccard intersections for every changed×pool pair (the dominant
gate term on big PRs) and shingled thousand-line bodies in full while overlap confirmed the
same pair on its first 400 lines. Now: an exact size-ratio early-exit (J ≤ min/max — measured
78% of 37,350 self-map pairs skipped, with 5e-7 slack so knife-edge pairs that round to the bar
survive) plus overlap's BODY_LINE_CAP. A 40-case property test pins prefiltered results to a
filterless oracle. (perf-quality finding 15)
find_similarstops re-shingling the repo on every call. The MCP server prescribes it
before every new function an agent writes, and each call re-read and re-shingled every non-test
body from disk. The report stage now persists each candidate's exact K=3 shingle SET (sorted,
deterministic — sets, not sketches, so there is no probabilistic cut) in a stamped
similar-index.json beside the graph; find-similar serves from it with zero source reads plus
an exact size-ratio precut, and reuses the scoring pass for scanned. Stale/absent sidecar or
--structural falls back to the live path; a regression test pins byte-identical matches
across sidecar, live, stale-fallback, and removed-sidecar paths. (perf-quality finding 16)
- One scan cache per workspace — the first hook fire after a map is no longer cold. run.mjs,
the post-edit hook, and refresh each used a DIFFERENT cache filename for the same workspace
(.scan-cache.json / scan-cache.json / extract-cache.json), so the first post-edit hook
after every map ran a cold full re-scan (28.7s at 16k symbols against the hook's 30s timeout)
and the first MCP auto-refresh was cold too. All three now share SCAN_CACHE_NAME from
lib/cli.mjs — and the hook dropped its lone --no-ctags, which on ctags machines both thrashed
the engine-namespaced cache AND diffed a regex fragment against a ctags baseline (phantom
regressions). With finding 10, a no-change hook/refresh now scans zero files. (perf-quality
finding 17)
- The post-edit hook's fast-path floor, specified and landed (
docs/specs/hook-fastpath-floor.md
— the "baseline-fragment reuse" spec fastpath-daemon.md called for, in its landed form: the
reuse rides the stamp tier's per-file products, serving every extract consumer). The hook's
fragment now streams back on stdout — the pid-named temp file (~1.2MB, never unlinked, leaked
per edit forever) is gone entirely, along with a full serialize+write+read cycle — and the
extractor skips its multi-MB cache write-back when no entry changed (cacheDirty). Measured at
8,804 nodes/52k edges: hook end-to-end ~800ms warm (was ~4s-class; full re-extract alone was
3,350ms), decomposed in the spec with revisit triggers pointing at finding 25's in-process
extraction. (perf-quality finding 18)
- The MCP server no longer head-of-line-blocks, and stale graphs heal in the background.
Every non-fast-path tool ran through spawnSync on the readline loop — a 2.5ms structural
query measured at 527.6ms behind a concurrent campaign, and a stale graph stalled the current
call behind an inline synchronous refresh (up to 4–28s at the 16k benchmark; 120s behind a
wedged child). Children now spawn async (the handleMap pattern generalized), serialized per
workspace so stateful sequences keep their order while fast paths and other workspaces never
wait; auto-refresh is fire-and-forget, serving the stale-but-ANNOTATED answer immediately —
and because the server drains in-flight work before exit, the next call is fresh. Measured:
callers behind a concurrent campaign 527.6ms → 2.4ms. (perf-quality finding 19)
- The tools the server prescribes per edit answer in-process.
codeweb_explainand
codeweb_context — the INSTRUCTIONS' before-every-edit calls — took the spawn path (node boot
+ a fresh multi-MB graph parse per call, ~256ms measured back-to-back) while structural queries
answered from the cache in ~3–13ms. context-pack's payload assembly moved to
lib/context-core.mjs (one truth, two transports — byte-identical JSON, field order included);
explain rides buildCards as the sidecar already did; and context-pack's --json miss now
emits the same found:false + suggestions contract explain adopted in #2 (the old stderr
die() made MCP reply an empty string). Measured, even while a campaign runs: explain 9.3ms,
context 12.3ms. (perf-quality finding 20)
- The report's graph sim is grid-based, frame-budgeted, and freeze-free.
gStepcomputed
all-pairs repulsion per animation frame — 1,174ms/frame measured at 16.3k nodes (×~259 anneal
frames ≈ 5 minutes of sim CPU for one "Expand all" click), and the prefers-reduced-motion
branch ran the whole anneal in ONE synchronous task (a hard freeze, not jank). Now: a uniform
spatial grid (only 3×3-neighborhood pairs interact — deterministic, 11.5x on the dense
micro-bench and far more on real spread-out layouts), a wall-clock frame budget (8ms of sim per
frame — small graphs settle in a blink, huge ones stay responsive), and the reduced-motion
settle chunked into ≤12ms slices with a single final draw. Spec L's harness gained the
expand-all row (finding 13(d)): simMsPerFrame recorded and judged in the verdict, so the
cliff that sat one click past the old green can never regress unmeasured. (perf-quality
finding 21)
- Temp hygiene: nothing leaks into the OS tmpdir. The post-edit hook's per-edit ~1.2MB leak
died with finding 18 (no temp file exists at all); the two test files that leaked
(stdout-flush's three codeweb-flush-* dirs, efficiency-pilot-usage's cw-usage-* dir)
now clean up in finally; and a tripwire in post-edit-diff.test.mjs runs the hook end-to-end
and asserts no codeweb-hook-*.json appears — the leak signature can't return silently.
(perf-quality finding 22)
- SessionStart serves a pre-rendered brief; MCP sweeps staleness once per burst. The
session-brief hook re-parsed and re-indexed the whole graph every session start (97–100ms on
this repo, 310–328ms at 17k nodes) for a payload that is a pure function of the graph — the
report stage now pre-renders brief.json (Spec P stamp pattern) and the hook serves it at the
boot floor, byte-identical output pinned across sidecar/parse/stale-fallback paths. The MCP
server also stat-swept every meta.sources entry TWICE per fast-path request (auto-refresh
check + payload annotation — 2×12–17ms at 5k files against 3–13ms answers); one memoized
verdict per (graph identity, 1s TTL) now serves both, threaded through context-core so the CLI
path is untouched. (perf-quality finding 23)
Added
- Specs K–P, landed on main after v0.9.0 (previously missing from this section — the release
script would have refused to roll an "empty" release over real work):
- K — the scale bench is runnable (
bench/experiments/scale.mjsportable + pre-flighted);
it found the next wall, which N then removed.
- L — report.html measured at 16k symbols (
bench/results/report-scale.json): cold load,
interaction, and heap all green at TypeScript-compiler scale — no fix needed, receipt kept.
- M — the efficiency pilot re-ran budgeted on v0.9.0 (5 engine-frozen reps,
bench/experiments/efficiency-pilot.reps5-v090.json): recall +0.31 ± 0.04 at equal token
cost — the earlier ">90% fewer tokens" prediction was wrong and is recorded as such; the win
moved from cost to completeness. One per-task loss (flask) opened Spec Q.
- N — LSH/MinHash banding in overlap (
CODEWEB_LSH, auto at scale): the quadratic
same-name/twin passes band first, making the scale caps true backstops; identical findings on
small inputs, byte-stable.
- O — incremental stages, O-1 (extract rides the scan cache inside
run.mjs; downstream
stages memoize on the fragment hash). O-2 (incremental cluster/overlap) measured under the
pre-registered rule and deliberately not built — the override is recorded in the spec.
- P — resident daemon: NO-GO (
docs/decisions/fastpath-daemon.md): the per-edit surface is
already at the ~52ms node-boot floor via the pre-edit sidecar (index-lite.json), and a
resident graph risks stale-serving; revisit triggers documented.
- A symbol miss is never a dead end. Every
found:falseanswer (query/dependents/impact
over CLI and MCP, explain, context-pack) now carries a hint pointing at codeweb_find
concept search plus up to 3 deterministic near-match ids (case-fix, prefix, substring,
shared-name-token tiers) — the most frequent agent mistake now teaches the recovery path
instead of sending the agent back to grep. (IMPROVEMENTS.md #2)
Added
- Every ranked surface now honors the role model — and deadcode knows what a host invokes.
The vite-playground precision lesson (rankings drowned by non-product code) was applied to
overlap only; hotspots, risk, deadcode, and campaign still ranked test helpers, bench scaffolding,
and the generated site bundle first on codeweb's own map. All four now default to **product
scope** with a counted exclusion line (--all / MCP all:true restores the everything view).
deadcode additionally learns manifest-declared entrypoints — files named by any
package.json main/bin/exports, hooks/hooks.json, or .claude-plugin/plugin.json are
review-tier, never "safe to delete" (the safe tier used to list the VS Code extension's
activate/deactivate) — and demotes closure-scoped functions (defined inside a reachable
parent's span) to review. Dogfood receipts on the self-map: hotspots' top-10 went from five test
helpers + the generated site bundle to all product code; campaign's plan dropped from 55 steps
(-730 claimed LOC, mostly false orphans) to 26 grounded ones. (IMPROVEMENTS.md #6)
- The report now shows the pipeline's own findings — and every view is a link. The
interactive report loaded graph.overlaps and never rendered it: the Findings tab showed only
a client-side name-match heuristic, so the body-confirmed, tiered findings in
overlap.md/optimize.md never reached the report's audience. A new **Consolidation
findings** section now leads the tab (confidence badges, evidence, recommendation, clickable
symbols; body-refuted candidates counted). The URL hash grew from node-only #s= to the whole
view — #tab=…&roles=…&s=… — restored on load (legacy links keep working), with a
copy link button in the header; hosted reports carry og:title/og:description built
from the target label and counts only (the meta.root privacy invariant holds, re-pinned by
test). (IMPROVEMENTS.md #8)
- The report meets people where they are: light mode, print, full keyboard reach. A light
theme follows the OS preference (prefers-color-scheme) with an explicit auto → light → dark
toggle (persisted; the canvas graph re-reads its label/halo colors from the theme tokens); a
print stylesheet makes the report paper-able; every hover affordance gained a
:focus-visible twin; matrix cells and treemap blocks are keyboard-reachable
(tabindex + Enter/Space + aria-label); the tablist has real tabpanel/aria-controls
wiring and roving arrow keys. Also: the undefined --hi token (the "duplicated" tag silently
lost its color) now uses --stCritical; the treemap caches its render like sibling tabs; the
mobile search-count no longer collides with the input; the editor-root picker is an inline
form instead of a blocking prompt(); and the whole-panel aria-live (which re-read the
entire inspector on every click) became a scoped one-line announcer. (IMPROVEMENTS.md #9)
- The value receipt is felt, not buried. The session brief's activity line used to render
only the current calendar month's bucket and vanish when it was empty — every new user and
every 1st-of-the-month saw silence. It now leads with lifetime totals ("codeweb here since
2026-06: …", current month in parentheses), the brief warns when the map is a week old
("built N day(s) ago — refresh with codeweb_refresh"), a successful run.mjs map ends with
the receipt line, and CLI queries (query/explain/context-pack) now count toward
queriesServed — the denominator stops undercounting non-MCP use. (IMPROVEMENTS.md #10)
- The agent loop closes over MCP — 27 tools — and codeweb_map reports progress. Three
capabilities that existed only as CLIs join the MCP surface: codeweb_simulate (the
regression gate's verdict for a hypothetical delete/merge/move — pre-flight a refactor for the
cost of one call), codeweb_annotate (false-positive suppression memory, written beside
the graph, never to source), and codeweb_stats (the local value receipt). codeweb_map
is now async and, when the client sends a progressToken, emits notifications/progress per
pipeline stage — a big first map is no longer a silent black box (in-flight work drains before
the server exits). The MCP/CLI parity receipt is re-measured at the full surface
(bench/results/auxiliary.json): 26/26 parity pairs + JSON-RPC conformance at 27 listed tools,
11/11 auxiliary checks green — the old receipt covered the 20-tool era and its harness had the
count hardcoded (now derived). The handshake instructions teach the new loop steps.
(IMPROVEMENTS.md #11)
- Coverage→symbol mapping — "is this symbol actually tested?" is now a measured fact.
(ROADMAP Phase 4's named-missing mechanism.) scripts/coverage.mjs (npm run coverage)
ingests a coverage report — lcov text (Node's own
--test --experimental-test-coverage --test-reporter=lcov output works directly) or a
c8/istanbul JSON — and stamps every instrumented symbol with covered/hits (declaration-line
hits don't count as body execution; unknown ≠ uncovered; ambiguous path suffixes dropped).
explain, query --tests, and context-pack answers then carry the facts — loudest one:
⚠ NOT covered by the recorded test run on the edit window of an unguarded symbol.
Optional + explicit like --churn (absent input leaves graphs byte-identical);
run.mjs --coverage <report> annotates right after mapping; codeweb_refresh drops stale
annotations and says how to restore them. Pinned end-to-end by tests/coverage.test.mjs,
including a real Node-runner dogfood. (IMPROVEMENTS.md #13)
- Ruby and PHP join the dispatch tier; Kotlin/Swift's blocker is recorded, not papered over.
Two new vendored grammars (same pinned trusted source, @vscode/tree-sitter-wasm@0.3.1; Ruby
ABI 14, PHP ABI 15) power Spec-F-pattern walkers: Ruby wires self./implicit-receiver calls
inside a class (the parser has already disambiguated a call from a bare identifier, so wiring
to a sibling method is precision-safe); PHP wires $this->m() plus Type $p typed-receiver
intents under the one-owner rule. The regex tier's bare-name resolution simultaneously got
STRICTER for both languages — a bare name can never reach another file's owner-qualified
method on a name coincidence anymore (that attribution now belongs to the dispatch tier, which
has receiver evidence). Kotlin/Swift stay regex-only with the blocker recorded in
PROVENANCE.md: no trusted wasm exists at our pinned ABI (upstream ships C sources and native
prebuilds only) — revisit when @vscode/tree-sitter-wasm grows them. (IMPROVEMENTS.md #14)
- The gate is a reviewer for adopters, not just a red ✗. codeweb's own PRs already got the
sticky structural-review comment (delta, renames, findings); the reusable composite action
third parties consume ran the gate silently. The action now takes comment: true and posts
(and updates in place) the same digest via the calling workflow's GITHUB_TOKEN — the comment
lands before the verdict can fail the job, fork PRs with read-only tokens degrade
gracefully to the check verdict, and docs/ci-gate.md documents the
pull-requests: write requirement. Reviewers who never installed codeweb now see the blast
radius of every gated PR where they already look. (IMPROVEMENTS.md #15)
- The CLI grew a front door. Every CLI answers
--help/-h(exit 0) — including the
codeweb bin, where --help previously errored with target not found: --help; run.mjs
documents all its flags, rejects unknown ones with usage (exit 2), and ends a successful map
with open <ws>/report.html in your browser. Graph resolution now walks up to the nearest
.codeweb/graph.json above the cwd when no path/CODEWEB_WS is given (the same discovery the
hooks and MCP server already used) and announces which graph it picked — so bare
npm run stats, query.mjs --callers X, and context-pack.mjs <symbol> work from anywhere
inside a mapped repo. Exit codes on the first-touch scripts (run, extract-symbols,
build-report) now follow the documented 0/1/2 convention. (IMPROVEMENTS.md #5)
Fixed
- Spec Q closed: the flask Python import-edge regression was real, and it's fixed. The
spec's bisect contract was executed first: the reps8-era engine (c892f50) resolved 14
render_template dependents, v0.9.0 resolved 7 — the package-boundary precision rule had also
killed calls backed by an explicit from flask import render_template. Three Python
resolution gaps fixed (each pinned by tests/python-src-layout.test.mjs): single-segment
absolute imports now resolve the repo's OWN top-level package (rooted, src-layout aware —
import json still can't grab a nested in-repo package); __init__.py re-exports are
followed (bounded chain) on both the from-import and the pkg.member() paths; an explicit
import binds bare calls across package boundaries (the import is evidence — unimported bare
names still respect the boundary); module-level import sites attribute to <module> (site
granularity). Flask pre-flight: 7 → 48 dependents, 26/26 truth sites under id
normalization. SCANNER_VERSION 11→12 (cached edges invalidate); the research-page caveat now
records the closure. (IMPROVEMENTS.md #12)
- The VS Code lens is truthful again — and covers all 11 languages. The extension README
promised "the lens re-reads the graph on change," but no watcher or change-event existed:
lenses showed stale numbers until a file was reopened. A FileSystemWatcher on
**/.codeweb/graph.json now fires onDidChangeCodeLenses (debounced), a manual
codeweb: Refresh CodeLens from the graph command exists, and the language selector grew from
9 to the engine's 11 (Ruby/PHP/Kotlin/Swift files finally get lenses). Extension v0.2.0.
(IMPROVEMENTS.md #7)
- The public prose can no longer understate the product — and what it said is fixed. The
homepage, product page, start page, and og-descriptions said "20 tools" (24 ship); the /codeweb
command steered agents off the fast path for 8 of the 11 native languages; the engine-detection
reference said five; README's axios headline carried pre-regeneration numbers (334/11 vs the
real 274/8); the site still promised the deleted sharding layer; the tree-sitter backlog doc
claimed "nothing wired" about a tier that shipped two releases ago. All fixed at the source —
site prose now derives counts ({{toolCount}}/{{langCount}}), and check-consistency gained
prose scans (digit and word forms) plus a claim-ledger "N / N tools" check, so this class of
rot now fails the build. Site prose counts are template-derived (toolCount/langCount
placeholders filled at build time). The 2026-07-18 product review is archived to
docs/product-review-2026-07-18.md with a historical header. (IMPROVEMENTS.md #3)
npm testis green again on a fresh zero-dependency clone. The Spec-N LSH suite's N6 case
asserts a Type-3 (AST-tier-only) finding but was missing the tree-sitter unavailable skip
guard every other AST test carries — so the suite failed 1 test exactly on the "just Node"
install path the README celebrates (CI never saw it because CI installs the optional
dependency). Verified both ways: runs with the engine, skips without. (IMPROVEMENTS.md #4)
- An empty scan no longer masquerades as a successful map. Pointing codeweb at an empty
directory, an unsupported-language tree, or a typo'd path used to print a green [run] done
and write a blank report. The extractor now stops with an actionable message — the path it
scanned, the supported extensions, and "is this the right directory?" — and run.mjs aborts
before writing artifacts. --allow-empty (both CLIs) keeps intentionally-sparse targets
workable. (IMPROVEMENTS.md #1)
Removed
- spike/ and .live/ are out of the tree — the self-map stops counting prototypes.
spike/tree-sitter/ was a graduated prototype (the engine shipped as lib/ts-engine.mjs in
PR #17) still tracked with its own package.json and drifted copies of production functions;
.live/ tracked five dev scripts nothing referenced, every one superseded by shipped code
(confirm.mjs → overlap's body confirmation, rust-dissect.mjs → the native Rust tier,
cli-sketch/ → finding 24's parseArgs, measure.mjs → cluster3). Both deleted — git history
keeps them; prose references now say so. .live/ is fully gitignored (it stays run.mjs's
default workspace target, generated artifacts only), and codeweb.rules.json gains standing
spike/ → example and .live/ → generated role overrides so a future prototype tree can
never pollute the self-metrics again. Self-map effect: 13 → 12 domains (spike/tree-sitter
gone), 33 → 28 self-overlap findings (all five spike-only findings gone), 1,240 → 1,215 nodes.
(perf-quality finding 28)
Changed
- THE similarity thresholds live once — and churn is bounded and cached. The 0.6
high-confidence floor (with the 0.35/0.15 bands) was independently hardcoded in four files and
the K=3 shingle size in five, each copy documenting the coupling in a "must match overlap.mjs"
comment instead of importing it. lib/shingles.mjs now exports BANDS/K; overlap's
confidence tiers, optimize's READY floor, find-similar's tiers + low-band cutoff, dup-check's
HIGH gate, similar-index's SIMILAR_K, and skeleton's default all import them (values unchanged
— pipeline outputs byte-identical). Same disease, different organ: the full-history git log
churn parser was byte-near-identical in risk.mjs and hotspots.mjs, unbounded (whole history
per advisory run) and uncached (campaign ran it twice back-to-back). lib/churn.mjs now owns
it: windowed to the last 5,000 commits (deterministic per HEAD, unlike a --since that drifts
with the clock) and cached beside the graph keyed by rev-parse HEAD + window — on this repo,
hotspots --git after risk --git serves from the cache in ~97ms with zero git spawns.
tests/churn.test.mjs pins counts, the poison-proved cache hit, HEAD invalidation, the window
bound, and the not-a-repo {} degradation. (perf-quality finding 27)
- One dispatch skeleton, seven language tables.
ts-engine.mjsrepeated its per-language
dispatch walker seven times (Java/C# via a shape closure; Ruby/PHP/Python/Go/Rust as five
hand-copied closures), with up() defined five times in two drifted signatures,
typedParamsOf four times plus a renamed paramTypesOf, and the owner-index + dedupe + sort
epilogue cloned per language — the same-name copies were also why deadcode showed @-suffixed
orphan ids for this file. Now makeDispatchWalker(parser, table) owns THE skeleton (ancestor
climb upTo, typedParamsFrom, seen-key dedupe, sorted {thisCalls, typedIntents} return,
try/finally tree.delete()), and each language declares only its genuine tree shape:
collectOwners / callSite / enclosingOf / isSelf / identName / typedParams —
~25 declarative lines per language, 762→735 total with seven walkers collapsed to one.
Verified byte-identical on a purpose-built 7-language dispatch corpus (this-calls AND typed
intents wired in every language, incl. Go receiver-variable "self", Rust &-ref stripping,
PHP nullable-type params) plus pycorp and corpus7; language/AST suites and the full 582-test
run green. (perf-quality finding 26)
- The extractor is decomposed — and testable in-process.
extract-symbols.mjswas a
1,500-line script/module hybrid: per-language scan rules, body extents, signatures, import/
re-export/member resolution, and the caches all interleaved, with byName/files as module
globals — so every extractor test shelled out (253 runNode( spawn sites across the suite).
Two pure modules now hold the substance: lib/lang-rules.mjs (the 11-language regex symbol
scan, bodyEnd brace/dedent extents, parseSignature, KEYWORDS/DYNAMIC_RE/langOf — functions
of their arguments, no IO) and lib/import-resolve.mjs (createImportResolver(ctx) — relative
and Python module resolution, JS/TS re-export chains incl. export *, Python from-import
re-export tables, unique-member lookup, and per-file import binding, all over injected context
with no module-global state). The orchestrator keeps enumeration, caching (stamp/hash/bind/rex
replay), engines, and edge derivation — 968 lines, down from 1,506 — and the extractor's own
SRC extension list is now the shared SRC_RE from lib/common.mjs (the hand-mirrored copy
the file itself warned could drift). Bodies moved verbatim; proof: fragments byte-identical
old-vs-new on four corpora × both engine tiers × cold/warm/replayed-cache paths, including an
old-engine scan cache replayed by the new engine, plus a same-tree self-map A/B. The first
in-process extractor tests land with it (tests/lang-rules.test.mjs: 10 cases, 137ms, zero
spawns — the seam #30's spawn-collapse needs). (perf-quality finding 25)
- One flag loop. 25 scripts carried the identical hand-rolled
for (…argv…)parse with three
drifting policies — run.mjs rejected unknown flags (the documented #5 convention) while
trend/build-report/extract-symbols still swallowed them as positional paths (the exact bug
class #5 fixed: --help becoming the target path), and several CLIs answered no --help at all
(ci-gate, screenshot, extract-symbols). lib/cli.mjs now owns THE loop — declarative
parseArgs(argv, spec) with one policy: unknown flag → exit 2 with usage, --help/-h → usage
and exit 0, typed values (bool/string/number/float/pair) with missing/non-numeric
values rejected by name. All 25 scripts migrated to ~10-line specs (hand-written usage strings
kept — they're part of tested contracts); build-report also drops its near-verbatim copy of
the graph-load error handling and rides loadGraph like every other tool. The pure helpers
(SRC_RE, SCAN_CACHE_NAME, sign, capList) moved to side-effect-free lib/common.mjs
(re-exported from cli.mjs) so stage scripts can import constants without cli.mjs's module-level
EPIPE handler. Dogfood: this was codeweb's only HIGH-confidence self-finding, now zero
hand-rolled loops remain (grep proof: the one flag loop left is parseArgs itself).
(perf-quality finding 24)
Fixed
- Releases are gated. Tag-push and workflow-dispatch publishing both flow through a
testjob
first — publish now needs: the full suite plus the consistency check, so a failing suite
blocks the release instead of shipping past it. Dispatched refs must be ancestors of origin/main
(checked before the tag is created; a stray branch can no longer become a tagged release), and
@vscode/vsce is exact-pinned to 3.9.2 at both call sites instead of executing whatever npm
serves as @latest that day. tests/workflows.test.mjs pins the gates as text invariants —
the round-1 regression class was gates silently dropped from these files.
(perf-quality round 2, finding #2)
Changed
- CI got breadth, and the AST tier can no longer silently un-test itself. The test job runs an
os x node matrix (ubuntu/windows x Node 22/24, npm cache on every setup-node block); after
npm ci a probe (node -e "await import('web-tree-sitter')") fails the job when the
optionalDependency install hiccuped, and the skip count is bounded (ceiling 8 = a named runner
census of 7 environment skips — 3 golden-target, 1 TS_MODULE bench, 1 inverse fallback, 2
playwright report-scale — + 1 headroom; windows runs its own ceiling 11, its leg adding exactly
the 3 named ctags-shim platform skips; the tier-wide failure mode adds ~38 skips and trips
either ceiling hard; both node 22's TAP and node 24's spec-style summaries are parsed). A new
test-no-ast job installs with
--omit=optional and proves the regex fallback green stand-alone, asserting the
fallback-equivalence test ran UN-skipped (it inverse-skips wherever the engine is installed —
i.e. always in the matrix job). codeweb-gate.yml now runs npm ci, so the structural self-gate
analyzes PRs with the same engine tier the product ships instead of regex-blind. engines says
>=22 honestly — Node 20's npm test glob is broken and the matrix never tested it. The
matrix's first real windows catch: bench/all.mjs dynamic-imported a bare absolute path — an
unsupported ESM scheme on windows (D:\...) that crashed the whole bench; it imports via
file:// URL now. (perf-quality round 2, finding #3)
- The suite's 60-second floor is gone. IE-EQUIVALENCE ran its 40 property trials sequentially
in one test() (59.8 s solo — nothing else exceeded 13.3 s); the trials now run as concurrent
subtests (cap 4) over the new async spawn helper runNodeAsync, with the depth env-gated as
CODEWEB_IE_TRIALS (40 in CI — unchanged depth — / 10 local; a typo'd value throws instead of
passing vacuously). Per-trial seeds make trials independent and concurrency-safe: fixture bytes
differ from the serial version, the semantics class (random 2–6-step mutation sequences, warm ≡
cold assert per step) does not. 40 trials: 58.0 s → 15.3 s; full suite ~93 s → ~55 s class.
(perf-quality round 2, finding #6)
- Correction to the round-1 claim. Commit
bfc6b92("implementation (all 32 findings)") did
NOT land findings 29–32 — the release gate, the test split, the CI matrix/skip guards, and the
consistency-gate coverage; this changelog documented findings 1–28 only. They ship in this round
as findings #2/#6/#3/#4. The four dropped items were precisely the gates that would have caught
the drop, so the honest closer is structural, not narrative: per-finding changelog entries land
WITH each finding (an empty [Unreleased] can no longer be rolled over shipped work), and the
package.json prose scan (finding #4) plus its regression tests make the surface where the drop
was publicly visible fail the build by itself. (perf-quality round 2, finding #1)
Fixed
- The consistency gate scans package.json — and the npm listing stops lying. The description
said "24 MCP tools" while 27 shipped, and check-consistency printed OK over it because
package.json was in neither the prose scans nor the sync targets. The gate now scans the
description (description-only — keywords/scripts can't false-positive), version-sync repairs the
count at every roll, the live drift is corrected to 27, and the drifted-fixture round-trip in
tests/release-tooling.test.mjs pins the class. Mid-change, the scan alone turned the real-repo
consistency tests red on the live drift — the gate catching, in-repo, exactly what it was built
for. (perf-quality round 2, finding #4)
npm testno longer rewrites tracked docs/ — and the published site can't go stale again.
site/build.mjs gained --out <dir> (the shared parseArgs loop; unknown flags die), the whole
site-build suite builds into a temp dir, CI asserts docs/ is byte-fresh after the build
(git diff --exit-code -- docs plus an untracked-outputs check), and the stale committed
docs/changelog.html — the published Pages changelog was missing the round-1 "Removed" section
at HEAD — is rebuilt and committed. Argv-less callers (release.mjs, npm run build:site, CI)
keep the docs/ default. (perf-quality round 2, finding #5)
- The docs drift trio. (a)
--enginenow validates its value — unknown engines (including a
typo'd CODEWEB_ENGINE, which feeds the same default) exit 2 with the valid set instead of
silently ENABLING the AST tier, and README's nonexistent --engine read mode is gone (ts
stays a valid tree-sitter alias). (b) The release-tag skill's runbook numbers match reality
(~590 tests, up to ~5 environment skips — it said "~400, tree-sitter-absence"). (c)
scripts/release.mjs exits 1 when its own consistency audit fails, BEFORE printing the gated
git commands — release prep can no longer end in "consistency: N problem(s)" followed by the
exact commands to ship anyway. (perf-quality round 2, finding #7)
- One huge single-line string can no longer kill the entire extract. The string regexes in
complexity.mjs's strip, maskRuby's RB_DQ/RB_SQ, and ts-engine's stmtHash used the
alternation form V8 recurses per character — a >=8.4 MB inlined string (base64 asset, dataset)
threw an uncaught RangeError from cyclomatic in BOTH tiers, taking the whole map, post-edit
hook, and MCP refresh with it. All three sites now use the unrolled-loop form, preserving each
site's escape atom (\\. vs \\[^] — they differ on backslash-newline in templates; a seeded
5,000-case per-site equivalence property pins each), and cyclomatic/nestingDepth are belted:
any internal throw degrades that node to 1/0 instead of killing the run — combined with
ts-engine's existing null-on-throw per-file fallback, no single file can kill an extract in
either tier. SCANNER_VERSION 13 -> 14 so warm caches can't replay pre-fix products.
(perf-quality round 2, finding #15)
- The JS masker learned nested templates.
maskJs's two scalars (inTemplate, exprDepth) could
not represent a template inside ${} on ordinary modern JS: nested-template TEXT stayed live
(` Usage: ${xs.map((n) => fabricateMe(${n}))} fabricated a call edge), a }` inside that
nested text closed the interpolation and INVERTED template state (edges lost, extents run to
EOF), an escaped ` \ ` did the same (no \ handling in text), and ${}`-interior strings were
kept verbatim even in blank-values mode (string content fabricated edges — deadcode's safe tier
corrupted again, one idiom over from round 1's regex-literal fix). Template state is now a stack
of frames (nested backticks push; text \ consumes escapes; expr strings route through the
keepValues gate, so codemod's two-view diff classifies a name inside ${'…'} as inside-a-value),
every delimiter backtick blanks in default mode, and default-mode maskJs is now IDEMPOTENT over
the corpus — pinned with per-line length preservation by the new shared property suite
(tests/masking-properties.test.mjs, corpus-scoped with the value-then-division counterexample
documented). Five repro fixtures run under both engine tiers in
tests/maskjs-nested-templates.test.mjs. (perf-quality round 2, finding #8)
- Spread calls edge; IIFE-initialized consts are values, not functions. The two mis-lexings
that made BOTH of the self-map's deadcode "safe to delete" verdicts false: (a) ...metrics( was
routed into the member-call branch by the . before the match, where the backward identifier
match can never succeed on dots — the call edge was silently DROPPED and trend.mjs:metrics
showed 0 callers; a ..-preceded match now falls through to addEdge (a?.b( and ...obj.fn(
verified unchanged). (b) The const-arrow rule matched const PERM_SEEDS = (() => {…})() — an
IIFE-initialized VALUE became a function node invisible to callRe/refRe, a guaranteed deadcode
false positive; =(?!\s*\(\() now rejects it (the accepted loss — a genuinely function-valued,
non-invoked const g = ((a) => a) — is pinned in the test so re-widening is a conscious flip).
Dogfood re-run: trend.mjs:metrics has 2 call edges in, PERM_SEEDS is no node, and the
self-map deadcode safe tier is EMPTY (was: exactly these two false positives — campaign would
have emitted DELETEs that break trend and minhash at runtime). The self-map regression is now a
test (tests/spread-iife-selfmap.test.mjs extracts the real trend.mjs + minhash.mjs texts).
(perf-quality round 2, finding #9)
- **Accessors both exist, declaration lines fabricate nothing, and the regex tier sees modern TS
methods.** Three related truths: (a) the AST tier's method dedupe silently DROPPED the second
body with a colliding id — always a real get/set pair or static/instance same-name (TS overload
signatures are never framed); the second now suffixes @ + its 1-based start line — the scheme
the regex tier's file-level disambiguator already used, so both tiers emit byte-identical ids
(getter keeps the bare id: no query/fingerprint churn) — and the cross-tier defensive dedupe
suffixes instead of dropping. (b) The self-definition guard covered only a node's own start
line, so setter/impl declaration lines were scanned as calls with the CLASS as scope —
fabricated Widget -> Widget.value phantom callers hid every accessor/overloaded member from
deadcode; a per-file declStarts map now skips any same-named declaration line, and body-less
overload stubs (which have no range) are covered by a class-enclosed stub-line guard over both
callRe and refRe (class-gated — narrowed from the audit's unconditional line guard, which would
have suppressed 112 ordinary finish(code);-shaped statement lines in this repo alone).
(c) The regex method matcher stacks modifiers, admits default params ([^;]* interior — not
[^;{}]*, which would regress destructured params) and : Type return annotations —
get value(): number {, compute(n: any): number {, render(x = 1) { were all invisible, their
bodies' calls re-attributed to the class. Self-map after: zero class->own-member edges.
tests/accessor-overload-truth.test.mjs runs the fixture in BOTH tiers and asserts identical id
sets. (perf-quality round 2, finding #12)
- Ruby heredocs and PHP
#comments stop fabricating symbols and edges. Ruby's symbol scan ran
on RAW text and maskRuby had no heredoc state, so a <<~SQL body containing helper(1) and
def phantom_method produced a phantom node and a fabricated module->helper call edge.
maskRuby is now a stateful line loop with a FIFO queue of pending heredoc tags: body and
terminator lines mask to empty lines (nothing inside a body can queue, scan, or edge), the
opener TOKEN masks to the literal '' so sql = <<~SQL.strip stays live code, a << b shift
and <<= never match, stacked f(<<~A, <<~B) queues in order, and ~/- tags terminate by
trimmed equality while plain tags need column 0 — and the Ruby scan now consumes masked('rb')
(the edge scan already did). PHP routes through maskJs, which now takes {hashComment:true}
(.php only — JS private fields unaffected): # legacy note: helper(1) no longer fabricates a
module->helper call. Accepted limits documented in the mask header: heredoc-interior #{…}
interpolation blanks with the body (the pre-existing Ruby interpolation gap), and quoted-tag
<<~"TAG" openers are eaten by the string replaces first (backtick-quoted tags still open).
(perf-quality round 2, finding #13)
- Python f-strings keep their
{…}code.maskPytreated f-strings as plain strings, so
return f"total={compute(x)}" dropped the report -> compute edge — functions invoked only
from f-strings (logging/formatting, idiomatic Python) showed 0 callers, poisoning caller counts
and blast radii; the maskPy limit list didn't even mention it. A quote preceded by a 1-3 char
[rRbBuUfF] run containing f/F (covers f, rf, fr, Rf, …) is now an f-string: {…}
interpolation code is kept verbatim in both modes (the exact analogue of the JS ${} rule) with
a brace-depth counter for nested {} (dicts, f"{x:{w}}" format specs), {{/}} blank as
text, triple-quoted f-strings carry expr state across lines, and a quoted run INSIDE the expr
blanks through the keepValues gate as one slice — NOT verbatim, which would fabricate edges from
string content (f"{'compute(1)'}", the #8 n4 analogue — pinned by the decoy2 fixture) and
break the shared idempotence property. keepValues output stays byte-identical to before
(differentially verified). Limits documented in the header (nested same-quote f-strings are
best-effort). (perf-quality round 2, finding #14)
- NodeNext
.jsspecifiers reach their.ts/.tsxsources. Under
moduleResolution: node16/nodenext TypeScript REQUIRES relative imports spelled with the
emitted extension (./x.js for ./x.ts) — the resolver's candidate list never mapped those
back, so alias, namespace (import * as u → empty nsmap), and re-export edges silently
vanished in modern TS repos, with the unique-bare-name rescue masking the hole just often
enough to look random. One exported candidate builder (importCandidates + EXT_REMAP:
.js→.ts/.tsx, .mjs→.mts, .cjs→.cts, .jsx→.tsx, plus the missing
/index.tsx|jsx|cjs|mts|cts candidates) now serves both resolveImport and the
pub-entrypoint walk — the walk's stale duplicate list (already missing .tsx/.jsx//index.mjs,
so a main: "./src/index.js" backed by src/index.ts never marked anything pub) is gone.
Direct extensions stay before remaps: the literal on-disk file wins (Node runtime semantics —
a deliberate, documented divergence from tsc's substitute-first probe order), so every remap is
a pure recall addition and existing corpora extract byte-identically (A/B-verified against the
pre-fix resolver). .mts/.cts join SRC_RE and the JS/TS dispatch points — the remap table
only means something if those sources are enumerated. Six fixture packages in
tests/import-nodenext.test.mjs (disambiguation, namespace member, re-export chain + barrel
dependent, .mjs→.mts + /index.tsx, pub-walk mirror, both-exist precedence pin).
(perf-quality round 2, finding #11)
- Bare-ref magnets are gone — parameters stop fabricating cross-role edges. Measured on the
self-map: 234 of 1,180 ref edges targeted 8 single-letter test/bench symbols and 209 ref edges
ran product → non-product, all fabricated from PARAMETERS — refRe scanned declaration lines
(function metrics(g) { emitted a ref from metrics to a test file's global g) and body uses
of params hit the role-blind unique-global fallback. The codebase renamed its own parameters to
dodge this (sourceReader's relPath, import-resolve's relPathOf/recTextOf/maskTextOf —
comments admitting it). Four mechanisms now close the class: (1) refRe skips declaration lines
(with paren-balance continuation for multi-line signatures; callRe/inherit/instanceof scans
untouched); (2) a bare name token-bound by the signature of ANY enclosing range never reaches
the fallback — shadowing semantics, per binding (a call through a param invokes the param's
value, never the global; sig.raw over-collection only ever suppresses fallback edges);
(3) the unique-global fallback rejects 1–2-char names, counted and surfaced in the banner as
(N short-name) — never silent (the 4 short product symbols all resolve same-file; nothing
legitimate is lost); (4) ref kinds are role-gated in REJECT form — a product caller whose
unique in-package def is non-product code drops as ambiguous (filter-form would fabricate a
product→product edge from a name collision); the gate is deliberately one-directional, the
missing mirror of the test→product relabel that feeds testIn/coverage. Self-map after: refs
into ≤2-char symbols 234 → 2 (both same-file refs to a real one-letter bench formatter),
product→non-product refs 209 → 0, and both rename workarounds are deleted with the natural
names restored — tests/self-map-roles.test.mjs re-extracts the repo and pins the invariant
plus the exact cycle class the renames dodged; tests/extract-refscope.test.mjs isolates each
mechanism (including the test→product survival pin and the per-binding positive control).
SCANNER_VERSION 14 → 15 so warm caches can't replay pre-fix edges.
(perf-quality round 2, finding #10)
- Overlap's Signal B honors the product-role scope its header advertises. The role filter fed
Signals A and C (via defs), but Signal B's twin candidates came from outLabels built over
ALL graph edges and the twin loop never consulted roles — on the self-map every emitted
parallel-impl finding paired test helpers ("merge the test helpers", the flagship bad advice
role-scoping was built to kill) while the header claimed those symbols were excluded. One
caller-side filter on cand closes it: both members of every pair come from cand, so the
twin loop, LSH and exact paths are all covered; considerPair/jaccard judging is untouched;
CODEWEB_ALL_ROLES=1 short-circuits before the role lookup and restores the old scope exactly
(byte-identical findings, A/B-verified against the pre-fix overlap on the same map — 16
test-helper pairings on the post-#10/#11 self-map, all excluded by default, ONLY they).
Signal-B case in tests/overlap-roles.test.mjs writes call-kind edges directly to exercise
the caller-side filter. (perf-quality round 2, finding #16)
[0.9.0] - 2026-07-19
Added
- Warm refreshes stop paying the AST tax (Spec A). The tree-sitter engine now initializes
lazily — a cheap availability probe decides cache namespaces and meta stamps up front, and
the WASM runtime loads only at the first file that actually needs a parse. AST products
(qualified methods, dispatch edges, exact per-node complexity) ride the scan cache, so a
warm cached extraction on codeweb itself dropped 1.89s → 0.38s (5×) with byte-identical
fragments — felt directly by the MCP auto-refresh, the post-edit hook, and every staleness
check. The banner now reports the tier's state (ast: loaded|idle|off).
- The AST performance gate finally has a committed verdict
(bench/results/ts-engine-bench.json): cold extraction costs 3.6–4.3× regex
(~+1.35 ms/symbol, axios + self) — paid once per changed file — and the warm path is
engine-free, so default-on stands. Also fixes the bench's regex arm, which had silently
benchmarked tree-sitter against itself ever since the tier went default-on.
- The pipeline memoizes its downstream stages (Spec B). cluster/overlap/optimize/report
are pure functions of the extracted fragment (+ CODEWEB_* levers), so a re-run whose
fragment is byte-identical reuses their outputs — wall-time changes, never a byte
(property-tested modulo the generatedAt stamp). Extract itself now rides the scan cache
inside run.mjs, making a no-change re-map of codeweb ~0.4s end to end. --full
forces a recompute.
- Overlap survives monorepo scale — with declared caps. Mapping TypeScript's
src/
exposed two quadratic passes (overlap sat at 100% CPU for 8+ minutes): all-pairs body
confirmation inside huge same-name groups, and twin seeding through hub labels with
thousands of callers. Same-name groups now body-confirm on a deterministic 12-node sample
(the finding's evidence says so), >50-caller hub labels are excluded from twin seeding, a
200k global pair budget seeds smallest groups first, and 400+-line bodies shingle their
first 400 lines — every cap counted in the md header. Deterministic, reported, never silent.
- Fixed: an input-dependent HANG in the shared tokenizer. The string-literal regex in
lib/shingles.mjs ((?:\\.|(?!\1).)*) backtracked exponentially on unterminated-quote
content — one lone apostrophe in a big real-world body (TypeScript's testRunner fixtures)
pinned the whole overlap stage at 100% CPU for 15+ minutes. Replaced with a linear-time
matcher (each char consumed exactly one way) + a regression test; identical semantics.
Found by the Spec B scale test; this also protects find_similar and the edit-time
duplication check, which share the tokenizer.
- The scale verdict is committed (
bench/results/scale-typescript.json): TypeScript
src/ — 16,286 symbols, 44,640 edges, 709 files including checker.ts — maps in **43s
cold, 4s memoized re-run**, queries answer in ~200ms on the 13.5MB graph, and the top
findings are real compiler duplication (substituteExpression ×9, drifted). On that
evidence lib/shards.mjs is deleted: monolithic graphs hold with 10× headroom, the
sharding layer had zero consumers, and git history keeps it if a 100k-symbol case ever
materializes.
- codeweb consolidated itself (
bench/results/self-campaign.json): the campaign's three
product-true merges are executed — findTarget (both hooks; the duplication had hidden
REAL drift, one hook's language list was seven languages stale), load() (diff/review →
the canonical loadGraph, with better errors), and edgeKey (break-cycles/diff →
graph-ops, NUL-separated collision-safe variant). Before/after gate: −22 nodes, −2
duplication findings, coupling −25, zero structural regressions. The 65 DELETE steps
are honestly skipped as false orphans (walker closures dispatched through function tables —
exactly the --orphans cross-check caveat, applied to ourselves).
npm run bench:all— the standing benchmark suite behind every published number
(Spec C): pipeline timings, a representative 12-call MCP session (bytes/≈tokens/validity),
per-tool response budgets, and the ts-engine gate, written to bench/results/benchmarks.json
(+ a site mirror). A new CI job runs it with --gate: budgets live in bench/budgets.json,
and breaking one fails the build. check-consistency now also audits that every evidence
source cited by the ledger and README exists — claims physically can't rot.
- Replay A/B gains cost-to-coverage (Spec D). The workflow runs cells sequentially and
records each cell's true token cost from the harness's own budget.spent() deltas (never
solver self-report); the analyzer reports per-condition means and costToFullCoverageTokens
— the discriminator when both arms hit the coverage ceiling. Old cells without cost report
null and are counted, not fabricated. Corpus v3 is frozen after nine mining runs across
six repos (funnels committed as bench/results/replay-mine-*.json): the miner re-derived
the v2 task independently at a wider follow-up window (identical answer key — the instrument
reproduces its own ground truth), one new 4-caller candidate was rejected under
hand-verification (a feature-PR rewrite mentioning the symbol, not a caller fix), and the
corpus stays at one fully-verified task — honestly. True-breakage tasks are rare; that is
the finding.
- Role overrides (Spec E).
codeweb.rules.jsongainsroles: [{glob, role}]— heuristics
can't know a repo's private layout, so the repo says it once and extraction honors it (first
match wins, invalid roles fail loudly, absent config is byte-identical to before). codeweb's
own config marks docs/** as generated (it's the built website), completing the
vite-playground precision lesson on our own map.
- Python, Go, and Rust join the dispatch tier (Spec F). Three vendored pinned grammars +
dedicated tree walkers wire the calls regex precision-gates away: self.m()/cls.m(),
Go receiver methods, Rust self.m(), and typed-receiver calls (p: Pipe, q Pipe,
p: &Pipe) resolved globally under the one-owner rule — ambiguity drops and is counted,
never guessed. Nodes stay byte-identical between engines; products ride the scan cache.
- Caller-reliance contracts v2 (Spec G). The explain card (and the ambient pre-edit card)
now also reports: callers that try/catch or .catch() the symbol ("thrown types are
contract"), callees that mutate a named parameter ("callers share that object"), and callers
that null-check the result ("keep null/undefined returns possible"). Same conservatism as
v1 — line-visible evidence or no claim.
- Type-3 (near-miss) clone detection (Spec H). The AST tier fingerprints each function's
statements (identifier/literal-normalized, order-independent multiset); overlap pairs bodies
sharing ≥70% of them — reordered or lightly-edited copies the exact and Type-2 passes cannot
see. A distinct near-miss-clone finding kind, REVIEW-only by construction, bounded and
deterministic.
- Ruby, PHP, Kotlin, and Swift on the deterministic fast path (Spec I) — eleven native
languages. Discovery with owner-qualified methods (Ruby def self., Kotlin extension
fun Type.name, Swift extension members), visibility-as-export per language's own rules,
comment/string masking, precision-gated calls, test-role detection (spec/, _spec.rb,
*Test.php, *Tests.swift), and package manifests (Gemfile, composer.json, Package.swift).
Verified on sinatra (1,173 symbols), monolog (1,622), okio (3,874, mixed Kotlin+Java),
and Alamofire (2,616) — zero keyword phantoms, deterministic.
- The report closes the loop to the editor (Spec J). The inspector shows
Open in editor
(vscode://file/...) once the viewer supplies their project root — stored in localStorage,
client-side only, because the shipped report still never embeds the absolute source path
(the standing privacy invariant, now pinned by a second test).
- npm + VS Code distribution, prepared (Spec J). The package is publish-ready (
codeweb+
codeweb-mcp bins with shebangs, files whitelist, LICENSE, zero runtime deps — verified by
an npm pack test); the release workflow builds and attaches the .vsix to every GitHub
Release and carries npm/Marketplace publish steps that no-op until NPM_TOKEN/VSCE_PAT
are configured. Publishing stays a human decision; the prep is done.
Changed
- The paper program is archived; the receipts stay.
paper/is retired frommain:
the runnable instruments and every frozen result (nulls and the discarded pilot included)
now live in bench/ as the product's benchmark suite (bench/README.md,
node bench/run-all.mjs), and the site's Paper page is gone — the evidence ledger on the
Research page (now including the 2026 blind replay A/B null) plus the CHANGELOG's Research
notes are the public record. The manuscript, pre-registration (H1–H18), and figure
apparatus remain in git history, last present at tag v0.8.0. README and ROADMAP
rewritten to point at receipts instead of the manuscript.
[0.8.0] - 2026-07-19
Added
- Java and C# call wiring (tree-sitter dispatch tier). The regex engine still finds every
symbol; for Java/C# files an optional AST pass now adds the call edges regex could never
claim safely: this.helper() calls inside a class, and receiver.method() calls where the
receiver's declared type names exactly one class in the repo (two classes with the same name
→ the edge is dropped and counted in the banner, never guessed). Nodes are untouched —
identical between engines — so determinism holds. Grammars vendored from
@vscode/tree-sitter-wasm@0.3.1 (scripts/grammars/PROVENANCE.md records the ABI trap that
rules out the older grammar package). Spec: docs/specs/java-cs-tree-sitter.md.
- The ledger now counts whether advice was FOLLOWED, not just delivered. When a pre-edit
card names caller files and a later edit in the same session touches one of them (30-minute
window, once per file, the changed symbol's own file excluded), the ledger bumps
cardCallersFollowed and the session brief reports "N card-named caller(s) followed" —
the difference between "we showed advice" and "the advice changed what happened."
Spec: docs/specs/card-correlation.md.
Research
- The replay miner is tested and honest about its funnel.
paper/experiments/replay-mine.mjs
(mines real commits that changed a function's definition and — per the repo's own later
fixes — missed caller files) gained a TDD suite on synthetic git histories
(tests/replay-mine.test.mjs, P1–P5) and a stage-by-stage funnel report. The tests + a
hand-audit of the first frozen corpus exposed and fixed three miner bugs: a prefilter that
silently zeroed every Java/C# candidate, a raw def-line comparison that read a prettier
reformat as a "signature change" (which had produced 2 invalid tasks), and instructions
truncated mid-hunk at 4KB (now: complete-or-rejected). Spec: docs/specs/replay-corpus.md.
- The replay A/B protocol went blind before spending. The v1 pilot leaked its own answer
key three ways (the grading list pasted into solver prompts, self-reported coverage, and
full-history isolation that let solvers read the historical fix commit). It is preserved —
discarded — in paper/results/replay-ab-pilot.json; the v2 harness solves in a
history-free export of the base revision and the workflow itself computes coverage as
filesChanged ∩ missedByChange. Spec amendments: docs/specs/replay-run.md.
- Replay A/B result: both arms at ceiling (honest null). The v2 corpus froze at ONE
fully-verified task — axios buildFullPath gaining allowAbsoluteUrls, where the real 2025
change missed 2 of its 3 caller files and axios needed two follow-up PRs (#6810, #6814).
Blind-replayed 4× per arm: all 8 cells covered both missed files with 0 structural
regressions — with or without codeweb. On a 3-caller single-package change, a capable
agent's grep saturates; the historical miss does not reproduce, so ambient context is
bounded near zero on this task shape. The run proves the instrument (leak-free,
fixed-function grading, ambient engagement verified in every treatment cell — the card's
caller list matched ground truth exactly) and shows guarded mining makes true breakage
tasks RARE: 2 of 3 v1 tasks and the only new candidate died under scrutiny. Discriminating
between the arms needs many-caller cross-package tasks or cost-to-coverage metrics —
recorded as the corpus growth path. Full data: paper/results/replay-ab{,-raw}.json.
[0.7.1] - 2026-07-19
Fixed
- README screenshots crop to content. The regenerated shots were uniform full-page
frames — squeezed into README boxes, the matrix rendered ~170px wide and the blast
shot's inspector was unreadable. scripts/screenshot.mjs now crops each frame to what
it shows: the graph to its drawn bounding box, the blast shot to the LIT selection
(tracked from the draw pass) + inspector at a tighter zoom, the matrix to its table +
legend. README display sizes and the stale corpus line ("274 symbols across 8
domains" → the real 334/11) synced; shots remain one-command regenerable.
[0.7.0] - 2026-07-19
Changed (the report finally looks like the product it is)
- A validated color system replaces generated hues. Area colors were
hsl(i × 137.5°)—
unbounded spun hues; the treemap ramped green→red at full saturation (measured deutan
ΔE 2.2 — invisible to red-green-deficient viewers). Now: a fixed-order 8-slot categorical
palette validated against the actual dark surface (worst adjacent ΔE 8.4 protan / 19.3
normal, all ≥3:1; 9th+ areas fold to neutral), a single-hue slate→red lightness ramp for
duplication density (with an on-canvas legend), reserved status colors for finding
severity, and the brand lime demoted to what it should be: UI accent (selection, focus),
never a data series.
- The graph is drawn like a product. Focus + context replaces expand-everything: every
area starts as a bubble and clicking one expands ITS symbols in place — the all-at-once
hairball is impossible unless explicitly asked for. Curved weight-scaled edges; node fills
with darker same-hue rings; halo labels in the real UI font (the canvas font stack fell
back to a serif before); the tangle color only appears where it means something; search
reveals a hidden symbol's area instead of saying "no matches"; positions persist across
expansions; layout is seeded — the same repo always draws the same map.
- Treemap/matrix polish: cell gaps + rounded corners, styled area headers with color
chips, readable in-cell numbers (they were dark-on-dark), chip legends instead of prose.
- First paint earns its pixels: the inspector opens with the repo overview (largest
areas, findings counts, how-to-read) instead of "Pick an item"; engine jargon moved out
of the masthead into a tooltip; a logomark anchors the header.
- Deterministic demo shots (
scripts/screenshot.mjs): staged states (top hotspot
selected, its area expanded, inspector populated) at 2× retina with fixed framing — every
committed screenshot (assets/screens/) regenerates from the real report in one command.
The live demo (docs/demo/) rebuilt on the new template.
[0.6.0] - 2026-07-19
Added
- The local outcome ledger (
scripts/stats.mjs,npm run stats): codeweb now counts what
it actually does during real work — session briefs injected, pre-edit cards delivered,
post-edit checks run, regressions flagged before landing, queries served, auto-refreshes —
written by the hooks and the MCP server beside the graph (stats.json). Strictly local
(never transmitted; counter names + integers only; CODEWEB_NO_STATS=1 disables), fail-open
by construction. The brief carries a one-line receipt ("codeweb this month: …") — the value
made visible where it accrued.
Research
- Evidence program moves into the product (
paper/STATUS.md): the manuscript is frozen as
a reproducible artifact; claims live in the site's evidence ledger + these Research notes.
- Replay benchmark (
paper/experiments/replay-mine.mjs+replay-ab.workflow.js): mine
git history for commits that changed a depended-on signature and provably missed caller
files a later commit had to fix — each hit is a task with a built-in answer key (no invented
tasks, no floor effect). First verified mining run on axios (1,647 commits, funnel reported
at every stage): 2 ground-truth tasks, e.g. forEach — 9 caller files, 8 missed, fixed in a
follow-up. The replay workflow runs control vs ambient codeweb over the mined set, graded on
historical-miss coverage + the deterministic gate.
[0.5.0] - 2026-07-19
Added (fewer mistakes per token)
- Day-one briefing (
codeweb_brief, 24th MCP tool +scripts/brief.mjs+ a SessionStart
hook): one ~2KB page — areas with summaries, the most depended-on symbols, entry points
(heuristic), test layout, known issues — injected automatically when a session starts in a
mapped repo. Replaces the first 20-50k tokens of exploratory orientation with pre-computed
answers; served in-process from the cached graph.
- Caller-reliance contracts (
lib/reliance.mjs): the explain card (and therefore the
pre-edit hook, which embeds its summary) now reads the actual call sites and says what
callers depend on — destructured/member-accessed result fields ("callers use {timeout,
retries} — keep those"), awaited fraction, and the argument-count range in use. Targets the
most common breaking edit: changing a return shape a caller still destructures.
Conservative: only call-site-line patterns count; no sites → no claim.
- Confidence calibration (extractor v10): symbols reachable from a package entrypoint
(package.json main/module/browser/bin/exports, followed through named + star re-export
chains) are stamped pub — "0 in-repo callers" on a public symbol now answers with
"⚠ public API — external callers likely; renames are breaking" instead of false confidence.
Files using dynamic dispatch (computed member calls, getattr, non-literal require, emitters)
are recorded in meta.dynamic, and empty callers/dependents answers cite them ("absence of
callers is weaker evidence"). Confident answers stay caveat-free — no noise.
Research
- H18-v2 prepped (
paper/experiments/agent-ab2-ambient.workflow.js+agent-ab2.README.md):
the agent A/B rerun as one funded command — v1's null was a floor effect (both arms ~0
regressions on easy tasks), so v2 pre-registers hard tasks (graph-verified fan-in ≥ 5 /
shape changes) and an AMBIENT treatment arm mirroring what the hooks now inject (brief +
explain cards with reliance/caveats — context delivered, not offered). The analyzer takes
raw/out paths so v1 results stay frozen.
[0.4.0] - 2026-07-19
Added
- Java + C# on the deterministic fast path (extractor v8): class/interface/enum/
record/struct discovery with visibility-as-export, owner-qualified method ids
(constructors included), Allman-brace + expression-bodied members (C#), extends /
base-list inheritance edges, control-flow phantom guards, Maven/Gradle test-layout
and *Test.java / *Tests.cs role detection, and pom.xml / build.gradle / .csproj
package boundaries. Verified on square/javapoet (497 symbols, 0 phantoms, 0.7s) and
restsharp/RestSharp (1,542 symbols, Allman style, 0.9s). Method-dispatch recall
(obj.Method()) stays precision-gated as in the JS regex tier — a tree-sitter tier
for Java/C# is the next increment.
Added (recall ceiling + ambient loop)
- Tree-sitter tier default-on when web-tree-sitter is installed (
--engine regex
opts out; absent dependency degrades to regex byte-identically). Class-field arrow
methods survive the AST handoff.
- Barrels are dependents (the measured recall gap):
export { X } fromedges the
barrel's <module> to the resolved symbol; export * from chains resolve
transitively AND edge the barrel to the target module. Oracle A/B moved from
recall 0.94 to 1.00 (precision 0.94 vs grep 0.87) on both the fixed and a
fresh-seeded 30-task sample.
- Ambient loop: the pre-edit hook now injects the ~1KB explain card (identity, top
callers, tests) instead of a pointer — blast radius arrives with zero agent
discipline; the MCP server auto-refreshes a stale graph inline (~1s incremental,
throttled, CODEWEB_NO_AUTOREFRESH=1 opts out) before structural queries, and
per-DIRECTORY mtime stamps make brand-new files trip the staleness check (per-file
stamps cannot see a file that didn't exist).
Added (the proof, the surfaces, the last query family)
codeweb bench(scripts/bench.mjs,npm run bench): the oracle A/B packaged as a
one-command benchmark on YOUR repo — context cost per dependents task + blast-radius cost
always; recall/precision graded by the TypeScript LanguageService when typescript is
resolvable; ripgrep optional. The engine moved verbatim into scripts/lib/bench-core.mjs
(the paper experiment is now a thin wrapper over it — reproduction against the committed
canonical run is byte-identical), so published numbers and user-generated numbers can
never measure different things.
- The gate now posts its review:
ci-gate --mdrenders the structural delta (blocking
regressions, new cycles/duplications, symbols that lost all callers, renames-not-churn)
as a budgeted digest and the codeweb gate workflow posts/updates it as a sticky PR
comment on pass AND fail — visible where reviewers already look, verdict unchanged.
- Editor CodeLens (
editor/vscode-codeweb): zero-dependency VS Code extension showing
N callers · blast M above every mapped symbol from the nearest .codeweb/graph.json
(identical semantics to codeweb_callers/codeweb_impact), click-through to
report.html#s=<id>.
codeweb_find— concept search (23rd MCP tool +scripts/find.mjs): free text
("where is retry handled?") → ranked symbols, deterministically — camelCase/snake_case
token match with light stemming over identifiers/files/domains, weighted by exports,
role (tests only when asked for), and fan-in. Served in-process from the cached graph,
budgeted, staleness-annotated. Closes the last gap: every other query tool needs a name;
this one turns an idea into the right starting symbol.
Research
- Oracle A/B (
paper/experiments/oracle-ab.mjs, results in
paper/results/oracle-ab.json): dependents-discovery graded by the TypeScript
compiler's own reference finder over 30 seeded vite symbols — codeweb recall 0.94 /
precision 0.89 at 1/3 of an idealized grep's context cost (0.8KB vs 2.5KB per
task); blast-radius ("what transitively breaks") in one ~1KB call vs a recursive
grep loop's ~130KB (126× on the canonical run, simulated generously for grep). Mechanical and
reproducible — complements (does not replace) the frozen frontier-agent pilot,
whose run stays the evidence for agent-loop behavior (+0.27 recall, ~44% fewer
tokens). The 6/30 under-recalled symbols are the known dispatch/re-export gap.
[0.3.0] - 2026-07-18
The agent-efficiency release: outputs that can't corrupt, answers that fit a context
window, and a map that tells you when it's stale. Driven by the measured product review
(PRODUCT-REVIEW.md).
Fixed
- Flush-safe output everywhere:
process.exit()after a large stdout write silently
truncated anything past the 64KB pipe buffer (piped CLI JSON cut at exactly 65,536
bytes; MCP responses clipped mid-string). All CLIs now end naturally via
lib/cli.mjs emitters; | head (EPIPE) exits cleanly.
run.mjsresolves a relative<SRC>against the caller's cwd (it resolved against the
plugin root while --out-dir used the cwd) and reports stage failures in one clean
line instead of a raw execFileSync stack. --open is parsed and forwarded (it was
documented but inert).
- Campaign delete steps ranked ROI 0:
deadcodenow emitslocper item and the
planner reads it (locSaved stays as a legacy alias).
- Same-file same-name methods collided into one node id: v6/v7 extraction emits
owner-qualified ids (file:Type.method) in every tier (Python classes, Rust impls,
Go receivers, JS/TS classes), with member-access resolution following.
- Body spans no longer desync on multi-line template literals / block comments (bodies
swallowed whole neighboring functions — a 5-line helper recorded as 550 loc on vite,
poisoning complexity, context-pack, and body-confirmation). Extents are measured on
masked lines.
mcp-serveradvertised version 0.1.0 on a 0.2.0 product;serverInfo.versionnow
derives from package.json and check-consistency audits it.
- Signal-B twins de-duplicate by label pair (several
<module>nodes produced N
byte-identical findings).
Added
- Budgeted MCP responses: list-heavy tools default to a one-line
summary+ top-N
most-relevant items + TRUE totals + explicit more.remaining; full: true or
limit/offset override. codeweb_context returns call-site windows (±3 lines)
instead of whole caller bodies (~300KB → ~10KB on a busy vite symbol).
codeweb_map: build/rebuild the graph over MCP;graphbecomes
optional on every tool (nearest .codeweb/graph.json above cwd, or CODEWEB_WS),
and a missing graph returns an actionable error naming the fix.
codeweb_explain: one ~1KB card (identity, contract, dependents, blast radius,
findings) answering "tell me about X" — previously 3-4 calls. 22 tools total.
- Plugin auto-registration:
.claude-plugin/plugin.jsonnow carriesmcpServers,
so /plugin install codeweb delivers the tools without a manual claude mcp add.
- Code roles: every node carries
role(product|test|fixture|example|bench|
generated). Overlap findings, deadcode tiers, and the report's default view scope to
product code (CODEWEB_ALL_ROLES=1 / an in-report toggle widen it).
- Interface-pattern detection: ≥4 same-named implementations that nothing in-repo
calls demote from "merge these" to an informational interface-pattern finding
(framework hooks like a bundler plugin's resolveId()).
- Workspace scoping: bare-name call resolution never crosses a package (manifest)
boundary — cross-package name collisions no longer fabricate edges in monorepos.
- Staleness awareness: the extractor stamps per-file size+mtime into
meta.sources; query/context results annotate when the graph no longer matches disk
and point at codeweb_refresh.
- Pre-edit hook: one advisory line of blast radius before an edit lands in a mapped
target (PreToolUse; fail-open). The post-edit hook re-extracts incrementally
(--cache) instead of full-scanning per edit.
- Bare-identifier arguments become
refedges (a callback passed is a reference, not an
invocation); class-field arrow methods (handleClick = () => {}) are discovered and
owner-qualified.
- Report: product-only filter, interface-pattern section, search that navigates
(Enter cycles + selects matches), #s=<id> deep links, keyboard/ARIA support, and
prefers-reduced-motion (layout settles without animation).
lib/cli.mjs: the shared CLI harness (die/emit/loadGraph/capList/staleness) —
deleting the die()×16 / graph-load×13 duplication codeweb's own overlap report flagged.
- In-process query serving: the MCP server answers structural queries from a
cached parsed graph (4–6ms vs 75–122ms spawn+parse), via the same
lib/query-core.mjs the CLI ships — one truth, two transports.
- Rename-aware diff: a removed+added pair with an identifier-normalized
(Type-2) body match ≥85% reports as renamed[] instead of delete+add churn.
- Domain summaries are genuinely descriptive: size, file count, exported-first
key symbols, and role composition ("mostly test code").
Changed
campaignbatches its delete simulation (one clone instead of one per step):
8.9s → 1.25s on a 3k-symbol monorepo.
- MCP
initializereturns workflowinstructionsand negotiates a SUPPORTED protocol
version instead of echoing arbitrary client strings; tool calls get a 120s timeout.
codeweb_find_similaracceptsbody(stdin plumbing) +structuralover MCP;
codeweb_review accepts before + gate; codeweb_reading_order accepts
scope/value/budget — previously CLI-only capability, now agent-reachable.
[0.2.0] - 2026-06-23
The first managed release: codeweb gets a real public front and the machinery to keep
product, marketing, and research in lock-step.
Added
- Public website built from a zero-dependency Node builder (
site/build.mjs) into
docs/ — Home, Product, Research, Get Started, and Changelog pages, all self-contained
with zero third-party requests, served by GitHub Pages.
- Design system (
site/tokens.css+site/styles.css): the engine's palette codified
into reusable tokens and components, plus a polished animated hero.
- Evidence ledger on the Research page — every claim tagged
Validated,
Preliminary, or Null with its exact number, sample size, and source file.
- Single source of truth (
site/data/product.json) for the tagline, the 20 MCP tools,
the Tier 0–3 map, supported languages, and the claim ledger.
- Release ecosystem: this
CHANGELOG.md, plusscripts/version-sync.mjs,
scripts/check-consistency.mjs, and scripts/release.mjs.
- Shared navigation injected into the existing interactive demo and research paper, so
they are no longer orphaned pages.
Changed
- Corrected the Claude Code plugin manifest, which advertised 15 query tools; the real
surface is 20. The tool count is now derived from the MCP server and verified in CI.
- README updated for the new site, version, and a
Versioning & Releasessection.
Fixed
- Per-page Open Graph metadata and favicon (the Pages root previously 404'd — there was no
landing page at all).
Research
- Surfaced the efficiency pilot: frontier-agent caller discovery improves by
+0.265 ± 0.045 recall and ~34% fewer steps vs grep over 8 engine-frozen reps
(paper/experiments/efficiency-pilot.reps8.json). Labelled preliminary.
[0.1.0] - 2026-06-22
The deterministic engine and its evidence base.
Added
- Core pipeline — extract → cluster → overlap → render: a parse-free, zero-dependency
code-structure graph with a self-contained interactive report.html.
- Ten features across four tiers (F1–F10): scoped context, live refresh, the
duplication-delta gate, hotspots, campaign planning, structural clone detection,
suppression memory, reading order, incremental edge derivation, and sharded subgraphs.
- 20 deterministic MCP tools spanning structural queries, write-time, review-time,
optimize-time, freshness, and comparison.
- Five-language fast path: JavaScript, TypeScript, Python, Rust, and Go.
- CI gate — a GitHub Action that fails a PR on a new cycle, a new duplication, or a
symbol that loses all callers.
Research
- Pre-registered effectiveness study — 32 of 33 checks pass; 0 disagreements across
~490k oracle comparisons; the study found and fixed two real engine bugs the 286-test
suite had missed (paper/).