Commit Graph
150 Commits
Author SHA1 Message Date
Colby McHenry a72f22a6d3 feat(ui): scaffold the codegraph ui viewer as a Svelte 5 + Vite workspace (CG-40)
Adds `ui/` as an npm workspace (Svelte 5.56 + Vite 7, devDependencies only —
the engine's runtime dependencies are untouched) and chains its build into
`npm run build`, so the browser viewer ships inside `dist/` with everything
else: `build-bundle.sh` already copies `dist` wholesale and `pack-npm.sh`
packs that bundle.

Output is `dist/viewer/`, NOT `dist/ui/`: `src/ui/` is the engine's terminal
ui (shimmer progress + its worker) and tsc compiles it to `dist/ui/`, so
emitting there both deletes those modules — the CLI then dies at startup with
`Cannot find module '../ui/shimmer-progress'` — and would leave the static
server handing out compiled engine internals. The design spec is corrected to
match.

`scripts/check-ui-build.mjs` is the release guard: index.html must exist, be
non-trivial, and every local asset it references must be on disk, and the
compiled engine next door must still be intact. It runs after every UI build,
again in `build-bundle.sh` once the bundle stage has copied `dist`, and again
in `pack-npm.sh` once each archive is unpacked — so a broken viewer fails the
release instead of shipping a CLI that serves a 404.

`vite build` does not override an ambient NODE_ENV, so a shell or runner with
NODE_ENV=development silently shipped dev-mode Svelte (~13 kB of dev-only
runtime checks, warning in the user's console). The config now pins production
for `command === 'build'`; macOS and Windows ARM64 then emit byte-identical
bundle hashes.

The shell itself follows docs/design/codegraph-ui-design-spec.md §2–§3.1:
design tokens as CSS custom properties (light on bare `:root`, dark under both
`prefers-color-scheme` and `[data-theme="dark"]`), square corners, hairline
rules, one oxblood accent; top bar 48px / trail bar 34px / main; a hash router
over `#/s/<id>`, `#/file/<path>`, with `#/map` and `#/flow` reserved for phase
2. Fonts are vendored through @fontsource rather than fetched, so a local
reader works offline and never announces the project to a CDN.

Verified: clean `npm run build` from an empty dist on macOS and on the Windows
ARM64 VM (forward-slash asset URLs, CLI still starts, both assertion failure
modes exit 1); `dist/viewer` present in a real darwin-arm64 bundle and in the
packed npm platform package; shell geometry, tokens, all seven routes, both
themes and font loading checked in headless Chromium with no console errors;
`npm test` unaffected.
2026-08-26 15:55:10 -05:00
Colby MchenryandGitHub 838006c947 fix(kernel): guard the native walkers against stack overflow and defer deep files to wasm (#1581) (#1600)
Fixes #1581.

## What was wrong

`codegraph init` / `codegraph index` died with `Segmentation fault` — the whole CLI
process, not a parse worker — on a C/C++ file with very deep brace nesting (llvm's
`clang/test/Parser/parser_overflow.c`, 16,384 nested `{`). The reporter's diagnosis is
exactly right: tree-sitter's parser is iterative, so the file parses fine, and then the
native kernel's **recursive walker** (`visit_node` → `visit_for_calls_and_structure` → …,
one frame per AST level) overflowed the thread's stack. A native overflow can't be caught
the way a wasm abort can, and a parse worker is a thread of the `codegraph` process, so
the SIGSEGV took the entire indexer down — no message, no per-file fallback, no partial
index.

Two things made "just give the worker a bigger stack" the wrong fix:

- it only moves the cliff — reproduced here: the reporter's 16,384-deep file kills a
  default 4 MiB worker (rc=132 on macOS / 139 on Linux), and a 100k-deep file kills the
  8 MiB **main** thread too;
- the walkers are shared by every kernel-routed language (20 of them), and each has
  several recursion points with different frame sizes, so no single stack size is a
  provable bound.

Meanwhile the wasm path already handles this shape gracefully: its JS walker catches its
own `RangeError` per file and stores a partial result with a `parse_error`. The kernel
just needed a way to get there instead of dying.

## What this does

**The kernel guards its own recursion against the calling thread's real stack bounds and
defers a too-deep file to wasm** — the same `defer:` routing signal it already uses for
files with parse errors, which `src/extraction/kernel/index.ts` treats as "take the wasm
path for this file", silently.

- `codegraph-kernel/src/stack.rs`: per-thread stack bounds from the OS, computed once per
  thread and cached — glibc/musl `pthread_getattr_np` + `pthread_attr_getstack`, macOS
  `pthread_get_stackaddr_np` + `pthread_get_stacksize_np`, Win32
  `GetCurrentThreadStackLimits` (a hand-declared `kernel32` extern; no `windows-sys`).
  `exhausted()` is one thread-local load and one compare: true once the stack pointer is
  within a 256 KiB red zone of the limit, and it latches a flag. Where the OS can't report
  bounds it falls back to a fixed 1 MiB descent budget measured from the entry stack
  pointer — safe on anything from Node's 4 MiB worker default up. So the guard is exact on
  the 4 MiB worker, the 8 MiB main thread, and any `resourceLimits.stackSizeMb` alike.
- `stack_guard!()` (defined in `lib.rs`) is the first statement of every recursive walker
  function — all **150** self-recursive or on-cycle functions across the 15 walker modules,
  found by script (every cycle in the call graph, not just direct self-calls). It returns
  `Default::default()` (`()`, `false`, `None`, `""`) so an exhausted walk simply stops
  descending; a hook returning `false` sends its caller down the generic child walk, whose
  own guard returns at once.
- `extract_file` runs the whole walk under `stack::run_guarded`: if the flag is set
  afterwards the (truncated) result is discarded and replaced by
  `defer: nesting too deep for the native walker — wasm recovery handles it`.
- `parse-pool.ts`: a comment at `new Worker(scriptPath)` records why there is deliberately
  no `resourceLimits.stackSizeMb` bump.
- No new crates beyond `libc` as a direct unix dependency (already in `Cargo.lock`
  transitively). No wire/ABI change.

Net effect for the reporter's repo: `deep.c` goes to the wasm path, lands as
`function foo` plus a recorded parse warning, and the other 31,607 files index normally.
`CODEGRAPH_KERNEL=0` and the `exclude` workaround are no longer needed.

## Tests

**Rust unit tests** (`cargo test`, 21 passed — 7 new in `stack.rs`): the walkers for
C, C++, Rust, TypeScript and Python are driven on a **1 MiB** thread (a quarter of Node's
worker default) with 30k-deep nesting and must return `defer:` instead of crashing;
shallow files are untouched; the latch resets between runs; the OS bounds are sane on the
main thread and describe a small thread's own stack.

**`__tests__/kernel-deep-nesting.test.ts`** (new, 8 tests — skips without a staged `.node`,
fails under `CODEGRAPH_KERNEL_EXPECT=1` if the kernel is missing, like the other kernel
suites):
- every default-routed language (all 20) survives a 60k-deep expression on the main thread
  — clean result or the wasm fallback's partial result, never a crash;
- the reporter's exact 16,384-brace C file is indexed (partial) on the main thread;
- 200-deep expressions in every language still take the kernel path clean (the guard never
  trips on normal code);
- inside a **default-sized 4 MiB `worker_threads` Worker** through `dist/`: the reporter's
  `deep.c` and a 60k-deep expression in every language come back `deferred` with exit 0,
  and a normal file still extracts natively;
- end-to-end through the built CLI: `codegraph init` on a repo holding `deep.c` + `ok.c`
  exits 0 and records both files, with both functions.

**Existing kernel suites**: all 15 (`kernel-*-parity`, `kernel-scaffold`,
`kernel-retry-materialize`, `kernel-grammar-parity`) pass unchanged, 147 tests — the guard
never fires on the parity fixtures.

**Reporter's probes** (`one.js` from the issue, default 4 MiB worker, this build):
`deep.c` → `deferred`, exitCode=0 (was rc=132/139); `deep100k.c` → `deferred`, exitCode=0.
Main thread: `deep.c` / `deep100k.c` → wasm partial with
`Parse error: Maximum call stack size exceeded`; a 6,000-term binary expression and a
3,000-branch `else if` chain stay on the kernel path with clean results.

**Perf** (same `dist/`, only the `.node` swapped via `CODEGRAPH_KERNEL_PATH`; interleaved
main/new ×3, `codegraph init`, macOS arm64):

| repo | main (median) | guarded (median) | nodes / edges |
|---|---|---|---|
| express (141 files) | 0.60 s (0.58–0.65) | 0.61 s (0.58–0.61) | 1,084 / identical |
| redis (786 C/H files) | 4.44 s (4.39–4.66) | 4.49 s (4.41–4.70) | 19,942 / 76,446 identical |

Within run-to-run noise, as expected for one TLS load + compare per recursion entry.

**Linux (Docker, `node:22-bookworm`, kernel built in-container, `docker run --rm --init`)** —
the reporter's platform and the glibc `pthread_getattr_np` bounds path:

```
=== platform ===
Linux efe3cc86947b 6.12.54-linuxkit #1 SMP Tue Nov  4 21:21:47 UTC 2025 aarch64 GNU/Linux
v22.22.3
-rwxr-xr-x 1 root root 35332288 Aug 22 18:02 codegraph-kernel/prebuilds/linux-arm64/codegraph-kernel.node

=== reporter repro (issue #1581): 16,384-brace deep.c, codegraph init ===
│
└  Done

init exit code: 0
  file: deep.c
  file: deep100k.c
  file: ok.c
  function: add
  function: bar
  function: foo

=== worker probe: kernel raw extract in a default 4 MiB worker ===
deep.c: deferred
deep.c: worker exitCode=0
deep100k.c: deferred
deep100k.c: worker exitCode=0
ok.c: kernel nodes=2
ok.c: worker exitCode=0

=== cargo test stack:: (glibc pthread_getattr_np bounds path) ===
test stack::tests::os_bounds_are_sane_on_this_platform ... ok
test stack::tests::small_stack_reports_its_own_bounds ... ok
test stack::tests::normal_files_are_untouched_by_the_guard ... ok
test stack::tests::deep_braces_c_defer_instead_of_crashing ... ok
test stack::tests::latch_resets_between_runs ... ok
test stack::tests::deep_parens_cpp_rust_ts_python_defer_instead_of_crashing ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 15 filtered out; finished in 0.23s

=== vitest: kernel-deep-nesting + kernel-scaffold ===
✓ __tests__/kernel-scaffold.test.ts (10 tests) 30ms
✓ __tests__/kernel-deep-nesting.test.ts (8 tests) 36989ms
Test Files  2 passed (2)
Tests  18 passed (18)
```

(The pre-fix crash was reproduced on macOS — rc=132 in a default worker, rc=139 on the main thread at 100k depth — not re-run inside this container; the reporter's Linux x86_64 trace is the SIGSEGV form of the same overflow.)

**Windows (Parallels ARM64 VM, MSVC 14.44, `cargo 1.97`, kernel built on the VM,
`GetCurrentThreadStackLimits` path)**:

```
head: cbf8485 fix(kernel): guard the native walkers against stack overflow and defer deep files to wasm (#1581)
=== cargo build --release (win32-arm64) ===
    Finished `release` profile [optimized] target(s) in 2m 04s
staged: 35086848 bytes
=== cargo test (stack guard unit tests) ===
test stack::tests::normal_files_are_untouched_by_the_guard ... ok
test stack::tests::os_bounds_are_sane_on_this_platform ... ok
test stack::tests::small_stack_reports_its_own_bounds ... ok
test stack::tests::deep_braces_c_defer_instead_of_crashing ... ok
test stack::tests::latch_resets_between_runs ... ok
test stack::tests::deep_parens_cpp_rust_ts_python_defer_instead_of_crashing ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 15 filtered out; finished in 0.49s
=== reporter repro: codegraph init on a 16,384-brace deep.c ===
└  Done
init exit code: 0
=== vitest: deep-nesting + scaffold (CODEGRAPH_KERNEL_EXPECT=1) ===
✓ __tests__/kernel-scaffold.test.ts (10 tests) 55ms
✓ __tests__/kernel-deep-nesting.test.ts (8 tests) 67239ms
   ✓ every default-routed language survives a 60k-deep expression on the main thread 52801ms
   ✓ inside a default-sized (4 MiB) parse worker, through dist/ > defers a 60k-deep expression in every default-routed language 13050ms
   ✓ end-to-end: codegraph init on a repo holding the deep file > exits 0 and records deep.c alongside the normal files 936ms
Test Files  2 passed (2)
Tests  18 passed (18)
```

(The end-to-end test is what reads the Windows index back through `node:sqlite` — `files` = `deep.c`, `ok.c`; functions `add`, `foo`.)

Full `npm test` on this branch (macOS arm64, kernel staged): **190 files passed, 3,185 tests passed, 10 skipped, 0 failed.**

Clippy note: `cargo clippy` on the current toolchain (1.92) reports 18 pre-existing lints
(`manual_contains`, `unnecessary_to_owned`, …) in walker code this PR only touched by
inserting guard lines; none are in `stack.rs`/`lib.rs`. Left alone to keep the diff
reviewable.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
2026-08-26 10:38:29 -05:00
Colby MchenryandGitHub 7963672689 fix(rust): resolve self.field.method() on the field's declared type instead of a same-named method (#1585) (#1599)
Fixes #1585. **Stacked on #1596** (the base branch is `fix/1588-rust-impl-type-qualification`; this PR's own diff is the second commit). Merge #1596 first, then retarget/merge this one.

## What was wrong

```rust
impl Outer {
    pub fn run(&mut self) {
        self.inner.run();      // inner: Inner
    }
}
```

produced `Outer::run -> Outer::run` — recursion the source doesn't contain. The extractor collapsed every `self.<field>.<method>()` receiver to the bare method name (`run`), so the resolver only ever saw `run` and exact-matched the nearest same-named method — the calling method itself, or a method of an unrelated type. Nothing marked the edge as a guess, and no row stayed in `unresolved_refs`, so a consumer had no way to tell.

The same happened when the field's type isn't a project type at all (`its: std::vec::IntoIter<_>` → `self.its.next()`, `matcher: Regex` → `self.matcher.is_match()`): the bare `next` / `is_match` attached to whatever local method shared the name. ripgrep had 279 self-edges on `main`; the issue lists three sites, all of this shape.

(The issue's C++ control — "`Outer::run -> Inner::run` resolves correctly" — doesn't actually hold on `main`: `inner.h` is classified as C by the `.h` heuristic, so `Inner::run` never exists and the C++ repro self-edges too. That's #1592, fixed separately.)

## What this does

Rust struct fields are not graph nodes, so the field's type can only come from the struct's declaration text. This follows the Go 2-hop precedent exactly (`matchGoFieldChainCall`, #1276), including its exclusivity rule:

1. **Extraction (TS walker + native kernel, identical, parity-tested):** a call whose receiver is `self.<field>` keeps the owner-field shape — `self.inner.run()` is emitted as `self.inner.run`. Deeper chains (`self.a.b.m()`), call receivers (`self.f().m()`), parenthesized receivers and bare `self` keep the bare name, exactly as before.
2. **Resolution (`matchRustSelfFieldCall`):** owner type = the calling method's qualified-name prefix (`Outer::run` → `Outer`); the field's declared type is read from the owner struct's **own declaration lines** (comment-stripped, line by line — same discipline as the Go helper); the method is resolved **and validated** on that type by `resolveMethodOnType` (confidence 0.85, `instance-method`).
3. **Exclusive:** when the field is declared with an external type, a generic parameter (`T`), a container that doesn't auto-deref (`Option`/`Vec`/`Mutex`/…), or can't be found, the ref **stays unresolved** — it never falls through to the bare-name strategies. That is the safe behaviour the issue asks for, and it is what #1276 already chose for Go.

`rustFieldTypeName` looks through exactly the layers Rust's method-call auto-deref looks through: references (`&`, `&'a mut`) and the owning smart pointers `Box`/`Rc`/`Arc`. `Box<dyn Source>` yields the trait, whose method node the interface-impl synthesizer then fans out to every implementation. `Option<Inner>` is left alone — `self.inner.take()` is Option's method and must not become `Inner::take`.

Why it stacks on #1596: the owner is taken from the method's qualified name, which for a generic/lifetime impl was the trait's name before that fix.

## Measured on ripgrep (110 `.rs` files, #1596 build vs this branch)

| | #1596 | this PR |
|---|---|---|
| nodes | 4029 | 4029 |
| `calls` self-edges | 279 | **146** (none of the `self.<field>` shape remain — 116 bare-receiver, 30 other dotted) |
| `self.<field>.m()` calls resolved through a validated field type | — | **292** (`DecompressionMatcher::command -> GlobSet::matches`, `Parser::find_long -> FlagMap::find`, `Haystack::path -> DirEntry::path`, …) |
| `self.<field>.m()` calls left unresolved | — | **417** — every sampled one is a std/container method: `self.commands.push`, `self.child.wait`, `self.pre.is_some`, `self.colors.clone`, `self.path_terminator.unwrap_or` |
| `calls` edges total | 9150 | 8878 (the 272 removed are the former bare-name guesses for those 417) |

The issue's three sites: `walk.rs:824` now resolves to `IgnoreBuilder::add_custom_ignore_filename` (was a self-edge); `walk.rs:1195` (`self.its.next`, `IntoIter`) and `globset/lib.rs:983` (`self.matcher.is_match`, `Regex`) are parked as unresolved instead of guessed.

The issue's repro gives `Outer::run -> Inner::run` (`instance-method`, confidence 0.85) on both the kernel path and `CODEGRAPH_KERNEL=0`.

## Tests

- `__tests__/extraction.test.ts`: only the single-hop `self.<field>.<method>()` call keeps the prefix; deeper / call / parenthesized / bare-`self` receivers and a local receiver are unchanged.
- `__tests__/resolution.test.ts` (end-to-end, Cargo layout): the issue's repro → `Outer::run -> Inner::run`, no self-edge; an external field type (`std::vec::IntoIter`) with a local `next` decoy → no edge at all; `Box<Inner>` and `&'a mut Inner` resolve, `Option<Inner>` does not (even though `Inner` declares the method); a generic `T` field → no edge; genuine `self.run()` recursion keeps its self-edge; the #1588 repro's `UsesFile::go` / `UsesBuf::go` resolve to `FileSource::read` / `BufSource::read`, and a `Box<dyn Source>` field lands on `Source::read` with the synthesizer fanning out to both impls.
- `__tests__/fixtures/kernel-parity/torture.rs` grows the receiver shapes; all 15 kernel parity suites pass against the rebuilt kernel (147 tests).
- Full `npm test` on this branch: 189 files, 3187 passed, 9 skipped, 0 failed.

Re-index after upgrading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
2026-08-26 10:37:50 -05:00
Colby MchenryandGitHub 12f7a59f26 fix(rust): qualify generic/lifetime impl methods by the implementing type, not the trait (#1588) (#1596)
Fixes #1588.

## What was wrong

The receiver of an `impl` block — the name that qualifies its methods, owns the `contains` edge, and sources the `implements` edge — was found positionally: the **last bare `type_identifier` child** of the `impl_item`. That works for `impl Source for FileSource`. But once the implementing type carries parameters it parses as a `generic_type`, and the only bare identifier left is the **trait's**:

```rust
impl Source for FileSource         →  FileSource::read   ✓
impl<T> Source for BufSource<T>    →  Source::read       ✗  (should be BufSource::read)
impl<'a> Iterator for Parents<'a>  →  Iterator::next     ✗
impl Trait for &Foo                →  Trait::method      ✗
```

Two consequences, both reproduced on `main`:

- `BufSource::read` did not exist in the graph, so `resolveMethodOnType("BufSource", "read")` and "who calls `BufSource::read`" had no answer, and every generic implementation of a trait collapsed onto the same trait-qualified name.
- Because the impl's method carried the trait's qualified name, the interface-impl synthesizer treated the impl **body** as a second trait declaration and emitted a dispatch edge from it (`Source::read -> FileSource::read`, registered at the generic impl's line — a body of `{ 0 }` containing no call at all).

The native kernel (`rustlang.rs`) mirrored the positional rule deliberately, bug-for-bug, to hold byte-parity with the TS walker — its header said "preserve, never fix via the grammar's trait:/type: fields". So the fix has to land on both sides at once.

## What this does

Both extractors now read the grammar's **named fields** instead of scanning children. One shared rule (`rustImplTypeName` in `languages/rust.ts`, `impl_type_name` in the kernel), applied to `impl_item.type`:

| implementing type | node | receiver |
|---|---|---|
| `Foo` | `type_identifier` | `Foo` |
| `Foo<T>` / `Foo<'a>` | `generic_type` → its `type` field | `Foo` |
| `m::Foo` | `scoped_type_identifier` → its `name` field | `Foo` (was: no receiver) |
| `&Foo` / `&'a mut Foo` | `reference_type` → its `type` field | `Foo` |
| `(A, B)`, `dyn Tr`, `*const T`, `u32`, fn types | anything else | none — extracted as plain functions, exactly as before |

The `implements` back-reference reads `impl_item.trait` (full text, so `fmt::Display` and `From<u32>` keep their spelling) and bails when the field is absent (inherent impl). Everything else — the no-scope impl quirk, the source-order `contains` owner scan, method extraction — is untouched; the `contains` edge simply lands on the implementing type now instead of the trait.

The kernel header comment, the parity test's description, and the two design docs that documented the quirk as "preserve" are updated to say what changed.

## Measured on ripgrep (110 `.rs` files, `main` build vs this branch)

| | main | this PR |
|---|---|---|
| nodes / methods | 4029 / 2202 | 4029 / 2202 |
| impl methods qualified by a **trait** name (node outside that trait's extent) | 61 | **0** |
| `Iterator::*` methods | 2 | 0 |
| duplicate method qualified names | 77 | 42 |
| synthesized `interface-impl` edges originating **outside** any trait declaration (the phantom fan-outs) | 38 | **0** |
| synthesized `interface-impl` edges originating at a real trait declaration | 33 | **52** |
| plain (non-heuristic) `calls` edges | 9098 | 9098 |

So the synthesizer lost every phantom edge and *gained* 19 legitimate fan-outs to implementations it could not previously see as implementations. `contains` edges went 5237 → 5224: the 13 removed were trait→impl-method edges produced by the mis-qualification.

The issue's repro now gives `BufSource::read` at line 12, `BufSource -> Source`, and both synthesized edges registered at the declaration (line 2) — identical on the kernel path and with `CODEGRAPH_KERNEL=0`. (The remaining `UsesFile::go -> BufSource::read` exact-match guess there is the separate `self.field.method()` receiver problem, #1585, which stacks on this.)

## Tests

- `__tests__/extraction.test.ts` (Rust Extraction): method qualified names for generic / lifetime / reference / scoped / generic-trait impls; the trait's qualified name names exactly one node; `implements` refs come from the implementing type for every shape; the `contains` edge lands on the type; tuple / `dyn` impls keep producing plain functions with no `implements` ref.
- `__tests__/resolution.test.ts` (end-to-end): `Source::read` names only the declaration; dispatch fans out to **both** `FileSource::read` and `BufSource::read`, every synthesized edge registered at line 2; neither impl body sprouts a synthesized call.
- `__tests__/fixtures/kernel-parity/torture.rs` grows all the new impl shapes; `kernel-rustlang-parity` (LF + CRLF) passes against the rebuilt kernel.
- `CODEGRAPH_KERNEL_EXPECT=1 npx vitest run __tests__/kernel-*.test.ts` — all 15 suites, 147 tests pass.
- Full `npm test`: 3180 passed, 9 skipped, 1 failed — `mcp-daemon.test.ts > daemon idle-times-out after the last client disconnects`, a 30 s timing test that passed on re-run in isolation (the machine was running four parallel suites and kernel builds at the time); unrelated to extraction.

Re-index after upgrading to pick up the corrected names.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01LxZj6W6Y1SHXwvpT3uwJpK
2026-08-26 10:36:53 -05:00
Colby McHenry d289bf84d3 Merge main into fix/union-declarations-not-indexed
Resolves the CHANGELOG conflict — main and this branch each prepended a
bullet to [Unreleased] > Fixes; both are kept. Everything else auto-merged,
including src/mcp/tools.ts, which main reworked heavily for the explore
allocation/displacement work (CG-28/31/36/38) while this branch added the
`union` kind to its container sets.

Verified on the merged tree with the native kernel built: 3070 passed,
9 skipped, 0 failed.
2026-08-07 21:26:53 -05:00
Colby MchenryandGitHub 99f2ebf0d1 Merge pull request #1527 from colbymchenry/bugfix/CG-38
CG-38: guarantee an agent-named symbol renders, wherever it sits
2026-08-07 13:19:25 -05:00
Colby McHenry 2c708caf7c Merge branch 'main' into feature/CG-35 2026-08-06 21:17:56 -05:00
Colby McHenry 89c53ddf24 fix(explore): guarantee an agent-named symbol renders, wherever it sits (CG-38)
`codegraph_explore` never returned `queueMessage` (L1087) or
`flushQueuedMessages` (L1102) from a 1,414-line file, on a symbol bag or a
prose question, even with that file at rank #1 holding 67% of the envelope —
the agent got a same-stem `QueuedMessage` interface at L70 and had to Read the
file for the functions it had named. Pre-existing at every build including
pre-epic (controlled bisect, index held fixed).

Two independent causes:

1. `buildFlowFromNamedSymbols` returns the Flow prose AND the set of node ids
   the agent named — and the latter is the whole guarantee, since it injects a
   named def into its file's cluster ranges at importance 9. Its bail-outs
   returned EMPTY, zeroing the identity whenever there was nothing to PRINT.
   Two sibling closures that never call each other produce no chain, no synth
   hop and no boundary, so both defs lost importance 9 and the file rendered
   from its head. `identityOnly()` now separates the two, gated on
   shape-precise tokens so a prose word that exact-matches a callable cannot
   promote itself.

2. The ceiling trim filled in SOURCE order, so an over-ceiling render always
   dropped the END of a large file first. The shrink HAD kept both symbols
   (1022-1121); the trim cut back to 839. `windowToCeiling` now takes the
   spine call site plus every importance>=9 member as focus lines, tries the
   full ceiling first, and splits the held-back reserve evenly with
   carry-forward — greedy-in-source-order reproduced the bug one level down.

The shrink's loose size estimate is left alone deliberately, and the comment
now says why: making it exact was built and measured WORSE (it stops at the
last member that fits whole and the released bytes carry forward to
lower-ranked files, costing payroll-go's `s.store.Upsert`). `bound()` clamps to
the ceiling anyway, so the slack costs no bytes; it just must not pick the
survivors, which is what the trim now handles.

The measurement gap this closes: every existing probe is aggregate — envelope
share, per-file spend, source totals, file counts — and all are green on a
response that returns 25K from the right file and omits the named function.
`probe-named-symbol.mjs` checks the definition LINE against the response's
rendered lines, per symbol.

Suite envelope byte-identical to main on all six repos; probe-allocation 4/4,
no starvation flags; 180 files / 2,997 tests green. Fixture: 7/7 fail on main,
7/7 pass here, deterministic over 4 runs per arm.
2026-08-06 21:11:51 -05:00
Colby McHenryandClaude Opus 5 07338ff12e docs(benchmarks): record CG-38 as open, and correct the regression claim
The epic record said nothing was open. CG-38 is: agent-named symbols in the
tail of a large file never render, which the epic's probes cannot see because
none of them measures whether the named symbol appeared.

Also corrects a wrong claim made while investigating it. The epic was said to
have regressed its own motivating query; that comparison varied the index as
well as the engine. A controlled bisect holding the index fixed shows the
pre-epic engine rendering 12 lines and CG-36 rendering 463 — the epic strictly
improves the case, and the symbols render at neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:29:14 -05:00
Colby McHenry 10f1ac601a docs(benchmarks): record the CG-36 cluster-starvation measurement
The issue blamed the density tiebreak; both real cases lost on maxImportance,
so ranking was left alone. Full before/after table, the one cost (okhttp's
rank-6 file, squeezed out by reservations that were already structurally
over-subscribed), and what ships to keep it measurable.
2026-08-06 15:13:35 -05:00
Colby McHenryandClaude Opus 5 76ab1fe130 docs(benchmarks): record the CG-24 epic resolution
Four shipped fixes, one open defect (CG-36), and five issues closed because
measurement contradicted them. The headline is that the reported symptom was
not an explore bug at all — it was a degraded index (CG-33), and the reported
query answers correctly on a clean rebuild with no explore change.

Records the two traps that cost real time and are now guarded in tooling: the
nonexistent .codegraph/graph.db path that sqlite3 silently creates, and
ab-new-vs-baseline.sh swapping src/ mid-run so a commit captures baseline
sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:46:11 -05:00
Colby McHenryandClaude Opus 5 9efae0f8f2 fix(explore): damp ambient declaration files on flow queries (CG-28)
A file that declares nothing but types and that nothing in the index depends
on — a hand-written ambient `.d.ts` of global shims, vendored typings, module
augmentation — cannot answer a flow question: no bodies, no call edges, no
behaviour, nothing typed by it. But the identifiers it declares are exactly the
generic ones a prose question uses (`Body`, `Message`, `ImageMetadata`,
`ReadableStream`), so on term overlap it out-scored the implementation. Measured
on the new fixture: rank #1 and 51% of delivered source, with the flow's own
entry file pushed out of the response entirely.

Measured first, per the issue: the Wrangler `worker-configuration.d.ts` that
opened this is already handled by CG-25's banner detection, worth 15-46 points
of envelope share across four flow queries. CG-25 credited; only the un-bannered
case needed anything.

`rankPenalty` now multiplies score and graph mass by 0.5 for such files, taken
as the STRONGER of it and the generated penalty rather than multiplied — one
property two signals see must not be charged twice. Detection is structural, not
by extension, and four conditions deep. Two of them were forced by measurement:
requiring every symbol to be type-level takes the corpus flag rate from 1-18%
(which swept in Kotlin sealed classes, Rust mod.rs re-exports and django's
locale tables) down to 0-4%; requiring that nothing depends on the file
separates an ambient shim from a working types module, and without it the rule
demoted displacement-ts's pipeline `types.ts` and broke the CG-31 gate.

A query that NAMES a declared type is exempt, so a question about a type still
reaches its declaration at full weight. Precise tokens only, so "…the file
body…" cannot exempt a `Body` interface it never meant to name; this needs its
own set because `namedSeedIds` is callable-only and a type never becomes one.

Regression evidence in docs/benchmarks/explore-declaration-only-cg28.md:
6-repo envelope sweep byte-identical against a clean baseline build, zero
ambient files reach the candidate set on VS Code across five queries, corpus
flag rate 0-0.74%, both allocation fixtures PASS, full suite 2,978 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:35:56 -05:00
Colby McHenry 91cb5b4317 measure(explore): the factory-closure envelope premise does not hold (CG-27)
CG-27 asked whether the >50%-of-file envelope drop should cover `function` /
`method`, so a `createFoo()` factory returning an object of closures stops
merging every closure inside it into one cluster. Measured on a hermetic
fixture, it should not, and the issue is closed as obsolete with CG-30 credited.

Two mechanisms already absorb the shape. shrinkCluster orders members by
(importance desc, size ASC) and refuses any member that overruns the cap once
something is kept, so a file-spanning member is only selected when it is the
sole member of the top importance tier — eight of nine query shapes never
selected it at all. When it IS selected, CG-30 windows it on whole lines, so
the file still delivers bounded, readable source (6 of 9 closure definitions
in that configuration).

Dropping the range instead SPLITS the file, and only the first-chosen cluster
may be shrunk: a trivial 7-line cluster won the density tiebreak and the
answer-bearing cluster was dropped whole — rank-#1 file 7,539 chars and 7 of 11
closures to 397 and none. Reaching the same intent more carefully (defer the
envelope MEMBER inside shrinkCluster, leaving clustering untouched) is noise:
69 vs 68 closure definitions across nine query shapes. Nothing shipped.

Adds the fixture, the probe, a standing gate on the outcome, and the record —
including a real defect the measurement exposed on the epic tip: django's
query.py leaves 8,212 of 10,135 unspent and drops a score-290 cluster to keep a
score-14 one. Filed separately.

No behaviour change, so no CHANGELOG entry.
2026-08-06 14:07:22 -05:00
Colby McHenryandClaude Opus 5 03893b0ab9 CG-33: converge incremental sync with a full rebuild
A live, auto-synced index did not converge to a clean rebuild of the same
tree — 4.3% of distinct edges wrong in both directions on this repo's own
index, overwhelmingly `calls`, which is what flow queries traverse and what
explore's file ranking weights. Silent: nothing warned, and the symptom read
as "codegraph isn't very good" rather than "this index needs rebuilding."

Two causes, and the fix needed both. Resolution binds a reference to one of
the same-named definitions PROJECT-WIDE, so a definition appearing or
vanishing changes the correct answer for references in files the sync never
touches — and those references resolved successfully once, which deletes
their unresolved_refs row, leaving nothing to revisit them with (#1240's
retry only revisits refs parked as failed). Separately, when nothing
disambiguated the candidates the winner came down to rowid, i.e. the order
files happened to be WRITTEN, which differs between a scan-order full index
and a sync that appends each file as it changes. That second one is why
re-resolution alone could not converge: re-resolving against the identical
graph still picked a different candidate.

So getNodesByName now orders by (file_path, start_line) — a property of the
code, not of the write order — and sync computes a definitionDelta and
re-opens the resolution edges whose answer it may have invalidated,
re-inserting each as the reference that created it for the orphan sweep to
bind against the post-sync graph.

The delta compares `file\0name` pairs per file rather than one name set over
the batch: a commit that adds `collect` to a new file while an unrelated
changed file already defines `collect` cancels out of a batch-wide set, and
that miss was the largest residual class in the first measurement.

Conservative where the failure modes are asymmetric — a wrong deletion is a
permanent edge loss, a missed rebind is only residual drift. Edges without a
refName stamp are never touched (nothing to restore them from), sources the
sync already re-extracted are skipped, and a per-name ceiling declines the
generic names. Edges are deleted before the sweep re-inserts, since
INSERT OR IGNORE against idx_edges_identity would otherwise keep both rows
when a reference rebinds elsewhere.

Replaying real commits of this repo through sync, then diffing against a
rebuild: 16 commits 48 -> 0; 80 commits 1,634 -> 361, with the actively
misleading direction (stale edges the index keeps asserting) 671 -> 2.
Index and sync wall-clock are unchanged; the ORDER BY costs 18% per uncached
name lookup, which never reaches wall-clock because the resolver memoizes it.

The 357-edge residual at 80 commits is one pre-existing class: refs to
generic names (`push`, `join`) parked above #1240's per-name retry ceiling,
which a rebuild resolves into cross-language garbage — a TS test file
"calling" an R method. Converging there would mean manufacturing wrong edges,
so it is left alone. And no drift metric in `codegraph status`: it cannot be
computed without the rebuild it would be recommending, and a proxy would fire
on that residual and train users to ignore it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 04:32:40 -05:00
Colby McHenryandClaude Opus 5 5f32478b57 docs(benchmarks): record the CG-26 A/B — the invariant holds on every path
Deterministic 6-repo table, the three agent A/Bs (django, excalidraw, okhttp,
2 runs/arm, Read 0 in all 12 runs), and an honest read of the two repos that
deliver a few hundred fewer source chars: at the CG-31 tip both were over-filled
by the flat-200 section overhead and paid for it by discarding their epilogue
whole.

Also: CHANGELOG entries for the two user-visible changes, and the memory note
now carries the fourth accounting gap plus the two lessons — hold the REMAINDER
when a full reservation no longer fits, and never skip a file over an accounting
difference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:58:18 -05:00
Colby McHenryandClaude Opus 5 be7c968439 docs(benchmarks): record the CG-31 A/B — no regression, four repos stop truncating
Deterministic (6 repos, clean rebuilds, both builds): four deliver more source
and one more file each, two are byte-identical, none deliver less. Agent A/B
(django n=3, okhttp n=2, gin n=2, sonnet/effort high, both arms codegraph-on,
0 contamination): the new arm is faster on all three, Read at or below
baseline, occupancy lower.

Also records the two corrections the suite forced on the first cut of the
guard, and the two residuals CG-26 inherits — the render loop's 600-char
epilogue margin (a sweep was run and deliberately NOT shipped) and the BUY
arm's source-space-only guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 03:12:30 -05:00
ctype_lab ba58365c6f docs(union): describe first-class union nodes 2026-08-06 17:11:26 +09:00
Colby McHenryandClaude Opus 5 0d014a6582 docs(benchmarks): record the CG-30 A/B — deterministic win, no behavioural regression
Primary evidence is deterministic: on django, query.py rendered 2.12x its budget
on main and 1.49x with the bound, and the freed bytes reach the files below it
(+2,104 chars of source in the same five files). gin is a true control — the two
builds emit byte-identical explore output there, which is what makes its agent-run
deltas variance by construction.

Also records the harness trap that voided the first two batches: ab-new-vs-baseline
swaps src/ to the baseline ref mid-run, so a commit made while it runs captures
baseline sources. Check the "changed:" line before believing any run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 02:26:22 -05:00
ctype_labandClaude Opus 5 86854cd0d7 docs(union): changelog entry + port-checklist annotations
The two kernel port checklists record the extractor configs as surveyed
at porting time; their structTypes lines are marked superseded rather
than rewritten, so the surveys stay readable as history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 15:47:09 +09:00
Colby McHenryandClaude Opus 5 2cf63fd114 CG-33: record index-drift measurement and add a drift diff tool
A live, auto-sync-maintained index does not converge to a clean full
rebuild of the identical tree. On codegraph's own repo, 4.3% of distinct
edges are wrong in both directions (751 missing, 476 stale), dominated by
`calls` — the edges flow queries traverse and that feed the RWR mass
explore ranks files by.

Raw edge rows differ by only +0.7%, because the divergence is
bidirectional and nets out; any drift check must compare edge SETS.
Rebuild-vs-rebuild is 0, so the indexer is deterministic and this is not
noise. Node sets are identical and every integrity check is 0 on both
indexes, so this is stale cross-file resolution, not accumulated residue.

`diff-index-drift.mjs` is read-only and takes two index paths — rebuilding
is the caller's job, so the tool can never clobber the artifact it is
measuring. It also refuses a missing path, since node:sqlite creates an
empty database rather than failing and an empty schema reads exactly like
a stale pre-migration index.

Diagnostic captures from the originating incident are deliberately NOT
committed: they contain verbatim source from a private repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 01:24:59 -05:00
Colby McHenryandClaude Opus 5 d6d17288be docs(benchmarks): re-derive the token figures the result.usage bug touched
Swept every benchmark doc for figures produced off `result.usage` and fixed
the ones that had raw logs to re-derive from.

residual-context-occupancy.md — the sonnet 3-turn throughput table. Re-derived
from the preserved logs: tokens saved 23% -> 56%, and vscode's "98% MORE tokens
with codegraph" was never real, it is 41% fewer. Cost, time and tool calls were
never affected by this field and are unchanged. The occupancy table itself is
measured off the timeline, so every number in it stands -- including the 82%
higher residual, which is the finding the document exists for.

call-sequence-analysis.md — this doc DIAGNOSED the bug and its reproduce block
claimed the aggregator summed per-turn tokens. It did not, until 04c0f8e. Noted,
with the three wrong results the gap produced: the excalidraw cut recorded here,
the sonnet campaign, and the Opus re-measure that invented a token regression.

answer-directly-vs-explore-agent.md — build 0.9.4, 2026-05-24, raw logs gone.
Cannot be re-derived, so flagged rather than silently left or invented: its
token figure is indicative, its turn/read/context findings do not depend on the
broken field and stand.

The remaining benchmark docs (allocation-ab-1500, dedup-cg20, allocation-
efficiency, feedback-metrics) carry no throughput tables — checked, clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:36:10 -05:00
Colby McHenryandClaude Opus 5 48cbc21a17 merge: cross-call explore session dedup (CG-2, #1500)
Never re-serve source this session already sent: session-scoped state (CG-17)
plus a precise back-reference in place of the bytes (CG-18). Duplicated source
drops 6.4% -> 0.74% of the response, at flat cost per call, with more unique
source in its place.

Bar 4 of CG-20 -- "residual occupancy must drop" -- is NOT met, and the gate
records why: CG-18's accepted rule spends reclaimed bytes on files not yet
shown rather than banking them, so a design that re-spends every byte cannot
lower the byte count. The two requirements were mutually unsatisfiable as
written. Bars 1-3 (no extra Reads, no abandonment, no bucket shift) pass across
24 runs on both arms.

Kept on that basis, and cheap to reverse: CODEGRAPH_EXPLORE_DEDUP=0 disables it
at runtime. Full gate: docs/benchmarks/explore-dedup-ab-cg20.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:29:20 -05:00
Colby McHenryandClaude Opus 5 2cfd321b23 docs: CG-20 — the dedup gate, three bars pass and the fourth cannot (CG-2)
Read is 0 in all 24 runs of both arms across client-go and excalidraw, no
isError, codegraph is the last tool in every run, and both "Read a file we
returned" / "did not return" buckets are empty — with back-references
demonstrably reaching the agent in 8 of the 9 multi-call runs.

Residual occupancy is flat, and the measurement shows it could not have been
anything else: CG-18 was accepted on the rule that reclaimed bytes get spent on
unseen files rather than banked, so the byte count cannot fall. What moves is
the duplicate fraction of that residual — 87% less across the agent runs, 86%
and 94% on two matched deterministic replays.

Also recorded: dedup.savedChars is a pre-clip figure (11,450 reported against
1,042 chars actually re-served), so tuning off it inflates the win ~7x.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:11:33 -05:00
Colby McHenry 7a7ea30cbd docs: cross-call dedup — its gates, where the bytes go, and the all-pointer guard (CG-18)
Extends the session-state design doc with the layer built on it: what gates a
withheld span (session, content fingerprint, two size floors, kill switch),
why the fingerprint and not #1474's drift flag, the two channels the
reclaimed bytes leave by, and why "no duplicate ranges across calls" holds
for every call that had something new to say rather than universally.
2026-08-05 13:47:29 -05:00
Colby McHenry 4e94860f8f docs: the session-state layer — its four constraints and which way to be wrong (CG-17) 2026-08-05 13:24:06 -05:00
Colby McHenryandClaude Opus 5 5dd4db68cd docs: the occupancy baseline says our residual is higher — write that down (CG-13)
Fills the empty RESULTS placeholder with the 2026-08-05 campaign
(bgjob-6d357cd2: 7 repos x 2 arms x 4 runs x 3 turns, 137 min).

The finding is not the flattering one. Retrieval residual is 82% HIGHER
with codegraph and share-of-context 27% higher, on all seven repos —
vscode 67k resident against 18k. At the same time six of seven
without-arms *process* more total tokens (gin 660k vs 290k) while
leaving less behind. Both are true: one dense verbatim payload stays
resident where many small Read/Grep results evict. This corroborates
issue #1500 on our own harness; the aggregator used to print it as
"-82% lower with codegraph" until the sign bug at 520ed9d.

Also:

- States the regime everywhere. This ran claude-sonnet-5 / 3-turn; the
  README's table is Opus 4.8 / single-question. Measured 24/23/20/84
  against the published 60/69/20/89 — model and turn count, not
  contamination. Records the two inversions honestly (vscode processes
  98% more tokens, django costs 17% more) and that 4 of 28 with-arm
  sessions still touched Read.
- Corrects the "Settled" section, which claimed a 7-repo baseline
  existed before one did, and adds the unclaimed Opus rerun.
- Records the contamination gate: 0 CLI calls returned output in 56
  sessions, but 29 attempts were blocked — 26 of 28 without-arm
  sessions tried. no-cli-shim.sh is load-bearing, not precautionary.
- Records the secondary readings as absolute, not before/after: 86.7%
  allocation efficiency pooled over 110 calls, read-of-a-file-we-
  returned 2%, explore-again 73% and ambiguous by construction.

README.md is deliberately untouched — restating its numbers from sonnet
3-turn data would be wrong. A proposed README paragraph is drafted at
the end of the benchmark doc for the maintainer to accept or reject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 13:06:51 -05:00
Colby McHenry 382791f11e docs: one entry point for the three feedback metrics, and how to run them (CG-11)
Three per-metric docs told a maintainer what each number means; none said
which one answers which question, which harness produces it, or how to read
the arm table. agent-eval-feedback-metrics.md is that page — the metric →
question map, when to reach for ab-new-vs-baseline.sh (isolates a change,
both arms codegraph-on) versus run-all.sh (with vs without, a different
question) versus bench-readme.sh, the worked CG-22 express table where all
three read together, and the bucket → fix mapping. Not a fourth restatement:
the derivations stay where they are and each doc now points here.

The caveats that change how the summary table is read are carried over rather
than dropped — allocation efficiency is relative (attribution is by citation,
so same-question builds only, and never "codegraph wastes N%"), occupancy
shares are Claude Code / 200k and do not transfer between hosts while the arm
ratio does, sufficient is not correct, small-n throughout. Plus the
contamination row, which means different things in the two harnesses and is
the first thing to look at in both.

Also records that the CG-8 7-repo bucket block no longer re-derives:
bench-readme.sh overwrites /tmp/ab-readme, so the swept logs are gone. The
current logs give a different distribution over the same 62 calls, and the
CG-8-era and current classifiers agree exactly on them — so nothing moved
under the metric, the corpus did. CG-13 re-establishes the baseline.
2026-08-05 00:59:58 -05:00
Colby McHenry fa15d1046a docs: allocation efficiency — the metric, its guards, and the 103-run baseline (CG-9)
Records the sweep over every A/B log on this machine (103 sessions, 297
explore calls, 0 crashes) and, more usefully, the new-vs-baseline arm
table the metric exists for: express 82% → 100%, cg21/client-go 67% →
95%, two pairs going the other way.

States the caveat in the places it can be misread: the corpus median sits
in the eighties because these are flow questions whose answers name most
of the chain, the metric is byte-weighted, and an agent can use a file
without citing it. It compares two builds on one question; it is not an
absolute waste figure.
2026-08-05 00:44:30 -05:00
Colby McHenryandClaude Opus 5 254e573f11 docs: the 7-repo bucket baseline, and the recall case by hand (CG-8)
The 14 multi-turn with-arm sessions of the README corpus exercise every bucket
(62 calls), so the sweep is no longer one repo family: 47% explored again, 11%
Read a file we returned, 2% Read a file we did not, 23% Grep/Glob, 18% moved on.
Flagged as a baseline rather than a verdict -- three-turn sessions on hard flow
questions, and "explored again" includes the legitimate second call on a repo
whose budget is 2-3.

The recall bucket's one real instance is worth reading: explore returned
InteractiveCanvas.tsx and named StaticCanvas.tsx without shipping it, and the
agent went and read exactly that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:30:58 -05:00
Colby McHenryandClaude Opus 5 945e52f4ee docs: explore sufficiency -- the metric, its rules, what it caught (CG-8)
Records what each bucket means and which fix it points at, the four rules that
keep the classification honest (same-message calls, bookkeeping tools, subagent
threads, earlier-explore files), and the three real transcripts it was
hand-checked against -- including the excalidraw canvasNonce run, where it
independently found the data-flow frontier CLAUDE.md already documents: 0%
sufficient, without being told what to look for.

Also states what it does NOT say: sufficient is not correct, one Read is a vote
rather than a proof, and bucket 1 is ambiguous by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:29:57 -05:00
Colby McHenryandClaude Opus 5 52b194a6be merge main into CG-3: keep the envelope view alongside occupancy
CG-3 branched from main before CG-1 landed and rewrote parse-run.mjs wholesale
into an exported parseSession(), which dropped CG-1's --envelope/--answer
reporting entirely. That view is the instrument the CG-1/CG-22 allocation gate
measures bar 2 with, and it is in that benchmark's documented reproduce steps,
so it cannot be lost to the merge.

Resolution takes CG-3's rewrite as the structure and ports the envelope feature
into it: parseSession now collects codegraph_explore response text in call
order, formatEnvelope renders the per-file share, and the CLI parses
--envelope/--answer ahead of the positional filter so a glob is never mistaken
for a log path.

The glob sentinel stays written as a \u0000 escape, never a literal NUL byte --
a raw one makes git treat the whole script as binary, exactly as the comment
there warns.

Verified: --selftest 18/18, and a synthetic explore transcript reports the
expected per-file shares and answer-set total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 00:08:57 -05:00
Colby McHenry e35d4861e0 test(agent-eval): block the codegraph CLI outright — hiding it from PATH was not enough (CG-7)
An agent denied `codegraph` on PATH ran `find / -maxdepth 4 -iname "*codegraph*"`,
found the binary, and invoked it by ABSOLUTE PATH — 12 times in one without-arm
run. So block the invocation itself with a PreToolUse hook on Bash, written into
the run's output dir as an artifact alongside the MCP configs rather than as a
repo file.

The pattern matches command positions only, so looking is still allowed and only
using is denied: `grep codegraph src/`, `ls .codegraph` and `which codegraph`
pass through, while `codegraph explore`, `/abs/path/codegraph …`, `cd x &&
codegraph …` and `VAR=1 codegraph …` are refused. run-all.sh proves both
directions at startup and refuses to run if either fails. parse-run.mjs's
detector uses the same rule, so prevention and detection cannot drift — and it no
longer false-positives on the corpus path, which contains the word codegraph.

Verified end-to-end: the without-arm now probes with `ls .codegraph; which
codegraph`, finds nothing usable, and falls back to Read/Bash.
2026-08-04 16:10:32 -05:00
Colby McHenry b93c8d2b6c test(agent-eval): self-test the occupancy math, and fix ratio calibration under shedding (CG-7)
parse-run.mjs --selftest runs the math over synthetic transcripts with known
answers: attribution, message.id dedupe, compact_boundary, FIFO micro-compaction,
and multi-turn stitching. It found a real bug. A gap where the window also SHED
content has a delta far below what was added, which reads as absurdly dense text
and dragged the whole run's ratio with it -- a shed gap in the fixture pushed
2.5 chars/tok to 4.4 and left the wrong result resident. Shedding can only push a
gap's ratio up, so the calibration now takes the lower median as its centre,
drops gaps well above it, and pools the rest. Runs that never shed are unaffected
(gin and vscode re-measure identically).

Also drafts docs/benchmarks/residual-context-occupancy.md -- method, error bar,
and the limitations this metric does not settle. Baseline numbers to follow.
2026-08-04 14:41:13 -05:00
Colby McHenryandClaude Opus 5 c65d56ceba docs: CG-22 — the epic's gate, re-run at CG-15's exact setup (#1500)
CG-21 fixed the unspent-reservation defect and re-ran the A/B itself. CG-22 is
the gate proper: CG-15's setup, unchanged, measured independently of the task
that wrote the fix. RUNS=3, both arms codegraph-on, sonnet/high,
CODEGRAPH_NO_PROMPT_HOOK=1 on both, baseline pinned to 49c11fc by SHA, fresh
clones of the same three repos and the same three questions.

All four bars pass. Read = 0 in all 12 new-arm runs (express 3, excalidraw 3,
client-go 6) while the baseline reads in 3 of 3 express runs and 1 of 6
client-go runs; the express run that failed CG-15 with 4 Reads of lib/utils.js
now reads nothing and receives the file whole. Answer share >= 66.6% in every
new run. Medians: express 26s -> 24s, excalidraw 26s -> 26s, client-go
35s -> 36.5s at n=6 with fully overlapping ranges.

Deterministic core re-measured on BOTH builds in one session rather than
quoted: lib/utils.js renders whole at 6,380 B on baseline and on HEAD (583 B
stub under CG-12), and the source envelope goes 13,849 -> 14,913 against an
unchanged 13,000 budget, so the reservation is spent and the envelope stops
shrinking. client-go's +1.5s median is attributed away from the build: explore
latency 669 vs 668 ms (n=5) and the new build's deterministic response is both
smaller (15.8K vs 18.9K) and more concentrated (top file 50.2% vs 35.7%).

Two counter-points recorded as measured, not smoothed: excalidraw's new arm
runs below its baseline on answer share (66.6-81.9 vs 75.5-92.7, all well over
the bar), and this session's client-go baseline sampled well (85.6-100%), so
the #1500 gap is smaller here than in CG-21's session.

CHANGELOG: the two #1500 bullets were multi-sentence paragraphs carrying
implementation detail. Rewritten to house style as four bullets that lead with
the symptom, with the mechanism, the banner catalogue and the old-behaviour
contrast dropped; the @LeDuyViet credit and the re-index note stay.

Suite green on the measured build: 171 files, 2,868 passed, 6 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:58:26 -05:00
Colby McHenry abee46c5e4 docs: CG-21 A/B — the gate passes, all four bars (#1500)
Re-runs CG-15's agent A/B on the fixed build: same harness, same three
prompts, same baseline ref, n=6 per arm on express and excalidraw.

Read = 0 in all 15 new-arm runs. The express regression that routed the
defect to CG-21 does not reproduce in 6 attempts, and the baseline now
reads in 4 of 6 while the new arm reads in none (median 24.5s -> 21.5s),
so the control beats the arm it previously lost to. client-go holds
92.7-96.2% answer share against a baseline run at 53.8%.

Excalidraw's new arm is ~8s slower at the median and that is recorded as
NOT attributable to the build rather than waved through: explore's own
latency is 374ms vs 372ms on the same query and index, the deterministic
responses differ by +2% with one byte-identical, and the unchanged main
build's own median moved 34s -> 26.5s between the two sessions — the same
magnitude as the gap.

Bars were not re-baselined; they are CG-15's four, applied to a larger
sample. The CG-15 section is kept intact and marked superseded, because
its root-cause analysis is the record of why the fix looks like it does.
2026-08-04 02:40:10 -05:00
Colby McHenry 51cd053d85 docs: CG-21 — spending the reservation (design record + CHANGELOG precision)
Records both levers, the funding-pool design (and the per-file version that
dropped payslip_builder.go), the resolved memory-budget.ts exception, and the
two hermetic fixtures with their mutation matrix.

The CHANGELOG clause 'no longer trimmed while a smaller, weakly-related one is
included whole' was imprecise after CG-21: the smaller file often IS still
included whole now, when its share nearly covers it. Reworded to say what the
fix actually guarantees.
2026-08-04 02:17:28 -05:00
Colby McHenryandClaude Opus 5 c7103c7f2f docs: CG-15 agent A/B of the #1500 allocation change — gate fails on the control
Three repos, both arms codegraph-on, sonnet/high, 3 runs per arm.

PASS on the two medium repos: client-go (the reporter's Go shape, 2,001 of
2,454 files generated) and excalidraw hold Read 0 in every run of both arms,
excalidraw goes 34s -> 24s at the median with one fewer explore call, and the
generated clientsets/informers that took 10.5%% of a baseline envelope appear
in no new run.

FAIL on express, the small control, in 1 run of 3: 4 Reads and 52s against a
baseline that read once. Not agent variance — replaying that run's query
deterministically, lib/utils.js goes from 6,380 bytes whole to a 583-byte
cluster stub and the envelope shrinks 13.8K -> 9.2K against an unchanged
13,000 budget. The diagnostic shows the allocator was right and the render
loop was not: utils.js is the top-ranked file, was reserved 3,870 chars, and
spent 583. The whole-file bound (allowance + grace = 4,450) lands just under
the file's 5,293 bytes, so the whole-file render is declined and the unspent
reservation is dropped rather than redistributed.

Bar 1 is the hard gate, so per CG-15's acceptance rule the design goes back to
CG-12 — the budget is not to be widened to compensate. Two candidate fixes are
written up in the design doc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:26:47 -05:00
Colby McHenryandClaude Opus 5 1d9206d2d0 test(explore): lock down proportional byte allocation (CG-14, #1500)
Coverage for the CG-12 allocator, built around "would this go red if the
lever were removed" rather than line coverage — every way this regresses
is silent, ending in an agent falling back to Read.

Unit (`explore-proportional-allocation.test.ts`, 18 -> 38): calibration
pins, envelope safety across every tier and 30 candidate shapes, the
cliff boundary, spine weighting/trim survival, the diffuse control, and
the degenerate inputs — identical scores, a lone file, a runaway top
scorer, zero results, maxFiles 0, a non-finite score.

End-to-end (`explore-allocation-e2e.test.ts`, new): CG-6's second
regression fixture as a deterministic synthetic mirror — a large relevant
file, a small helper that used to win by shipping whole, and an
incidental `explore`/`BUDGET` collision — asserting per-file budget
share, not file presence. Plus degenerate result sets and a survey-style
diffuse control through the real render loop. The live self-query arm
stays in probe-allocation.mjs, where drift is a number to re-baseline
rather than a red suite.

Reverting the render loop to the pre-CG-12 rules reproduces #1500 on the
mirror exactly and takes 5 e2e + 2 payroll gates red:

  file                     score  pre-CG-12       CG-12
  src/mcp/allocator.ts      77.5  4,843 (39.7%)   9,335 (80.1%)
  src/util/budget-math.ts   36.0  6,079 (49.8%)   1,037 ( 8.9%)

Two defects the invariants surfaced, both fixed in tools.ts:
- rounded shares could sum past `pool`, so "reservations fit the
  envelope" was approximate rather than exact; both terms now floor
- a non-finite score made every share Infinity/Infinity, handing the
  render loop a NaN allowance; `weightOf` now fails safe to 0

Also adds a hard-ceiling gate to the payroll fixture — at 19.3K against
a 19.5K ceiling it is the only fixture that stresses the ~25K inline cap
— and exports EXPLORE_ALLOCATION so invariant tests read the constants
while one test pins the literals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:56:50 -05:00
Colby McHenryandClaude Opus 5 5f7f5f59df feat(mcp): score-proportional byte allocation for explore, with a relative cliff (CG-12, #1500)
The explore envelope used to follow FILE SIZE, not relevance. Every admitted
file was capped at the same flat `maxCharsPerFile`, while the whole-file rule
handed anything under `maxCharsPerFile * 3` its entire contents — a 3x swing
decided by how big a file happened to be:

  - self-query: `memory-budget.ts` (score 18) shipped whole and took 51.2% of
    the response; `src/mcp/tools.ts` (score 41, 4x the graph mass, 3x the term
    hits — it holds the allocator itself) was clipped at 3,800 and got 32.9%.
  - #1500 Go fixture: two generated CRUD files shipped whole at ~4.5K each AND
    consumed two of the tier's four file slots, so `BuildPayslip` — the
    hand-written "calculate" half of the question — ranked #6 and never
    rendered at all.

`allocateExploreBudget` now reserves each ranked file a share of the envelope
before anything renders, so the render loop spends a reservation instead of
racing for whatever the files above it left:

  - weight = score x worth x (spine ? 2 : 1), where `worth` is `rankPenalty`
    applied a SECOND time — ranking answers "is this file about the query",
    allocation answers "will these bytes teach the agent anything", and
    generated CRUD can legitimately rank while its bytes stay boilerplate;
  - a relative cliff at 15% of the top weight (capped at SCORE_FLOOR_MAX, so a
    god-file can't silence peers the score floor just admitted) gives a file
    ZERO source — path, symbols and line numbers only — and crucially frees its
    `maxFiles` slot for a file that earns its bytes;
  - every admitted file gets MIN_CHARS, then the remainder splits by weight:
    the floor keeps a diffuse survey question returning a spread, the remainder
    concentrates a precise one;
  - the flat per-file cap is retired as the primary guard, leaving a 70%-of-
    envelope safety valve.

Two changes were needed to make the reservation bite: an oversize cluster now
shrinks by whole MEMBER symbol ranges (a single-cluster god-file previously
took ~40% more than allotted, and the file below it was dropped for lack of
room), and the arrival-order budget stops are gone — they cut files by the
order they were reached rather than by merit.

Measured: payroll-go answer group 25.6% -> 78.7%, generated 57.4% -> 0%, and
`func (s *Service) BuildPayslip` now delivered; self-query `tools.ts` 18.5% ->
60.6%, past the epic's >50% bar. Controls hold: cobra/gin diffuse survey
queries keep their file spread (3->3, 3->4), express's middleware query is
byte-identical, and gin's flow query moves its top file from the thin `ginS`
singleton wrapper to `routergroup.go`.

One documented exception to "no previously-unclipped file becomes clipped":
`memory-budget.ts` was unclipped-whole at 5,672 and now clusters within its
3.1K reservation. That is the epic's own diagnosis of the bug — it scored 18
against 58 and was taking the larger slice purely for being small.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:37:17 -05:00
Colby McHenryandClaude Opus 5 a3898cdc70 feat(mcp): relevance scoring overhaul for explore — kill incidental name-collision matches (CG-10, #1500)
Explore's per-file relevance awarded +50/+10/+3/+1 by match class and admitted
anything scoring >= 3. Neither half held up: the tier said HOW a symbol reached
us, never whether the match was evidence, and an absolute floor admits noise on
any repo where the top file scores 50+. Three scripts/agent-eval/*.mjs harnesses
took 63% of this repo's own "how does explore allocate its output budget" answer
on nothing but an unused `const explore` and a `const BUDGET`.

Four levers:

- KIND WEIGHT (RELEVANCE_KIND_WEIGHT): callables and types 1.0, members ~0.5,
  variable/constant/parameter 0.15-0.35. A weak-kind symbol with no usage edge
  anywhere in the graph (`contains` excluded — nesting is not usage) drops to
  0.08. Only weak kinds in the top two tiers pay for the DB probe; the subgraph's
  own edges answer most cases free. No measurable latency change (210 vs 211
  ms/call, n=12 interleaved).

- PERIPHERAL CAP: nodes >=2 hops from any match accumulate into a bucket capped
  at 5. Uncapped they added a flat +1 each, so a file grew more relevant by being
  bigger — parse-session.mjs reached 22 off one constant plus twelve unrelated
  symbols.

- RANK PENALTY: generated files x0.3, low-value x0.5, applied to the score AND
  the graph mass. Score alone would not have fixed #1500 — the generated CRUD
  carries MORE graph mass than the hand-written use-case, and graph mass outranks
  score in the comparator. Self-normalizing, never a hard exclusion.

- RELATIVE FLOOR: clamp(topScore * 0.2, 1, 10). Capped at one full-strength
  direct match so concentration elsewhere can never exclude one (without it a
  named-seed-heavy file pushed the floor to 21 and dropped a file the agent had
  named by class name). Backfills to 3 candidates when it would leave fewer, and
  drops the evidence requirement rather than return nothing at all.

excludeLowValueFiles was dead config — declared per tier, read nowhere; the
test/spec exclusion has been unconditional for a while. Removed. The real gap was
the detector: `isLowValue` anchored on a leading `/`, so a repo-ROOT `test/` dir
(express, cobra, most of npm and Go) never matched — express's routing question
spent 59% of its envelope on three test files. Anchored at `^` too, and the
filter now runs before the floor and judges "are there other candidates?" on the
whole gather.

Measured before/after on the same indexes (baseline bd86ad2):
- payroll-go fixture: generated 57.4% -> 23.5%; answer 25.6% -> 61.5%; cycle.go
  delivered 0 -> 38.9%. Generated ranks #3/#4, was #1/#2.
- self-query fixture: eval scripts 72% -> 0%; tools.ts ranks #1.
- express "route a request": 59% to test/* -> lib/application.js + lib/response.js
- cobra x3, codegraph "indexing pipeline": byte-identical (control)

Diagnostic gains a per-file penalty multiplier and NodeKind mix, so "why did this
file score X" is legible. Selection stages reordered to match the pipeline.

CG-6's gates flip from it.fails to live regressions except the byte-split ones,
which stay open for CG-12 (allocation still follows file size within the ranked
set).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:02:45 -05:00
Colby McHenryandClaude Opus 5 bd86ad2061 test(explore): #1500 regression fixtures for budget allocation (CG-6)
Two permanent fixtures pinning the failure mode from issue #1500 — explore
spending its byte envelope on files that merely name-collide with the query.
BOTH FAIL TODAY, by design: they document the bug and become the pass gate
for CG-10 (scoring) + CG-12 (proportional allocation).

__tests__/fixtures/payroll-go/ — a synthetic Go service mirroring the
reporter's shape: generated FKIT CRUD beside a hand-written payroll use-case,
entered from an HTTP route. Half the generated tree carries ORDINARY names
detectable only by their `// Code generated ... DO NOT EDIT.` header (the
#1500 case, and end-to-end cover for CG-5); `payrollpb/*.pb.go` covers the
path-detectable channel. BuildPayslip, Upsert and Store each exist twice,
generated and hand-written. cycle.go sits above the whole-file window so it
clips; the generated files sit below it so they ship whole.

Asking "how does payroll cycle create and calculate payslips?" — naming none
of the answering symbols — the generated CRUD delivers 57.4% of the envelope
against the hand-written layer's 25.6%, all of the latter domain types.
cycle.go is allocated the single largest slice (30.6%) and delivers ZERO: the
hard ceiling drops its whole section. runPayrollCycleAll, the hand-written
BuildPayslip and the real Upsert never reach the agent.

The second fixture is this repo, "how does explore allocate its output budget
across files", where scripts/agent-eval/*.mjs take 71.8% against tools.ts's
18.5% despite scoring 4.6x lower. It reads the live index, so its assertions
are relative rather than fixed percentages.

- scripts/agent-eval/probe-allocation.mjs — per-file budget-share probe,
  driving the CG-4 diagnostic through a JSONL sidecar so it measures the
  shipping allocator. Fixture entries are hermetic (copy + re-index per run,
  verified byte-identical across runs); exits 1 while any assertion fails.
- scripts/agent-eval/allocation-fixtures.json — both fixtures declared, with
  the 2026-08-03 baselines.
- __tests__/explore-allocation-1500.test.ts — fixture-shape assertions green
  today; the allocation assertions held as `it.fails` so the suite stays green
  while the bug is open and goes RED the moment it is fixed.

Also documented and deliberately left unfixed: runPayrollCycleAll's
`s.store.Upsert` edge resolves to the GENERATED Store.Upsert, not the
hand-written one — same-name method resolution across two packages picks the
wrong receiver. It is upstream of the allocation bug, so it belongs with
CG-10's scoring work.

Refs #1500

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:30:17 -05:00
Colby McHenryandClaude Opus 5 16e17495f4 feat(extraction): content-based generated-file detection (CG-5, #1500)
`isGeneratedFile` was path-only, but Go's own convention is a CONTENT
marker (`// Code generated by <tool>. DO NOT EDIT.`), not a filename one.
A Go monorepo with generated CRUD in ordinarily-named files sitting beside
hand-written use-cases was therefore invisible to every generated-file
down-rank in the codebase — that is #1500.

Measured on kubernetes/client-go (2,453 Go files): the canonical banner
appears in 2,001 of them, the path check flags 0, the new content check
flags exactly those 2,001 — no false positives, no misses.

Design: decide at INDEX time (content is already in memory for parsing),
persist on `files.generated`, read from the DB. Explore never reads file
headers per request.

- `hasGeneratedHeader(content)` recognizes the standard banners — Go's,
  protoc's, `@generated`, `<auto-generated>`, Thrift, OpenAPI Generator,
  FlatBuffers, bindgen, ANTLR. Precision-first and fenced three ways: an
  8KB/60-line header window, a comment-line requirement (leader or open
  block comment), and markers tight enough that prose can't trip them. A
  generator's own source, holding the banner as a string constant in its
  body, is not flagged; neither is this module itself (pinned by test).
- `isGeneratedFile(path)` is unchanged — cheap, sync, still the fallback.
- Schema v9 adds `files.generated` + a PARTIAL index. DDL only, no
  backfill: the flag derives from content the migration cannot see, so
  rows stay 0 until a re-index and every reader unions the flag with the
  path check — an un-migrated index keeps pre-#1500 behavior rather than
  regressing. Re-index required; noted in the CHANGELOG.
- `generatedPredicateFor(paths)` gives ranking a bounded probe + O(1)
  lookups. Bounded, not cached: no invalidation, so a ranking call can
  never serve a verdict the last sync already replaced. Wired into explore
  ranking, findSymbolMatches, findAllSymbols, search (MCP + CLI), the
  context formatter, and the dominant-file/route-file hygiene filters.

Cost (acceptance bar was no measurable index-time regression): a single
unanchored `/generat/i` test over the header rejects ~every hand-written
file before any line splitting. 4.6 µs/file on client-go (worst case —
82% generated). End-to-end `codegraph init` on client-go, n=3 alternating
arms: 5.73s median with detection vs 5.76s path-only baseline; the arms
cross over between runs, so the difference is inside run-to-run noise.

Scope note: generated status remains a stable TIEBREAK at equal score,
exactly where it was. Making it a strong negative signal is CG-10, which
this unblocks by making the signal correct and available.

Two pre-existing tests hard-coded schema version 8; both now track
CURRENT_SCHEMA_VERSION (or the migration table) so future migrations
don't require editing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:13:59 -05:00
Colby McHenryandClaude Opus 5 b37f191f5a feat(mcp): per-file allocation diagnostic for explore (CG-4)
How codegraph_explore divides its byte envelope among files was
unobservable — you could read a response and guess, but not say "this
file took 16% and that one took 20%." Nothing else in the budget-
allocation epic is measurable without that.

CODEGRAPH_EXPLORE_DEBUG now emits one report per explore call (stderr
table, stderr JSON, or a JSONL sidecar path). Per file: relevance score,
graph mass, term hits, ranking flags, render mode, bytes allocated vs
delivered, both shares, and whether it was clipped — plus why a ranked
candidate never rendered. Totals cover envelope vs maxOutputChars vs the
hard ceiling, the source/meta split, the selection funnel, and the score
floor and relevance-gate thresholds applied.

Allocated and delivered are reported separately on purpose: they diverge
exactly when the 25K ceiling truncates, and conflating them is how a
dropped trailing file goes unnoticed.

Off by default and byte-identical when off — it ships in the product
binary, and a diagnostic that perturbs the response by one byte would
invalidate every A/B taken with it on. ExploreDiagnostics.start() returns
null unless the env var is set, so every call site is a `diag?.` no-op.

Baseline recorded in docs/design/explore-budget-allocation.md: on this
repo, src/mcp/tools.ts gets 15.8% of the envelope while three weakly-
relevant agent-eval scripts take 61% between them — despite tools.ts
carrying 5.4x the score and 2.6x the graph mass of any of them. Small
files ship whole; the large answer file is clipped at maxCharsPerFile.
Rank ordering is correct and buys nothing. The loop also allocated 23,193
chars against an 18,000 budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:51:18 -05:00
49c11fc2e0 Self-hosted telemetry on Cloudflare D1 + password-gated admin dashboard (CG-7) (#1497)
* feat(telemetry): D1 schema + migrations for raw events and daily rollups

First step of replacing PostHog with self-hosted telemetry on Cloudflare D1.
Creates the codegraph-telemetry database binding and the initial migration; no
worker code paths change yet (the ingest write path and the nightly rollup cron
land next).

Schema is raw events plus daily rollups: `events` holds one row per sanitized
event with the envelope broken out into columns and event-specific props as
JSON; `daily_machines`, `daily_event_counts` and `daily_dim_counts` are the
nightly rollups the dashboard reads; `machine_first_seen` and `machine_days`
carry the retention cohorts and are never purged. One generic dimension table
covers every bar and pie, so a new breakdown is a cron change rather than a
migration.

The migration is commented as an audit surface, like the rest of this worker —
every column, and which dashboard chart each rollup table serves.

Three judgment calls worth flagging, all documented in the file:

- `events` gets `(day, event)` instead of the separate `(day)` and `(event, day)`
  indexes. D1 bills a row write per index touched, so a third index on the hot
  table costs ~97k writes/day, and `(day, event)` is a covering index for plain
  day-range scans anyway (verified with EXPLAIN QUERY PLAN).
- `daily_event_counts` and `daily_dim_counts` carry a `machines` column, and
  `machine_days` a `prod` flag. The "users by ..." panels and the production-user
  count are distinct-machine numbers, not event counts, and they are
  unrecoverable once raw events are purged.
- No CHECK constraint on `event`: the worker's allowlist is the source of truth
  and the write path is fail-silent, so a rejected INSERT would lose data
  quietly instead of erroring loudly.

Volume note in the migration footer: ~30M row writes/month against the 50M
included on Workers Paid. Storage is the tighter constraint — raw events grow
~74 MB/day, so retention should start at 90 days (~6.7 GB) rather than 180,
which would exceed D1's 10 GB per-database cap.

* feat(telemetry): admin dashboard worker — scaffold + shared-password auth

New Cloudflare Worker at telemetry-dashboard/, sibling of telemetry-worker/ and
bound read-only to the same D1 database. Serves a static frontend plus a JSON
API behind a shared password, on stats.getcodegraph.com.

Auth is the simplest thing that is actually safe for exactly two users: one
password in a secret, compared in constant time over SHA-256 digests, and an
HMAC-signed cookie (HttpOnly; Secure; SameSite=Lax; Path=/) with a one-year
expiry so you sign in once per browser. The cookie is a signed assertion, not a
lookup key — no session store. Its payload carries a fingerprint of the password
it was minted against, so rotating ADMIN_PASSWORD signs everyone out. Login
attempts are capped at 5/min per IP via a ratelimit binding.

Everything is deny-by-default: assets.run_worker_first routes every request
through the worker before the static-asset server sees it, so the dashboard
HTML, its JS, its CSS and the chart library are all behind the session check.
The login page is rendered inline by the worker rather than served from public/,
which leaves no "is this file public?" judgement calls in the asset directory.
Unauthenticated pages 302 to /login, unauthenticated /api/* gets 401. A missing
secret fails closed rather than opening the dashboard.

scripts/smoke-auth.sh is the regression net — 54 assertions against a throwaway
`wrangler dev` covering the gate, cookie flags and persistence, forged/flipped/
truncated cookies, open-redirect refusal, brute-force capping, and password
rotation invalidating live sessions.

Refs CG-11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(telemetry-dashboard): simplify the chart-library probe in the shell

Refs CG-11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(telemetry): nightly rollup cron + raw-event retention purge (CG-10)

Adds a scheduled() handler to the ingest worker that recomputes
daily_event_counts / daily_dim_counts / daily_machines for the just-completed
UTC day plus a 2-day overlap (late-arriving offline buffers), then purges raw
events past the retention window. Rollup writes are idempotent upserts, so a
re-run never double-counts. Also adds an ADMIN_TOKEN-guarded
POST /admin/rollup?day=YYYY-MM-DD for backfill/repair, and drops the PostHog
forwarding path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(telemetry): dashboard charts — SQL API over D1 + the Chart.js views (CG-12, CG-13)

Replaces the scaffold page with the dashboard proper: 19 panels covering every
view of the PostHog dashboard this retires, driven by one filter row.

src/api.ts is the read API CG-12 specified: /api/{meta,summary,timeseries,
breakdown,activation,retention}, all range-scoped, all parameterized against a
closed set of dims and metrics, all shaped labels[] + datasets[] so the frontend
does no arithmetic. Rollups answer everything except the activation funnel,
which needs raw events and says where they start.

The frontend splits into a DOM-free panel registry (public/panels.js) and the
page that mounts it (public/app.js), so the render check can drive the same
registry the browser rendered from. Panels fail alone, refetch dims rather than
flashing, and every chart carries a table twin.

Two numbers are labelled rather than rounded off: range-wide "users" per
dimension is machine-days (the rollups cannot give distinct machines, and
per-day counts are taken as the largest single-event count so one machine's
install + index + usage is not counted three times), and recent activation and
retention cohorts are marked as still-converting instead of drawn as a cliff.

Both colour scales were run through the data-viz validator against the panel
surface, not picked by eye; the results are recorded in public/theme.js.

Verification, all against the committed fixture (12 machines over 10 days, every
expected number worked out by hand from the events, not recorded from a run):
  scripts/smoke-api.sh      98 assertions
  scripts/render-check.mjs  79 assertions — real Chromium over CDP, no new deps
  scripts/smoke-auth.sh     54 assertions (unchanged, still green)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(telemetry): cutover runbook + the end-to-end gate that de-risks it (CG-14)

The account-level steps of the PostHog cutover are the maintainer's to run, so
this lands the runbook they follow and the check that has to pass first.

The runbook (telemetry-worker/README.md) walks the six steps in the order that
keeps them reversible: Workers Paid → migrate → deploy → watch 24h → verify the
first rollup and the dashboard → only then delete POSTHOG_KEY and cancel the
subscription. Step 3 records the outgoing version id because `wrangler rollback`
is the escape hatch for the whole verification window, and that window is
precisely why the PostHog key is deleted last rather than first.

The new gate (scripts/smoke-cutover.sh, `npm run smoke:cutover`) covers the one
seam nothing else did. Both workers declare the same D1 database_id, so pointing
them at a single --persist-to directory runs the real chain: a client batch →
the ingest worker → D1 → the nightly rollup → the dashboard API reading the
numbers back. Every other suite stops at one link — smoke-ingest at the events
table, smoke-rollup at hand-checked SQL, smoke-api at a hand-written fixture
that the cron never touched. That left the dimension names the rollup WRITES
versus the ones the dashboard READS agreeing by convention across two branches,
where a mismatch is silent: no error, no failed request, just a panel reading
zero forever. 61 assertions, all 13 dimensions, and three deliberate traps — a
ci machine that is active but not a production user, usage_rollup counts that
must be summed rather than tallied, and an uninstall's `targets` that must not
leak into the install-scoped breakdown.

Writing it caught that the activation funnel's denominator is first-seen
machines, not install events (deliberate — a reinstall must not re-enter the
funnel), so the suite now pins that distinction rather than assuming it.

Also rewords the last PostHog reference in dashboard code: a comment justifying
the 14-day retention curve by pointing at a dashboard step 6 deletes. The
reasoning now stands on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(telemetry): tell the truth about where events are stored (CG-15)

The telemetry docs are a privacy contract, and they still described a
managed analytics store that no longer receives anything. Replace that
with what actually happens now — events land in our own D1 database on
Cloudflare, the endpoint makes no outbound requests, raw events are
purged after 90 days and only anonymous daily rollups outlive them.
This strengthens the guarantee rather than restating it: there is no
second party to share with.

- TELEMETRY.md: new "Where it is stored" section; the never-collected
  IP bullet no longer leans on a vendor-side setting to hold.
- docs/design/telemetry.md: ingest section rewritten around D1 + the
  nightly rollup/retention cron; volume math redone on Workers Paid and
  the D1 quota (storage, not writes, is what sets the 90-day window);
  new section documenting the dashboard worker and cross-linking it.
- Fixed three drifts from the worker allowlist the sweep surfaced:
  schema_version was still 1, client_name/client_version was still
  marked "plumbing to add" though session.ts passes it today, and the
  legacy sqlite_backend field the worker still accepts was undocumented.
- telemetry-worker/README.md: step 6 claimed a repo-wide grep came back
  clean, which this runbook itself falsifies. Added step 7 — deleting
  the runbook is what makes that grep true, and is the completion check.
- smoke-cutover.sh: the vendor guarantee is now asserted by class
  (no analytics-ingest endpoint referenced) rather than by one vendor's
  name, so it keeps working once the name is gone. Verified it still
  catches a planted forwarding URL. 61/61 pass.

Retention is documented as 90 days, not the 180 in the task notes: 180
days of raw events exceeds D1's 10 GB per-database cap, and the code
purges at 90.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: untrack local Kommandr issue DB and ignore its sqlite artifacts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:17:10 -05:00
38580e0b04 fix(python): bare class references produce references edges to classes (#1478) (#1493)
Python's class-as-value idioms (return SomeClass, x = SomeClass, registry
dicts, classes passed as arguments) produced no references edges, so
callers/impact on a Django/DRF serializer missed the views that consume it.
Three gates dropped them:

- return_statement was never dispatched by PYTHON_SPEC (kernel mirrored)
- the extraction gate (definedHere) collected function/method names only
- resolution accepted function/method targets only (matchFunctionRef +
  the function_ref import fast path)

Capture return_statement for Python (single expression; tuple returns not
descended), admit same-file CLASS names to the gate, and accept class
targets for Python bare identifiers — scoped to Python so the TS/JS KIND
FILTER contract is untouched. The docopt false-positive mechanism behind
the function-only rule (lowercase locals vs same-named methods) doesn't
transfer: methods stay excluded for bare ids, and the same-file/import
gate + unique-or-drop rules still apply.

Probed on django-rest-framework (~250 files): 559 new references→class
edges, 10/10 sampled genuine (serializer_class = AuthTokenSerializer, the
ModelSerializer field-mapping registry, aliases, ctor args, isinstance).
EXTRACTION_VERSION 24 → 25.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 01:36:07 -05:00
3c1f30ab48 docs(kernel): mark R7b complete — 20 languages default-routed, Linux leg validated (#1387)
Flips the migration plan's R7b milestone to done: eleven languages across
four same-day batches (rust #1371; csharp/ruby/php #1378-#1380; swift/
kotlin #1381-#1382; r/lua+luau/scala/dart #1383-#1386), batch 4 going
4-for-4 first-run parity (12-of-13 arc-wide). Also records the batch-4
upfront grammar-probe method and the dart wasm byte-copy vendor.

Validation note: the full suite (2,688 tests) also ran green on linux-arm64
in a fresh rust:1-bookworm + node 22 container with the kernel built from
scratch and CODEGRAPH_KERNEL_EXPECT=1 — the Linux leg for all 11
post-R7a walkers.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 20:02:23 -05:00
d1b75a1a27 feat(kernel): R7b Dart walker — dart module, vendored-grammar-C d4d8f3e + wasm byte-copy vendor, dart default-routed (#1386)
R7b batch 4 #4 — the FINAL R7b language (docs/design/dart-kernel-port-checklist.md
is the authoritative quirk list). The fourth vendored-grammar-C language,
with a twist: production dart resolved its wasm from tree-sitter-wasms,
whose dart dependency is an UNPINNED github:UserNobody14/tree-sitter-dart —
a routine dependency update would have silently changed dart's grammar.
This PR byte-copies the shipping 0.1.13 artifact into src/extraction/wasm/
(VENDORED_WASM_LANGS += dart) and compiles the same-commit (d4d8f3e337d8)
parser.c/scanner.c in the kernel — table identity proven by the
kernel-grammar-parity row. crates.io tree-sitter-dart is the nielsenko
fork (different lineage) — rejected.

The center of gravity is THE SIBLING-BODY DOUBLE-WALK, reproduced
bug-for-bug: dart attaches every function/method body as a NEXT SIBLING of
its signature, and the TS walkers consume each body TWICE — once via
resolveBody (attributed to the function/method) and once via the enclosing
generic walk (attributed to the file/class). Duplicate local-function
nodes with the SAME id under different parents, duplicated
calls/instantiates refs, and file/class-attributed fn-ref twins all emit
in the exact observed interleave (a dedicated fixture pins the
duplicate-id rows; the bloc kind-census spot-check pins the counts).

Also preserved (probe-pinned): the extractBareCall selector matrix (the
first callTypes=[] language — cascades completely invisible, `?.` encodes
like `.`, the `ConfigT.load()` calls+references double emission with no
callee-of-call skip, capitalized-chain `Foo.create().run` re-encode,
const-object callee names); the constructor hooks (unnamed ctor skipped,
named ctors/factories renamed to the CTOR name with the class as
returnType, `@override (T) m()` record-misparse rescued by class-name
validation); operator methods minting `method "<anonymous>"`;
static_final_declaration constants via the visitNode hook while instance
fields mint NOTHING; the prefixed-return-type prefix bug (`other.OtherClass
f()` → returnType `other`); enum `with` mixins silent vs `implements`
working; anonymous extensions named after the ON type; deferred imports
invisible; named-argument callbacks NOT fn-ref-captured (the Flutter
`onPressed:` idiom — future accuracy PR, TS-side first); `async*`/`sync*`
NOT async; value-refs with the LIVE dart sibling-body pull and the
`$X`-vs-`${X}` interpolation asymmetry; dartdoc kept in all three comment
forms with the annotation-broken chain.

Gates: parity sweeps first-run 0-diff on shelf/bloc/flutter — 5,815 clean
files byte-parity, deferrals 10/21/1341 ≈ the survey's 10/21/~1340
(both-arm grammar reality: empty object patterns — the sealed-class
idiom — and unnamed `library;` dominate; --max-deferral 0.3); full-init
dumps byte-identical ×3 (shelf 7,959 / bloc 40,026 / flutter 1,855,319
dump lines); bloc per-kind node census identical across arms (the
double-walk duplicate rows survive the store identically);
kernel-dart-parity suite (7 fixtures + in-memory CRLF variants +
double-walk duplicate-id pin + generated-file skip pin + two defer pins);
full suite 2,688 green ×2 with CODEGRAPH_KERNEL_EXPECT=1.
DEFAULT_ROUTED += dart (20 langs — R7b COMPLETE).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:55:48 -05:00
bdd687b49f feat(kernel): R7b Scala walker — scala module, vendored-grammar-C master@0aca5d0a6f, scala default-routed (#1385)
R7b batch 4 #3 (docs/design/scala-kernel-port-checklist.md is the
authoritative quirk list). The third vendored-grammar-C language and the
biggest grammar in the tree (35MB parser.c): the vendored wasm is
tree-sitter/tree-sitter-scala master@0aca5d0a6f — a post-v0.26.0 generation
sync that is not a release (the 0.26.0 crate is 30 states BEHIND, so a
crate pin would be a silent downgrade). NO wasm change: production has
parsed with this exact revision since #91 — the kernel-grammar-parity row
(ABI 15, 26,650 states, 32 fields, id-by-id tables) is the whole alignment
proof.

Preserved bug-for-bug (all probe-pinned): the leak-through asymmetries —
extension methods mint NO nodes (first def's body calls leak to the
enclosing scope, later defs invisible, and the braced form resolves its
body field to the `{` TOKEN via first-match-wins field lookup → whole
extension invisible); anonymous `new T { … }` template_body members leak to
the enclosing scope (findAnonymousClassBody misses template_body); the
bodied-vs-bodiless class asymmetry (bodiless headers walk class_parameters
→ default-value calls emit FROM the class; bodied ones never see them) —
plus first-segment import names (`import com.example.C` → `com`), the
val/var hook keyed on the enclosing-definition NODE TYPE (object vals →
constants/value-ref targets, class/trait/enum/given vals → fields) with
consumed initializers, every def routed through extractMethod with the
top-level function fallback, nested defs in bodies minting NOTHING (the
inverse of kotlin) while body-local classes extract fully, curried
signatures keeping only the FIRST parameter list (type params win the
`parameters` field), enum cases positioned at the CASE node with invisible
params/extends tails, extends with-chains via scalaBaseTypeName,
`@deprecated(args)` decorates, the #750 capitalized-chain re-encode
(`WidgetS.create().render`), literal-receiver silence, static-member reads
AND writes, infix invisibility, `derives` silence, scaladoc retention with
the CRLF `\r` pin, full value-reference machinery (shadow prune, last-wins
same-name targets, `$X`/`${X}` interpolation reads), and SCALA_SPEC
fn-refs (bare ids + postfix eta unwrap + varinit, var-init non-capture).

Gates: parity sweeps first-run 0-diff on os-lib/cats/scala3-compiler-src/
scala3-library-src — 1,935 clean files byte-parity, deferrals 0/15/57/116
matching the survey's predictions exactly (scala-3's PHANTOM hasError
files — flag-true, zero ERROR nodes, capture-checking `^` — defer on the
FLAG); full-init dumps byte-identical ×3 (os-lib, cats, scala3 whole-repo
950,889 dump lines); kernel-scala-parity suite (9 fixtures + 9 in-memory
CRLF variants incl. Scala-3 indentation through the external scanner +
phantom/real-error defer pins + first-segment/namespace/value-ref pins);
full suite 2,669 green ×3 with CODEGRAPH_KERNEL_EXPECT=1
(kernel-scaffold's stays-wasm example moved scala → pascal).
DEFAULT_ROUTED += scala (19 langs).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 19:02:52 -05:00
e32135171e feat(kernel): R7b Lua+Luau walker — one lua module, vendored-grammar-C lua v0.4.1, tree-sitter-luau 1.2.0 pin, both default-routed (#1384)
R7b batch 4 #2 (docs/design/lua-luau-kernel-port-checklist.md is the
authoritative quirk list). ONE walker for both dialects (ccpp precedent) —
the differences are exactly four: luau's type_definition aliases, the
`export `-slice isExported hook, the return-type signature suffix, and the
grammar handle.

Grammar prep is kernel-side only, no wasm change: lua is the SECOND
vendored-grammar-C language (the vendored wasm is the v0.4.1 tag, a revision
not on crates.io — tag artifacts compiled via build.rs, shas pinned); luau
is a plain crate pin =1.2.0 whose tarball is sha-identical to the tag (the
swift tag≠crate divergence does not recur). Grammar-parity rows replace the
bump gate entirely.

Preserved bug-for-bug (all probe-pinned): the require/visitNode-hook
ASYMMETRIES (top-level requires — including inside top-level if/for/while —
mint import nodes while the identical body-level statement emits
`calls "require"`; top-level `local x = foo()` initializers are invisible
while global `x = foo()` calls emit), the BFS string-win inside require args
(`require(script:WaitForChild("Kid"))` → import Kid) and Roblox instance
paths, receiver-QN methods (`M.sub.deep::chained`, `_G::installed`,
stack-QN nested globals like `render::leakedGlobal`), the raw-text callee
world (colon forms with `self` never stripped, bracket callees,
newline-glued chains byte-verbatim, the `(handler)` paren-conversion),
LUA_SPEC function-as-value capture with the `M.cb = cb` param-storage skip
and first-occurrence dedupe, LuaDoc `---` keeping a leading `- ` plus
`--!strict` joining docstring chains (block-comment docstrings keep interior
CRLF bytes), variable nodes at the IDENTIFIER with positional value pairing,
duplicate same-(kind,name,line) ids, and the lua↔luau isExported wire
divergence (lua functions: flag absent; luau functions: present-false;
methods: absent in both; variables: present-false in both; `export type`:
true).

Gates: parity sweeps first-run 0-diff on kong/lazy.nvim/lua-resty-core
(lua) + lune/Fusion (luau) — 1,734 clean files byte-parity, deferrals
1/0/0/3/8 matching the survey's both-arm predictions exactly (kong's 1 = a
deliberately invalid fixture; luau's = grammar-inherent generic type packs
and default type params); full-init dumps byte-identical kernel-vs-wasm ×4
(kong 157,650 dump lines); kernel-lua-parity suite (both torture fixtures +
in-memory CRLF variants + glue-chain, duplicate-id, and cross-dialect defer
pins + kernel-arm wire-flag pins); full suite 2,647 green ×2 with
CODEGRAPH_KERNEL_EXPECT=1. DEFAULT_ROUTED += lua, luau (18 langs).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:42:14 -05:00
b2f9ab1800 feat(kernel): R7b R walker — rlang module, tree-sitter-r 1.2.0 crate pin, r default-routed (#1383)
R7b batch 4 #1 (docs/design/r-kernel-port-checklist.md is the authoritative
quirk list; survey + probe record therein). The lightest-shared-surface,
heaviest-hook port: languages/r.ts works entirely through the visitNode hook
(every type list empty except callTypes:['call']), so the walker is a file
node + a faithful hook transcription + the generic extractCall + pre-order
recursion — four shared machineries (value-refs, static-member reads, type
annotations, fn-ref capture) are dead by language gates and stay dead.

Grammar prep is the first true no-op of the arc: the crates.io tree-sitter-r
1.2.0 tarball ships parser.c AND scanner.c sha-identical to the r-lib v1.2.0
tag the vendored wasm was built from — crate pin only, no wasm change, no
bump gate; kernel-grammar-parity gains the r row (ABI 14, same-revision).

Preserved bug-for-bug (all probe-pinned): calls "return" on every return(x)
(named node in v1.2.0), the import quintet's silent dynamic-arg consumption
vs class/generic fall-through asymmetry, library(help = pkg) importing the
named arg, class-idiom variable suppression by callee name, chained/right-
assign/precedence-ghost gaps, env$fn body-leak-to-file, raw-text callees
verbatim (pkg::fn, obj$meth, "strfn" quotes kept, (handler) conversion),
duplicate same-(kind,name,line) ids, roxygen dropped entirely, UTF-16
columns/slices.

Gates: parity sweeps first-run 0-diff on AnomalyDetection/dplyr/ggplot2/
shiny (838 files; deferrals exactly 0/0/0/1 — the 1 is the moustache-
template pseudo-R file, both-arm) — kernel-parity.mjs gained lowercased-
extension matching so .R files sweep (matches detectLanguage routing);
full-init dumps byte-identical kernel-vs-wasm on dplyr/ggplot2/shiny;
kernel-r-parity suite (torture fixture + in-memory CRLF + BOM variants +
defer pin + kernel-arm quirk pins); full suite 2,638 green ×2 with
CODEGRAPH_KERNEL_EXPECT=1. DEFAULT_ROUTED += r (16 langs).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 18:28:28 -05:00