Skip to content

Rewrite: tree-sitter core, token budgets, simpler docs (v0.2.0) - #9

Merged
Chofito merged 54 commits into
mainfrom
rewrite
Oct 5, 2026
Merged

Chofito merged 54 commits into
mainfrom
rewrite

Conversation

@Chofito

@Chofito Chofito commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

A rewrite of Astrograph as one small tree-sitter package. The CLI commands, the MCP tool names (astrograph_*) and serve --mcp stay the same; the index format changes, so existing indexes rebuild on first use.

What changed

  • One package, one binary. src/core (scan → tree-sitter extract → SQLite → link), src/tools.ts (one catalog shared by CLI and MCP), src/format.ts, src/cli, src/mcp.ts. ~2.9k lines of core instead of ~19k across three packages.
  • No TypeScript compiler. References resolve through imports, re-export barrels, tsconfig paths, workspace packages, PHP namespaces/use, and declared types (annotations, new, fields, parameter properties, return types, PHP DI assignments, @var, catch). Every reference is labeled exact / inferred / ambiguous / external / unresolved.
  • Performance. ~3.7k-file PHP/JS repo: ~4.5 s and ~480 MB peak (was several GB). bun run bench guards regressions.
  • Token budgets. Every tool takes maxTokens (lists also limit/offset); cuts are always announced with how to continue. New outline tool (signatures + line ranges, no bodies), line-numbered source, results grouped by file, approximate cost in each MCP answer's footer.
  • No daemon/watcher/locks. The MCP server re-syncs changed files before each call.
  • Docs. The 97-file docs tree is replaced by README, ARCHITECTURE, DECISIONS, ROADMAP, AGENTS and CHANGELOG; site, skill and CI updated.
  • CI/release. Bun 1.4.2, macos-15-intel for darwin-x64, --version prints astrograph X.Y.Z, site has its own lockfile.

Verification

  • bun test (32 tests), bun run typecheck, bun run check, site build, compiled binary smoke test.
  • Indexed and queried real TS and PHP repositories (see DECISIONS.md for measurements).

Part of #2 (the release itself follows the merge).

🤖 Generated with Claude Code

Chofito and others added 30 commits July 28, 2026 11:43
…ot replace

The previous layout had the shape of a pluggable pipeline but not the
substance: the registry was decorative (`backendForPath` had zero call
sites), the indexer held a single `ProjectExtractor`, and Pass B deleted
every tree-sitter node for a file and reinserted the TS Compiler's. After
a full index, no node carried `provenance: "tree-sitter"` — tree-sitter
was discarded work, and node ids churned between the `parsed` and
`resolved` coverage states.

What changes:

- The indexer takes a `LanguageRegistry` and dispatches per file via
  `backendForPath`. Pass B batches per backend.
- Pass B reconciles by node id (`extraction/reconcile.ts`) instead of
  delete-and-reinsert. Matched ids keep their row — and every cross-file
  edge pointing at them — and are enriched in place. A Pass-A-only node
  is a subset-contract violation, so it is dropped *and* recorded as a
  `PASS_A_NODE_DROPPED` warning rather than vanishing silently.
- Pass A stays a conservative subset with byte-identical ids: where
  tree-sitter cannot prove it would compute the same id the enricher
  will — overloads, ambiguous `component` vs `function` — it emits
  nothing and lets Pass B supply the node. Bodyless overload signatures
  are counted for duplicate detection but never emitted.
- Pass A now also emits `contains` edges, so the `parsed` state is
  navigable instead of a bag of disconnected symbols.
- PHP ships as a real backend (tree-sitter only, no enricher) and reaches
  `resolved` on its own terms.
- Grammars are embedded via `with { type: "file" }`, including
  web-tree-sitter's own Emscripten core. Resolving them from
  node_modules at runtime produced a compiled binary that silently
  parsed nothing.
- `configHash` consumes each backend's `versionKeys()` and the real
  tree-sitter-wasms version, so a grammar bump invalidates the index.
- `indexableExtensions()` is the single source of truth for what gets
  scanned; the glob scanner and freshness watcher no longer keep their
  own hardcoded copies of the extension list.

The final graph for JS/TS is byte-identical to before — all 11 golden
fixtures are unchanged. Only the path to it, and the id stability along
the way, are different.

BREAKING CHANGE: `AstrographCore` gains `indexableExtensions()`, and
`Indexer` now takes `registry` instead of `extractor`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follows the core pipeline refactor.

- `status` reports the registered language backends (id, languages,
  extensions) in both the CLI and MCP formatters, so "which languages
  does this index actually cover" stops being a guess.
- The MCP `lang` enum is derived from the registry instead of a
  hardcoded list that advertised `python`, for which no backend,
  grammar, or extension mapping has ever existed.
- `astrograph install` writes the absolute path of the running binary
  into host configs when we are a compiled binary, falling back to the
  bare `astrograph` name in dev/linked mode. The installer puts the
  binary in ~/.local/bin, which GUI-launched hosts (Claude Desktop,
  Cursor.app) frequently do not have on PATH — so the previous bare
  command produced a config that failed with ENOENT. The
  compiled-vs-script detection that the daemon spawner already needed is
  now a shared `runtime.ts` helper rather than a second copy.
- Help text distinguishes the three things called "install": installing
  the binary (curl | sh), `astrograph install` (wires MCP + agent guide
  into hosts), and `astrograph init` (indexes a repo). `uninstall` now
  says what it does not touch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The old suite could not fail on any of this. `harness.ts` instantiated
`TsExtractor` directly, so the goldens never exercised the registry, the
backends, or tree-sitter at all — they were green because the effective
pipeline was still the pre-refactor monolith.

- `pass-a-parity.test.ts` is the important one: for each of the 11
  golden fixtures it runs the real tree-sitter Pass A and asserts every
  Pass A node id exists in the enricher's set (`dropped === 0`). It
  guards against a vacuous pass by first requiring Pass A to have found
  real declarations, so a grammar that failed to load fails the test
  instead of trivially satisfying it. This test caught a real id
  divergence on `overloads` that typecheck, the goldens and a
  clean-room binary run all missed.
- `reconcile.test.ts` covers the matched/added/dropped arithmetic
  directly — pure and DB-free.
- `registry.test.ts` covers per-path backend routing and the
  `backends.<id>.enabled` / `.enricher` config overrides.
- `php/backend.e2e.test.ts` indexes a PHP file through `openProject`
  and asserts real symbol nodes (not a lone `file` node), `resolved`
  state without an enricher, and the provenance canary: at least one
  node carrying `provenance: "tree-sitter"`, which was zero for every
  language before this refactor.
- `tree-sitter/parser.test.ts` covers Pass A `contains` connectivity and
  provenance stamping for both TS and PHP.
- The glob tests gain a `.php` case and one proving the scanner honours
  the extension list it was constructed with.

No `__golden__/graph.json` was modified — the final graph is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The harness could not answer the question the refactor exists to answer.
There was no way to compare a tree-sitter-only arm against
tree-sitter + enricher: `grep -rn backend eval/` returned nothing, and
the runner always minted a throwaway temp DB, so it could not even be
pointed at an existing index.

- Arms are first class (`ArmReport`), selectable per run and reusing an
  existing index, so two configurations can be diffed side by side.
- `meta` is no longer discarded. Coverage, `partial` and notes are
  scored and reported, so an index that resolved 10% of its edges can no
  longer post the same number as one that resolved all of them by
  getting lucky on name matching.
- `payloadSymbols` is reported per case and in aggregate. It was
  computed and silently dropped before, which is exactly the metric that
  catches an enricher buying recall with an enormous answer.
- One scorer for every API. `scoreNodeListCase` was a near-duplicate of
  `scoreNodeList` with the MRR line removed, hard-zeroing MRR for
  callers/callees/trace and mechanically depressing the headline number.
- Expectations can pin a file and kind, not just a bare name, so a case
  can no longer be satisfied by any same-named symbol anywhere.
- The two callers/callees cases expected the queried symbol among its
  own callers — true only under recursion, so they scored 0 by
  construction and dragged the mean-recall exit gate.
- `impact` gains cases; it is the headline tool and had none.
- The runner is importable rather than top-level script code, and
  `tsconfig.eval.json` puts `eval/` under typecheck for the first time —
  the root config only covered `packages/*/src` and `apps/*/src`, which
  is why a broken harness could go unnoticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`curl … | sh` is the documented install path, and it aborted on
Debian/Ubuntu: the script carried `set -euo pipefail` under a bash
shebang, but piping into `sh` ignores the shebang and dash has no
`pipefail`. It only ever worked on macOS by accident.

- The installer is strict POSIX `#!/bin/sh` and lives in exactly one
  place, `apps/site/public/install.sh`, which is the copy actually
  served. The second, consumer-less copy under `scripts/` had no sync
  mechanism and was free to drift.
- Checksum verification can no longer be skipped by accident. curl
  creates its `-o` target before evaluating HTTP status, so a 404 left a
  zero-byte SHA256SUMS, the "did we get one" check passed, the expected
  hash came out empty, and the script installed an unverified binary
  printing no warning at all. Missing or unmatched checksums now abort;
  `ASTROGRAPH_SKIP_CHECKSUM=1` is the loud, explicit override.
- Rosetta is detected, so an arm64 Mac in a translated shell stops
  installing the x64 binary. The install is verified by running the
  binary afterwards.
- `release.yml`: `workflow_dispatch` checks out the tag it publishes
  instead of building the default branch under someone else's version
  number; the release version is validated against the tag; the build is
  reproducible (`--frozen-lockfile`, pinned bun).
- `ci.yml` exists at all. Nothing ran tests, typecheck or lint before a
  tag until now.
- `build:all` reproduces the release matrix locally, and `install:local`
  matches what the installer does instead of a plain `cp`.

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

The docs described a product that no longer exists: JS/TS-only, the
TypeScript Compiler as *the* engine, multi-language as a non-goal, and
installation only from source. Several claims were not merely stale but
false against the shipped code.

- `docs/extraction.md` splits into `extraction/{overview,tree-sitter,
  typescript}.md`; the old path stays as a redirect.
- `docs/install.md` and the site install page are new, and document what
  the installer actually does: platforms, real asset names, checksum
  behaviour, every environment variable, and the three-way distinction
  between removing the binary, `astrograph uninstall` (host config and
  agent guide) and `astrograph uninit` (the project index).
- `contracts.md` matches `types.ts` again: `Language` is genuinely open,
  `KnownLanguage` lists what actually ships (PHP included — it appeared
  in no document at all), and `Parser`'s argument order agrees with the
  code. `graph-model.md` defines `resolved` per backend, so a language
  with no enricher is no longer excluded from ever reaching it by
  definition.
- The landing page loses "Depth over breadth, on purpose" and "every
  edge resolved by the type-checker" — the pre-pivot thesis stated as
  fact on the most public surface, and false for any language without an
  enricher. Remaining unqualified "all languages" claims in the README
  and site spec now say "any language with a backend — JS/TS and PHP
  today".
- `SKILL.md` states plainly that Python, Go, Rust and Java have no
  backend, instead of implying a graph exists for them.
- `docs/testing.md` loses three dead links into anchors of the deleted
  monolith and gains the multi-backend and id-parity coverage the suite
  now has.

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

Magento-style whitelist gitignores no longer drop git-tracked sources; callers/callees/trace/impact admit when a backend cannot produce needed edges; TypeScript default libs and out-of-root paths are never persisted as external nodes.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add a name-resolution enricher for PHP: alias→FQN + project FQN index drive extends/implements, file-level imports edges (replacing dead leaf import nodes), and type_of/returns from DI-style type positions — never bare-name matching.

Co-authored-by: Cursor <cursoragent@cursor.com>
Reuse one tree-sitter Parser/Tree across Pass A, FQN index build, and heritage so the enricher stops re-reading and re-parsing the same sources before call resolution lands.

Co-authored-by: Cursor <cursoragent@cursor.com>
Lock honest calls/instantiates design: intra-class type table, extends walk with incomplete-chain external, and Magento non-goals.

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep Pass A as node authority, stream PHP trees so Magento-scale indexes stay O(1) RAM, emit PHP calls/instantiates, and close CommonJS plus export-star edges. Align docs, site, and the canonical installer URL for the v0.1.0 cut.

Co-authored-by: Cursor <cursoragent@cursor.com>
…and MCP

Move `AstrographConfig` parsing and normalization into `packages/core/src/config.ts`,
a module with no filesystem, registry, or Bun dependency: callers discover their
available backend IDs and pass them in. CLI and MCP now normalize through the same
parser and preserve its diagnostics instead of each interpreting the file.

Known issue, not introduced by this commit's intent: `commands/shared.ts` declares
`InvalidCliConfigJsonError extends CliError` at module scope, and `shared.ts` is
inside the `cli.ts -> commands/* -> shared.ts -> cli.ts` import cycle. `extends`
evaluates eagerly, so `CliError` is still in TDZ and importing the CLI throws
"Cannot access 'CliError' before initialization". Fix by extracting `CliError` to
a leaf module before this branch merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…03, DEV-007)

A backend now either has no enricher or exactly one complementary enricher, and
nothing can bypass the structural floor.

- `EnricherMode` is the single literal `"complement"`. `replace` and object-valued
  `none` are removed; Pass-A-only is `LanguageBackend.enricher === undefined`.
- Delete the `skipPassA` branch: `parser.extractNodes` runs once for every eligible
  claimed file, with or without an enricher.
- `Enricher` gains required `id` and `provenance`. `indexFileReconcile` stamps
  `enricher.provenance` instead of hardcoding `"ts-compiler"`, which was actively
  wrong for PHP (its rows are tree-sitter facts).
- `LanguageRegistry` validates registration and throws `BackendRegistrationError`
  on duplicate backend ids, an extension claimed twice, empty/duplicate/unknown
  edge kinds, capabilities missing `contains`, a Pass-A-only backend advertising
  enricher-only kinds, or an enricher missing id/provenance.
- `versionKeys()` contributes `extraction:contract`, so pre-existing indexes rebuild
  rather than mixing row generations.

`resolveEdges()` keeps its current shape on purpose: splitting node enrichment from
edge resolution needs streaming and per-file lifetime, which belong to DEV-008/DEV-013.
ADR-002 records the implemented interface and why `enrichNodes()`/`dispose()` are deferred.

New `extraction/backend-contract.test.ts` drives an Indexer over stub backends:
Pass-A-only reaching `resolved`, exactly one Pass A per file, non-TypeScript producer
provenance, and a Pass A node the enricher omits being kept with PASS_A_NODE_DROPPED.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ning step (AG-104, DEV-016)

`bun run docs:check` (`scripts/docs-check.ts`) statically verifies the document set
is internally consistent and navigable. It does not generate prose from code and it
does not judge whether a claim is true.

Checks: local links and anchors (code fences and backticked spans are masked, so a
backticked path is a citation and not a link); architecture metadata (H1, Status from
the document vocabulary — ADRs use decision status — Baseline SHA, Canonical owner);
code and Mermaid fence balance; duplicate deviation IDs and DEV briefs with no register
row; the required-document floor and architecture-index reachability.

Findings in `*.es.md` are warnings: the architecture index already declares the Spanish
mirrors stale and non-canonical, so their drift stays visible without blocking a change
to the canonical English set.

CI runs it with `--warn-only` and `continue-on-error`, because the repository carries
pre-existing debt: stale `../../codegraph/src/**` links in docs/graph-model.md and
docs/tools.md, and a prose `Status:` line in configuration-and-invalidation.md.
Cutover to a gate is a single documented change — when `docs:check` reports 0 errors
on main, drop both flags from the workflow step.

Also adds docs/architecture/documentation-checklist.md (changed-path to canonical-document
table, per-PR checklist, suppression policy, cutover rule), a PR template, and
tsconfig.scripts.json so `bun run typecheck` covers the guard itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, NEW-003)

The roadmap called the same cut both "v1.0 stabilization" and "tag v0.1.0 after
merge". There is now one interpretation: v0.1.0 is a public preview, and v1.0.0 is
reserved for the first stable public contract with stated entry conditions.

- ROADMAP.md §0 fixes release identity: scope (JS/TS + PHP, single-app), public
  preview surfaces, what is not an SDK, 100% local, index policy, and what is out
  of the 0.1/0.2 roadmap. Collateral "v1.0" wording removed throughout.
- contracts.md §12 enumerates public-preview vs experimental vs internal surfaces
  and the patch/minor/major rules. The SQLite schema, the private @astrograph/*
  packages, and backend internals are explicitly not a public contract.
- CHANGELOG.md, SECURITY.md, CONTRIBUTING.md. SECURITY is scoped to a local binary:
  the index containing your code is by design, not a leak. CONTRIBUTING records the
  policy that tests are run by a human, never by an agent.
- release.yml gains a `gates` job that runs typecheck, biome, tests, the docs guard,
  installer shellcheck, and a version-identity check against the exact tagged commit;
  build and release depend on it. A green run on main was never evidence about a tag:
  CI does not trigger on tag pushes and a tag can point elsewhere.
- Index compatibility policy plus the minimal guard that makes it true:
  runMigrations throws IncompatibleIndexError when the database reports a schema
  version above LATEST_SCHEMA_VERSION. Migrations only move forward, so a
  best-effort open would read rows whose meaning changed behind a clean coverage
  banner. Full migration/rebuild-with-swap stays with NEW-007.
- Upgrade, pinning, rollback, and incompatible-index recovery documented in
  docs/install.md; exact-tag verification and index identity in distribution.md.

Renaming roadmap headings broke three inbound anchors; docs:check caught them and
they are repaired. The cross-project `codegraph/src/**` references in graph-model.md
and tools.md are now named rather than linked, since that project is not vendored
here. Documentation debt is down to one error, owned by DEV-006.

No tag, no release, no published binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n review (AG-106)

The AG-103 contract change left four documents describing a world that no longer
exists, and two deviation rows claimed closure on evidence nobody produced.

- glossary.md still defined "Replace" as an "implemented mode that skips Pass A".
  Removed; the "None" row now says it is a status projection, not a mode object.
- tree-sitter-pass-a.md listed DEV-007 as a live deviation that lets `replace`
  bypass Pass A. Removed.
- ADR-001 listed DEV-007 among its current deviations. It no longer applies.
- tools.md described BackendStatus without `enricher` or `capabilities.edgeKinds`,
  both of which the type has and `--json` returns. The human-readable `status`
  output only summarizes `id[languages]`; that is now stated.
- code-map.md had no inventory owner for scripts/docs-check.ts.

Deviation closure evidence: DEV-007 was marked `closed` and DEV-016 `resolved`,
but no user has run the verification commands. An implementation agent must not
run the suite, so it cannot produce that evidence. Both rows drop to
`implemented, awaiting verification`, and the register now documents the rule:
open -> implemented, awaiting verification -> closed, where the last transition
requires a user-reported result. A row that skips the middle state is a claim
without evidence.

None of this is caught by docs:check — it verifies that documents are navigable
and well-formed, never that a claim is still true. That is the half the
documentation checklist exists for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-106)

Importing the CLI threw "Cannot access 'CliError' before initialization" for
every invocation, so the binary was dead: `--version`, `--help`, every command.

The cycle `cli.ts -> commands/* -> shared.ts -> cli.ts` had always existed and had
always been harmless, because every consumer referenced `CliError` inside a
function body. `commands/shared.ts` then declared
`class InvalidCliConfigJsonError extends CliError` at module scope. An `extends`
clause is evaluated eagerly during module evaluation, and cli.ts's own body has
not run yet at that point, so the binding is still in its temporal dead zone.

The result vocabulary (`CliContext`, `CliRunResult`, `CliError`, `ok`,
`failOnPartial`) moves to `packages/cli/src/result.ts`, a module that imports
nothing. Commands import it directly; `cli.ts` re-exports it so any remaining
`from "../cli"` keeps resolving. Re-exporting alone was not enough — a re-export
from a module that is itself mid-evaluation is still unresolved — which is why
the consumers were repointed at the leaf.

This removes the hazard for every future subclass instead of patching the one
occurrence.

Also validates `daemon.json` instead of casting the parse result to
`DaemonMetadata`. A truncated or hand-edited file could previously flow in and
surface later as `pid: undefined` in `status` — the same unvalidated-JSON class
of bug the shared config parser removed for `.astrograph/config.json`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three test files failed `bun run typecheck`, so the release gate could never pass.

- core/config.test.ts: `test.each` infers its rows as const, which made the
  expected literals `readonly` and no longer assignable to the mutable
  `NormalizedAstrographConfig`. Annotated the rows with an explicit `ParseCase`.
- cli and mcp config tests: `fixture.diagnostics[0]?.message` is
  `string | undefined` and `toContain` takes a `string`. Bound the first
  diagnostic with an explicit guard instead, which also makes the test fail
  loudly if a fixture ever loses its diagnostics rather than asserting on
  `undefined`.

`bun run typecheck` now exits 0 for every workspace plus eval and scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- MCP formatters used `arr.forEach((x) => lines.push(...))`, whose concise body
  returns `push`'s length. Block bodies now say "statement, not projection".
- The site starfield keyed decorative elements by array index and had SVGs with
  no accessible name. Stars carry a deterministic id from their generator seed,
  edges and nodes key on their own coordinates, and the two purely decorative
  SVG layers declare `role="presentation"` inside the already `aria-hidden` wrapper.
- Two extraction fixtures must violate a rule to do their job: the overload
  fixture exists precisely to declare duplicate class members, and the JSX
  fixture is a minimal component rather than accessible UI. Suppressed by a
  narrow `biome.json` override scoped to `packages/core/__fixtures__/**`, with
  the reason recorded in the DEV-017 register row.

`noNonNullAssertion` stays a project-wide warning by existing configuration and
is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… DEV-016)

Documentation debt reached zero errors, which was the documented cutover
condition, so `--warn-only` and `continue-on-error` are removed from both the CI
job and the release gates. A pull request that breaks a link, drops architecture
metadata, leaves a fence open, duplicates a deviation ID, or removes a required
document now fails the build.

The last blocking finding was `configuration-and-invalidation.md`, whose
`Status:` line was prose rather than a vocabulary word. It is `mixed`; the
nuance ("shared parsing adopted, convergence invalidation pending") moves into
the Purpose section where it can cite DEV-003 and DEV-006.

Findings in the stale `*.es.md` mirrors remain warnings by design: they are
declared non-canonical and must never gate a change to the English set.

DEV-017 moves to `implemented, awaiting verification` — typecheck and check are
green, but no user has run them yet, and an implementation agent cannot supply
that evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… drop evidence

Two failures, one self-inflicted and one a real defect the new test caught.

**Reformatted fixtures (5 golden failures).** A repo-wide `biome check --write`
in the previous commit rewrote extraction fixtures, which are *parser input*,
not shipped code:

  basic/sample.ts        let currentRetry -> const   (variable becomes constant)
  functions/sample.ts    function expression -> arrow
  jsx/sample.tsx         import React -> import type React
  exports/sample.ts      re-export line removed
  imports/barrel/index.ts

Every one changes what the extractor sees, so the goldens were right to fail.
The sources are restored byte-for-byte and `packages/core/__fixtures__/**` now
disables the formatter, the assist actions, and recommended lints, so no future
sweep can silently edit an extraction input. Verified: `biome check --write` over
the fixtures leaves them unchanged.

**PASS_A_NODE_DROPPED was never persisted.** `mergeErrors` stripped any existing
`PASS_A_NODE_DROPPED` before appending, so Pass B phase 1 wrote the warning and
phase 2 immediately erased it — `result.errors` never carries that code. The row
survived, the evidence did not, for every backend including TypeScript. That
contradicts the DEV-007 invariant that an identity mismatch stays observable.

Stripping is now opt-in via `replaces`, used only by the reconcile phase that
recomputes the set. Merged evidence is also de-duplicated, because a file that
is both changed and a referrer runs the Pass B phases twice in one pass and
would otherwise stack identical warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pending`, `parsed`, and `resolved` answered "how far did the pipeline get".
Nothing answered "is what we know good enough to trust this answer", so a file
whose grammar was unavailable reached `resolved` and reported as complete. That
is how a graph produces a confident empty result.

Adds `packages/core/src/diagnostics.ts`: one versioned, exhaustive registry
mapping every emitted code to a category — `coverage_gap`,
`semantic_uncertainty`, `configuration`, `diagnostic` — plus an independent
`degradesCompleteness` flag. The two axes are deliberately separate:
`PASS_A_NODE_DROPPED` is a real backend defect that costs the user nothing
because the Pass A row is kept, while a configuration exclusion is not a defect
but does hide content.

Exhaustiveness is compile-enforced. `ExtractionError.code` is now the
`ExtractionDiagnosticCode` union and the registry is `Record<DiagnosticCode, …>`,
so a code that is emitted but unclassified fails to typecheck. Writing it that
way immediately caught three config codes I had missed. Nothing infers behavior
from an error `message` any more; the code is the contract.

Storage gains the primitives that query the two axes separately:
`getFilesWithCoverageGap()`, `getFilesWithDiagnosticCategory()`, and
`getDiagnosticCounts()`. `status` returns lifecycle and trust side by side.
`DIAGNOSTIC_REGISTRY_VERSION` feeds `configHash`, so re-categorizing a code
rebuilds instead of leaving persisted rows meaning something else; the category
is derived rather than stored in a column precisely so that stays out of the
migration path.

Stale diagnostics clear by re-derivation, not a sweep: `writeParsedFile` already
rewrites the whole record whenever Pass A runs, so a file that stops being
oversized loses its `FILE_TOO_LARGE` evidence in the same transaction that gives
it real nodes.

Whether a gap actually reaches a given query is the query domain's decision and
belongs to AG-206; this ticket only establishes that the gap is knowable.

Contracts §10.1, storage-and-graph-model, and ADR-003 updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eligibility was answered in four places with different rules: the scanner
applied include/exclude/gitignore, `indexFilePassA` re-checked the size limit and
backend ownership, `beginPass` re-derived ownership by walking the registry
again, and coverage counted whatever happened to be persisted. That is how an
oversized file was excluded from Pass A while still sitting in the backend's
`loadProject` set, and how a disabled backend left rows that queries kept
answering from.

`packages/core/src/eligibility.ts` computes one `Membership` snapshot per pass —
sorted by path, independent of backend registration order and SQLite row order —
and `indexAll`, `sync`, `syncFiles`, `beginPass`, Pass A and both Pass B phases
all read it. `grep maxFileSizeBytes packages/core/src/indexer.ts` and
`grep backendForPath` are now both empty: the indexer no longer has an opinion
about membership.

Design decisions worth stating:

- The scanner keeps owning include/exclude/.gitignore. Membership records the
  outcome as `out_of_scope` rather than re-implementing the matching, because a
  second matcher is exactly the second definition this ticket removes.
- `backend_disabled` is distinguished from `no_backend` via a static shipped
  extension table, since `createDefaultRegistry` drops a disabled backend
  entirely. "PHP is turned off" is actionable; "nothing reads .php" is not. A
  registry test builds the default registry and asserts the table has not
  drifted.
- `out_of_scope` writes no file record. A row would make the graph claim
  knowledge of a file the project does not contain. `too_large`, `no_backend`
  and `backend_disabled` do write one, carrying the AG-201 codes that mark them
  as coverage-degrading.
- `Pass B` entry points resolve their backend through `eligibleBackendFor()`, so
  an ineligible path cannot reach `resolveEdges` or reconciliation even if a
  caller passes it in directly.

Retirement of files that lost eligibility is deliberately not here: `sync` now
reports them as removals, but making a reused full index converge is AG-203.

Contracts §13, indexing-pipeline and code-map updated. DEV-002 moves to
implemented, awaiting verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-003)

`indexAll` only ever added and updated. A file that was deleted, newly excluded,
grew past the size limit, or belonged to a backend the user just disabled kept
its rows and kept answering queries. A reused database was therefore a function
of its history, not of the project.

The full index now retires everything the current membership rejects, before
Pass A, through `retireFile()` — the same policy both sync paths use, extracted
rather than duplicated. Retirement captures the edges pointing at a file's nodes
before deleting them and marks those edges unresolved; deleting first would
either dangle them or silently drop a relationship that still exists in source.

Identity is written last. `passState: "in_progress"` is set before any mutation,
and `configHash`, the version keys and `passState: "complete"` land together in
one transaction at the end. A crashed pass therefore cannot present itself as
current: `status.indexInterrupted` reports the mixture, and the next pass forces
Pass A instead of trusting content hashes a half-written run may have left.

`normalizeIndex()` is the comparison boundary for the convergence claim. It
drops exactly the volatile fields — `updatedAt`, `indexedAt`, `modifiedAt`, and
the autoincrement `edges.id` — and keeps `contentHash`, `state`, `nodeCount` and
sorted `errors`. Dropping more would let a real divergence pass; keeping the
errors means a converged index has to agree about *why* a file is incomplete,
not merely that it exists.

`convergence.test.ts` asserts
`normalize(indexAll(emptyDb, state)) == normalize(indexAll(previousDb, state))`
across delete, edit, scope loss, a tightened size limit, and backend
enable/disable, plus interruption detection and recovery.

The delta half of ADR-004 — scanner sync and event sync producing the same
normalized graph — is AG-205, and backend-owned invalidation is AG-204.

DEV-003 moves to implemented, awaiting verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…AG-204, DEV-001)

`healUnresolvedEdges` mapped a bare `node.name` to one node id and resolved
every unresolved edge whose `targetName` matched. Adding a `save()` method
anywhere could promote a `save()` call in an unrelated class — or in another
language entirely — and present the fabricated edge as `resolutionState:
"resolved"`. A wrong edge asserted confidently is worse than no edge.

It is gone, and so is `getEdgesByResolutionStateAndTargetName`, the storage
primitive that made it expressible. Nothing in production now promotes an edge
from a name; a test asserts the method is absent so the behavior cannot be
rebuilt by accident.

In its place, core supplies facts and backends decide:

- `InvalidationInput` carries added/modified/removed, the identities that
  existed before this pass, whether configuration changed, and
  `dependentsOf`/`dependenciesOf` computed from *recorded edges*, never name
  similarity.
- Identities are captured before retirement. After deletion the evidence is
  gone, and a backend cannot reason about what a removal broke.
- Each backend returns only its own files. Core filters the result through
  membership and ownership rather than trusting it, so a greedy or buggy backend
  cannot schedule work on another language.
- TypeScript derives dependents from module and type semantics; PHP from FQNs,
  `use` aliases and inheritance, rebuilt on the next `loadProject`. Neither can
  return a path it does not own.
- Omitting `invalidate` selects a conservative, still evidence-based default.

Deliberate behavior change: `resolver.test.ts` previously asserted that adding
`src/later.ts` resolved an unimported `laterFn()` call in another module.
TypeScript cannot connect those, so neither may Astrograph — the test now
asserts the edge stays `unresolved`, and a companion test proves an *imported*
symbol does resolve, through the backend rather than a name match. Per contracts
§12.4 this is a correctness fix, and it is in the changelog.

Contracts §14, incremental-sync and the deviation register updated. DEV-001
moves to implemented, awaiting verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…econciler (AG-205)

`indexAll`, `sync` and `syncFiles` were three implementations of the same idea
with different orderings, different removal rules, and different metadata
behavior. `syncFiles` never refreshed `configHash`, which made a watch-driven
index a second kind of index whose freshness depended on which entry point last
ran.

They now differ only in which paths they nominate as candidates — every eligible
file, every eligible file, or the coalesced event batch — and share `runPass()`,
which always executes the same seven steps in the same order:

  1. classify membership          (once, by the caller)
  2. capture invalidation evidence (before anything is destroyed)
  3. retire what no longer belongs
  4. Pass A over changed files
  5. load each backend's project state
  6. re-resolve the affected set
  7. persist the identity

Steps 4 and 5 were previously inverted. `beginPass` called `loadProject` before
Pass A, so a backend built its project view from the *previous* pass's persisted
rows; PHP only worked because it builds its name index lazily on first resolve.
`beginPass` is split into `startPass` and `loadBackendProjects` so the ordering
is explicit rather than accidental.

Event coalescing is now deterministic: `unlink` dominates within a batch.
Watchers do not guarantee arrival order, so "last event wins" let a stale
`change` resurrect a deleted file. `syncFiles` re-checks existence afterwards,
which is what still allows a genuine delete-then-recreate. A rename arrives as
`unlink` + `add` on two paths and needs no special case. An event batch never
retires a path it did not mention — silence is not deletion, and treating it as
such would empty the graph on the first single-file save.

`convergence.test.ts` now asserts all four routes agree:

  normalize(cleanFull) == normalize(reusedFull)
    == normalize(scannerSync) == normalize(eventSync)

across add, modify, remove, rename and a size-limit change, plus coalescing in
both arrival orders.

Dead code removed with the merge: `retireLostMembership`, `uniqueStrings`, and
the duplicated delta-classification loops.

ADR-004 and incremental-sync updated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n (AG-206, DEV-004)

`partial` was computed from the files a query happened to return. That is
circular: a reverse lookup that finds nothing returns no files, reports complete
coverage over the empty set, and presents "nothing calls this" with
`partial: false` — the most dangerous claim the graph can make.

Each tool now declares a domain (contracts §15): `global_discovery`,
`global_reverse`, `global_path`, `local_outgoing`, `explicit_scope`, or
`descriptive`. `buildMeta` evaluates three things over that domain — lifecycle
coverage, AG-201 trust diagnostics, and backend capability — so:

- a `resolved` file whose grammar was missing makes a global answer partial,
  because finishing the pipeline is not the same as having the content;
- a genuinely local `callees` answer stays complete despite an unrelated pending
  file elsewhere;
- an incoming question consults every backend that owns files, since a PHP
  caller of a TypeScript symbol would be invisible, while an outgoing question
  consults only the source's backend;
- a backend registered but holding no files penalizes nothing — disabling PHP in
  a pure TypeScript repository must not degrade every result.

Two capability evaluators became one. The old per-node `capabilityNotes` picked
whichever backend claimed the node's language, which answered the outgoing
question even for reverse lookups. It is deleted along with `isFullyResolved`.

Causes are structured. `PartialReason` distinguishes `coverage_incomplete`,
`capability_unsupported`, `search_truncated` and `semantic_uncertainty`, and the
CLI formatters now read `reason.kind` instead of substring-matching
`"produces no"` in prose — inferring behavior from message text is the exact
failure AG-201 removed for diagnostics. `notes` is derived from `reasons` in
order, so CLI and MCP render identical facts from one source.

`trace`'s negative branch is marked truncated: a bounded traversal that found
nothing is not the same claim as an exhausted one.

ADR-003 records the implemented shape. DEV-004 moves to implemented, awaiting
verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…AG-207, DEV-005)

Node-shaped payloads filter `edge.target === null` because there is no node to
render. That is reasonable for the payload and disastrous for the metadata: an
empty `callees` looked identical whether the symbol calls nothing or calls three
things nobody could resolve, and "no path found" never said what blocked it.

`collectEvidence` now runs *before* that filter and its result travels in
`ToolMeta.evidence`: exact counts by resolution state and edge kind, plus a
bounded sample carrying source, kind, target name, location, provenance, and the
producer's reason when it recorded one. Applied to `callees`, `callers`,
`getNode` previews, `impact`, `context`, and both `trace` branches — the negative
branch reports the unproven relations reachable within the traced depth, since a
negative answer that cannot explain itself is not honest.

Three deliberate boundaries:

- **`external` is not a failure.** A call into `node_modules` is a complete
  answer about a target outside the project. It is counted, but it never counts
  as an unproven relation and never makes an answer partial.
- **Unproven does not automatically mean partial.** The caller declares whether
  evidence is material for its question; an unresolved edge somewhere is not a
  defect in every answer.
- **Nothing sensitive travels.** The projection is a closed set of fields; no
  source snippets, and a test asserts the sample's key set exactly so extra
  metadata cannot leak in later.

Counts are exact and never truncated; samples are capped at 10, ordered by
state, kind, target name, source, then location, and truncation is stated rather
than implied. A test asserts identical output for reversed input.

Contracts §16 and query-and-honesty updated. DEV-005 moves to implemented,
awaiting verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records what AG-201 through AG-207 changed, the nine failure modes the stage was
supposed to eliminate, and the mechanical audit that answers each — so a
reviewer can re-run the checks rather than take the claim on faith.

Audit results, all re-runnable from the document:

  1. two eligibility definitions        none outside eligibility.ts
  2. lifecycle used as trust            3 hits, all edge state, no file.state
  3. separate full/sync/event branches  3 callers, one runPass
  4. name-based healing                 no lookup exists in storage
  5. cross-language relations           filtered by producer and re-filtered by core
  6. partial without explicit domain    11 of 11 meta calls declare one
  7. target-null before evidence        8 sites, all collect first
  8. CLI vs MCP metadata                identical fields
  9. convergence claimed without proof  0 closed rows, 8 awaiting verification

No P0/P1 survived. Three problems were found during the stage and fixed inside
it rather than deferred: two capability evaluators disagreeing about direction,
CLI formatters detecting capability gaps by substring-matching prose, and
`loadProject` running before Pass A so backends saw the previous pass's rows.

Two deliberate behavior changes are called out with their compatibility
justification: an unproven call no longer resolves because a same-named
declaration appeared, and `partial` is true more often on global questions
(while a genuinely local answer became less partial).

The exit gate is explicitly unmet. Every item requires the owner to run the
command and report the result; an implementation agent cannot supply that
evidence, which is why the eight deviation rows stay at
`implemented, awaiting verification` rather than `closed`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chofito and others added 24 commits September 1, 2026 14:59
…AG-204/AG-206)

Six reported failures: one real defect, one obsolete expectation, one contract
change the fixture had not caught up with, and three bugs in my own test helper.

**Real defect — an added module never reached its importer.** `dependentsOf`
answers from recorded edges, and an importer written *before* its module exists
holds an unresolved `imports` edge with a null target. Nothing points at the new
file, so `src/consumer.ts` was never re-resolved when `src/later.ts` appeared and
the import stayed unresolved forever. That is the ordinary way people write code.

`InvalidationInput` now carries `ownedFiles`, and both shipping backends
re-resolve their whole set when a file is added or removed or configuration
changed, using recorded dependents only for content-only edits. Adding or
deleting a module changes the compiler's module graph and PHP's FQN index in
ways the edge table cannot express. Deliberately conservative: narrowing it is
affected-set optimization, and being conservative costs time while being narrow
costs answers.

Verified out-of-band, not by the suite: the imported symbol now resolves, and the
*unimported* same-named symbol still does not — the stronger result, since
`consumer.ts` is now genuinely re-run through the TypeScript resolver and the
compiler still cannot prove the target.

**My test helper snapshotted the filesystem.** `invalidation.test.ts` built an
absolute-keyed copy of the project, so later edits were invisible to the indexer
and three tests "changed" a file that never changed. The scanner and filesystem
now read one live record.

**Obsolete expectations, both encoding behavior this stage removed:**

- `resolver.test.ts` asserted no edge at all pointed at `laterFn`. But
  `src/later.ts` legitimately owns `contains` and `exports` edges into its own
  declaration; those are not the question. The assertion is now scoped to `calls`.
- `graph-queries.test.ts` asserted `getNode` reports `coverage.total: 1`, scoping
  a reverse claim to the node's own file — exactly what AG-206 fixed. It now
  asserts the global domain, with a companion test proving `callees` stays
  complete when an unrelated file goes pending.
- `format.test.ts` supplied only `notes`, but formatters read `reason.kind` since
  AG-206. Rewritten around structured reasons, plus a test that rewording the
  detail cannot break the surface.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…209)

Five defects the AG-208 audit did not reach, found by re-reading the code
against the contracts rather than by running anything.

**1. A watch event could delete relations instead of demoting them.**
`syncFiles` named a file as a candidate, `runPass` skipped it because membership
now rejected it, and `recordIneligibleFile` then called `deleteByFile` directly —
so foreign keys cascaded the file's incoming relations away instead of demoting
them to `unresolved`, and their referrers were never re-resolved. A file that
grows past `maxFileSizeBytes` arrives at a watcher as an ordinary `change`, so
this was the common path, not a corner.

Losing eligibility is now a membership transition on every route: a path the
batch *names* is retired when membership rejects it, and `recordIneligibleFile`
goes through `retireFile()` before writing evidence. `eventScoped` still
protects files the batch never mentioned, and evidence records are scoped the
same way so a save no longer rewrites an unrelated oversized file's record.

A configuration change is treated as a project-wide fact: when the config hash
moves, `syncFiles` reconsiders the whole membership exactly as `sync()` does,
because a batch cannot speak for files it never heard about. Without that, the
four routes could not converge on a size-limit or backend-enablement change.

**2. A successful `trace` hid the destination's unresolved callees.** Evidence
was built from the resolved path while `destinationCallees` was projected
through a filter that drops target-null edges. The destination's relations are
now read once, evidence is collected before the filter, and the two edge sets
are merged rather than concatenated so counts stay exact.

**3. A failed `trace` could not see its own blockers.** `blockerEvidence` walked
`traverseGraph`, which discards a target-null edge *before* recording a visit —
structurally incapable of observing the hop that blocked the path. A focused
`collectPathEvidence` primitive collects each inspected node's relations before
deciding whether it can advance through them. `traverseGraph` is untouched, since
`impact`, `context` and `explore` depend on its current semantics. A negative
answer now distinguishes an exhausted search from a truncated one, and a blocker
one hop past `maxDepth` is absent rather than reported as examined-and-fine.

**4. A demoted edge could present a node id as a name.** Retirement filled
`targetName` from `edge.target`, a content hash, when the extractor had recorded
no name. The retired node's `qualifiedName` is now captured before deletion and
used instead; `edge.target` is never a fallback.

**5. `openProject` leaked its SQLite handle** when migrations, registry
construction or grammar loading threw after the database was opened — most
visibly when refusing an index written by a newer build. The handle is released
exactly once, and a close that itself fails cannot mask the original error.

The convergence fixture now emits real cross-file relations, resolved by the
stub backend from persisted Pass A rows the way PHP does. A backend that emits
no edges cannot demonstrate anything about retirement.

Documentation reconciled with the final code: `beginPass` replaced by `runPass`,
`startPass` and `loadBackendProjects`; the `targetName` healing sequence and the
"syncFiles does not persist metadata" claim removed from incremental-sync;
`docs:check` described as the required gate it is; DEV-017 no longer asserted as
failing. DEV-006 moves to `implemented, awaiting verification` — semantic
configuration convergence is now covered by code and tests. No row is `closed`;
that still requires results only the owner can produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ction

Three failures, all in the AG-209 tests themselves. The production fixes they
cover are unchanged and verified out-of-band.

**The fixture had its sizes inverted.** `src/caller.stub` is 15 bytes and
`src/target.stub` was 6, so a `maxFileSizeBytes: 8` limit retired the *caller* —
the opposite end of the relation from the one under test. With the caller gone
its outgoing edge went with it, so there was no demoted edge to assert on. The
target now carries padding to 25 bytes and the limits are named constants
between the two sizes, which makes the direction impossible to get wrong again
by editing a string.

**The `targetName` test was asserting through a rewrite.** It inserted a
`references` edge with no `targetName`, then removed the target file — but
removal makes the backend re-resolve its whole owned set, and
`indexFileResolveEdges` replaces a file's edges wholesale, so the manually
inserted edge was deleted before retirement's value could be observed. The test
now uses a backend that invalidates nothing, which isolates exactly what is
under test: the value `markIncomingEdgesUnresolved` writes. It also selected the
node by kind rather than by name and picked up a padding token.

Verified with throwaway harnesses rather than the suite: the retired target's
incoming `calls` edge is demoted to `unresolved` with `target: null`, its
`targetName` preserved, a `FILE_TOO_LARGE` record left behind and no dangling
edges; a demoted edge with no recorded name receives `src/target.stub::target`,
the qualified name, never the id `src/target.stub::function:target`; and clean,
reused, scanner sync and event sync all normalize equal across the size-limit
transition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One comparison boundary for every 0.1-C golden and convergence claim.
`testing/normalize.ts` was already the normalizer; what it lacked was a
statement of what it guarantees — and, it turned out, three ways to break
that guarantee.

Per-field stability rules for `Node`, `Edge`, `FileRecord`,
`ExtractionError` and `ToolMeta`, typed `Record<keyof T, FieldStability>`
so adding a field to a persisted shape fails to compile until it is
classified. Exactly four fields are removed, each with a written reason:
two wall-clock stamps, a filesystem mtime and a SQLite rowid. Everything
else survives — ids, resolution state, confidence, provenance, file state
and the full diagnostic list — because dropping a field to make two
outputs agree is how an oracle stops being one.

Three transformations did exactly that, and each now has a regression test
built from the counterexample that found it:

- `normalizePath` trimmed at the first `/node_modules/` *before* removing
  the project root, so `<root>/packages/a/node_modules/x` and
  `.../b/node_modules/x` — two distinct declarations in a monorepo —
  normalized to one path. Root removal runs first; the `node_modules`
  fallback now applies only to a path still absolute afterwards, which is
  the case it existed for.
- `stripRoot` was an unanchored substring replace over free text. With a
  root of `/tmp/ag`, `"in /tmp/agent/x.ts"` became `"in ent/x.ts"` —
  byte-identical to a genuinely different diagnostic. The bare-root
  replacement is anchored to a path boundary, so a sibling directory
  survives while `<root>: reason` is still stripped.
- `compareEdges` claimed to be a total order and was not. `metadata` is
  retained but was absent from the comparator, so two `ambiguous` edges
  differing only in their candidate sets were tied — and a tie is broken
  by SQLite row order, which is the non-determinism the comparators exist
  to remove. Canonicalized `metadata` is now the final key.

`omitUndefined` recursing into `metadata` was also reported as
destructive, and is deliberately left alone: `metadata` is persisted as
JSON, `JSON.stringify` omits `undefined` object values, and an oracle that
preserved that difference would report a divergence between an in-memory
graph and the identical graph read back out of SQLite. The rule is now
stated in the module — this oracle compares persisted meaning, and a
distinction SQLite cannot store is not a distinction — with a test pinning
both halves, including that `null` still distinguishes.

Node, edge, file and diagnostic orders are total. Query envelopes get
their own snapshot type rather than joining the graph one: `ToolMeta` is an
answer *about* the graph, computed per query, and folding it in would make
an envelope difference look like a graph divergence. `digest()` exists for
the corpus that cannot be committed and folds in `ORACLE_SCHEMA_VERSION`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extractor harness calls `TsExtractor` directly, so it proves what the
extractor computes and nothing about what the product persists: registry
routing, Indexer phase order, SQLite retirement and the query envelope are
all downstream of it (DEV-014). This harness runs the real composition
root — `openProject` — over a temporary project and reads the graph back
out of SQLite.

Six things are pinned, and one of them is subtle enough to have produced a
wrong answer that looked right. The clock, the project root (a fresh
`mkdtemp`, whose prefix the oracle strips), the project name, the
configuration and the database are the obvious five. The sixth is the
**type environment**: `ts.createProgram` had no `types`/`typeRoots`, and
TypeScript resolves automatic `@types` from the *process* working
directory rather than the project root it was handed — so running the
suite from this repository pulled `bun-types` and `@types/bun` into every
fixture's program. An `import { join } from "node:path"` in a project with
no dependencies came back `external`/`high` ("the declaration is known, it
is simply not yours") when the honest answer is `unresolved`/`low`. Every
temporary root now gets `HERMETIC_TSCONFIG`; real module resolution is
untouched, so a package genuinely installed inside a fixture's own root
still resolves.

Two composition hooks are added to `OpenProjectDependencies`,
`createRegistry` and `loadGrammars`, both optional and both defaulting to
exactly what `openProject` did. They exist for the AG-306 failure modes
that have no other deterministic trigger, and they are reachable from no
barrel: a consumer able to swap the registry could also claim capabilities
the shipped backends do not have. Documented in `project-lifecycle.md`.

Two snapshots per fixture, kept apart: persisted graph truth, and what
each probe claimed about its own completeness. `compare.ts` compares by
identity rather than by `toEqual` alone, so a failing row names the table,
the backend, the file and the field instead of handing over two
thousand-line JSON dumps. Row counts are deliberately not a comparison.

`biome.json` gains a narrower override: the existing
`packages/core/__fixtures__/**` exemption is for fixture *inputs*, which
must violate rules to be worth testing. This directory is real logic and
is linted and formatted like any other source.

The smoke fixture proves the harness can fail: four tamper tests
(a demoted edge, a dropped node and file, a flipped envelope, a missing
golden) plus release of the database and the temporary tree on success, on
a throwing body, and on a failed initialization.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixtures over the shipped TypeScript backend, sharing byte-identical
sources where they can so the only difference between two goldens is what
the configuration can prove.

`jsts-enriched` covers the matrix in one project, and covers it in all
four resolution states — which took a correction. It originally produced
`resolved`, `external` and `ambiguous` and no `unresolved` edge at all,
while the test that claimed to cover unresolved evidence asserted
`resolutionState !== "resolved"` and so accepted `external` without
noticing. `src/unprovable.ts` now adds the two constructs the compiler
genuinely cannot prove — a call on an `any` receiver, and a non-literal
`import()` — and every assertion names the exact state, the null target
and the `low` confidence rather than a set of acceptable states.

The merged function/namespace declaration is asserted as what it is: an
`ambiguous` reference that retains a target *and* lists both candidates.
Counting the two declarations, which is all the test used to do, would
have passed had a regression silently picked one.

`jsts-external-package` is separate because it needs an installed
dependency, and it is now the only fixture producing an `external` state.
It proves the other half of external targets: a declaration the compiler
resolves but the project does not own is persisted as a node with
`isExternal: true` and a root-relative path — the path matters, because
`isPersistableExternalNode` keeps an external node only when it lies
inside the project root, which is why a `lib.d.ts` declaration never
reaches the database and why this fixture's node id is identical on every
machine.

`jsts-pass-a-only` runs the same sources with the enricher off. It emits
`contains` and nothing else, a reverse question answers with
`capability_unsupported` rather than an empty list under a clean banner,
discovery stays complete, and its node ids are a subset of the enriched
ones — the Pass A subset contract, observed through persistence, where
breaking it would churn every id in the database. That check now has a
non-empty guard: an empty Pass A node set has no orphans either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four fixtures inside the frozen PHP STEP 3 scope. Their sources are the
existing reviewed extractor fixtures under `__fixtures__/php/`, read
straight off disk: copying them would create two sets of PHP inputs that
drift apart, and the point of DEV-014 is the same input producing an
extractor snapshot and a persisted, queryable answer side by side.

PHP resolution is receiver-aware and has no compiler to fall back on, so
the boundary is pinned from both sides. What must resolve: promoted,
typed, constructor-assigned and interface-typed properties, `self::`
statics, `new`, and the parent chain. What must stay unproven: a call on
an untyped variable, a method absent from the chain (with its
`PHP_CALL_UNRESOLVED` record), and a vendor parent — `external`, which is
a different claim from `unresolved`, because the receiver is known and
only its declaration is not ours.

`Worker::bucketOne` calls `run()` from three distinct receiver buckets,
and the test keyed a `Map` by `targetName`, which collapsed all three onto
one entry: it verified whichever edge came last and would have passed with
two of the three buckets broken. All three are now filtered, counted, and
required to land on a single target. Two negative assertions — no scalar
type edges, no resolved bare-name call — gained the positive guard they
were missing, since `filter(...).toEqual([])` is also satisfied by a set
that was never populated.

One fixture input is new, and it records a failure. `Casing.php` declares
`MixedCase` and type-hints it as `MIXEDCASE`. PHP class names are
case-insensitive, so that is a real in-project relation; the shipped
lookup keys are case-sensitive (DEV-009, open), so it is classified as
outside the project. Display casing survives correctly. The test asserts
the current behaviour deliberately and says so — it is the AS-IS evidence
DEV-009 was missing, and fixing DEV-009 must fail this test and its golden
and force a re-review. Nothing here endorses the outcome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One application, two languages, no bridge between them. The fixture is
built out of collisions: `process` and `invoke` are declared in both
languages, and each language contains a relation whose target name exists
only in the other one. A backend resolving by bare name — the easiest
wrong implementation, and the one NEW-004 forbids for 0.1.0 — would turn
those into resolved cross-language edges and the fixture would go green
while the product started lying.

Asserted: one owning backend per path; homonyms distinct, with distinct
ids because the id hashes the file path; zero edges whose source and
target languages differ; and each language resolving its own homonym, not
the other's. A capability reduction in one backend does not degrade the
other, in both directions.

**The product change.** AG-305's acceptance requires that every path have
one configured owner or one explicit ineligible reason, and it did not
hold. `openProject` built the scanner from `registry.allExtensions()`,
which omits a disabled backend, so with PHP switched off no `.php` path
was ever scanned; `buildMembership` then classified the persisted path
`out_of_scope` rather than `backend_disabled`, and `out_of_scope` is
deliberately not recordable. The rows were deleted, and a project whose
entire PHP half was unindexed answered **every query with
`partial: false`** — indistinguishable from a project that has no PHP.
`classifyPath` and `eligibilityEvidence` had implemented the honest path
all along, including the actionable "backend is disabled" message; nothing
could reach it.

The scanner now covers every *shipped* backend's extensions, enabled or
not. Scanning is all that changes: such a file is ineligible, is recorded
with zero nodes and an actionable reason, and reaches neither a parser nor
an enricher, so contracts §13 still holds. `indexableExtensions()` still
reports only the enabled backends, because that answers a different
question — what this configuration can index.

The reason it surfaces as is imprecise, and the test says so rather than
hiding it: `coverage_incomplete`, because coverage is what the unindexed
files move, where the honest reason is a capability limit.
`registry.summary()` lists only constructed backends, so `capabilityReasons`
cannot see a disabled one; fixing that needs a shipped-but-disabled
capability table and belongs to DEV-004/NEW-002.

Three vacuous assertions were also repaired. "PHP facts are gone after
disabling" was provable against a PHP half that had never produced any,
and "no relation crosses the language boundary" is true of an empty edge
set; both now guard on the facts existing first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six fixtures that put the pipeline in a state where it knows less than the
project contains, and pin two things: the persisted evidence saying why,
and what each query domain then claimed about itself. Every assertion
names a structured code or state, never message prose — a test on wording
fails on a copy-edit and passes on a re-categorization, which is exactly
backwards.

The matrix: an oversized owned file; an owned extension tree-sitter has no
grammar for; ambiguous and unresolved relations, which are different
claims with different evidence (ambiguous records a target *and* its
alternatives); an injected Pass A failure with an enricher behind it,
where the content is recovered and the diagnostic must survive anyway;
the same failure with no enricher, leaving a visibly known-and-empty file;
a grammar-runtime initialization failure, which is not a per-file
condition at all and refuses to open the project; and a Pass-A-only
project where nothing is broken and a relational question is honestly
unsupported.

Injection is confined to the composition seam. The grammarless backend
uses the real `TreeSitterParser` for an extension that genuinely has no
grammar, so the diagnostic comes from production code — mutating the
process-global grammar cache, the other way to reach that state, would
leak into every other test in the run.

Two fixtures cover the "no configured backend" row, because there are two
different classifications behind one code. `notes.txt` has no shipped
backend at all and needs an `include` entry to be scanned; `Service.php`
is claimed by a shipped backend that configuration disabled, and is
scanned regardless since AG-305 widened the scan to every shipped
extension. Both end as `NO_BACKEND` with zero nodes, and the *message* is
what separates "install a backend for this" from "re-enable the one you
turned off".

`failure-backend-disabled` changed meaning during review. It was written
to document a gap — the row was deleted and every query reported
`partial: false` — and now holds the AG-305 fix in place instead: the
record survives with its reason, global envelopes report the project
incomplete, and the local answer inside the healthy file stays complete.

`src/ok.ts` is in every fixture as that control: a `local_outgoing`
question inside it answers completely while the project around it is
degraded, so the suite rejects the shortcut of marking everything partial.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eighteen mutation rows, each run through clean full index, reused full
index, `sync()` and event-shaped `syncFiles()`, and each compared against
a clean index of the same final state through the AG-301 oracle.

One mutation-script format that every route consumes. The failure this
prevents is four independently authored expected graphs: if each route
carried its own expectation, a shared bug would be recorded four times and
called convergence. There is one expectation per row, and the watch batch
the fourth route receives is *derived* from the script — a hand-written
batch that happened to name an extra path would hide the bug the event
route exists to catch.

The matrix covers add, modify, delete and rename; both directions across
the size limit; an `exclude` change and, separately, an `include` change,
because a configured `include` replaces the scan glob while an exclude
subtracts from it and only one of those was covered; enricher enable;
backend disable and enable; JS/TS-only, PHP-only and mixed projects;
Pass-A-only in both languages; and an aborted first pass followed by
recovery and an ordinary edit.

That last row is a full four-route row rather than a standalone test.
`MutationScript.interruptedOn` crashes Pass A during the initial index,
then reopens with a working backend and proceeds, so recovery is held to
exactly the same expectation as every other row — which is what makes
"recovery" mean anything.

Two guards keep the matrix from going vacuous: every row must hold a
resolved cross-file relation at one of its two endpoints, and its two
endpoint graphs must actually differ. The relation is required at one
endpoint rather than both because a deletion row ends with it deliberately
demoted. That guard's exception for structural-only rows is gated per
backend rather than per row: an OR over both languages would have waived
the requirement for a mixed row's *enriched* half too, letting a
misconfigured row stop producing relations and still pass.

A refusal is an outcome, not a hole: a probe whose symbol was deleted
records its structured error code, so every route must reach the same
refusal instead of silently agreeing about a question none of them
answered.

Test timeouts are explicit. These rows do about two seconds of real
indexing each and were failing on CPU contention alone against Bun's five
second default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One way to update a production-pipeline golden, and it requires naming the
fixture.

`update-goldens.ts` validates every id against `manifests.ts` and refuses
an unqualified update-all: "update everything" turns a failing suite green
in one keystroke and produces a diff nobody can read, which is the failure
mode this exists to prevent. `--all` is available behind
`--i-reviewed-every-fixture` so the intent lands in shell history.
`--list` prints the vocabulary, an unknown id prints it too, and an
unrecognized option fails instead of being ignored. Every written path is
echoed, and output is deterministic: two-space JSON, trailing newline,
oracle key order.

Normal test runs are read-only by construction, not by convention. There
is no update branch anywhere in the read path — unlike the extractor
goldens there is no `UPDATE_GOLDENS=1` mode that can be left switched on,
and CI runs `bun test` and never the updater. A missing golden fails with
the exact command that records it.

The governance check now runs in both directions. Manifest → file caught a
fixture added without recording its expectation; the reverse, disk →
manifest, catches a renamed or removed fixture leaving an orphan golden
directory that nothing verifies and that would sit in the repository
looking like evidence.

Documentation: `testing-and-evaluation.md` becomes canonical for the
fixture layout, what is pinned and why, the implemented matrix fixture by
fixture, the four-route rows, the update guardrails, the review
expectations, and what building the fixtures found — two product defects
corrected, three oracle defects corrected, and the two gaps that remain
pinned as AS-IS. `docs/testing.md` §2.3 contrasts extractor and production
goldens so a reader of §2 does not conclude the extractor goldens are all
there is. `indexing-pipeline.md` names which fixture proves which of its
invariants and records the scanner change. `code-map.md` assigns the two
fixture families their owners.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two review rounds over AG-301 through AG-308, on deliberately separate
axes, with every finding recorded next to its disposition rather than
absorbed silently. Model agreement is not proof, and neither round is
treated as having cleared the range on its own.

Round 1 — repository standards plus a code-smell baseline, and the eight
ticket briefs — produced fourteen accepted fixes, four of them holes in
the evidence rather than style: a wrong claim that `NO_BACKEND` was
unreachable in production, no golden containing an external node, a
missing `ToolMeta` stability table, and three absent AG-307 matrix rows.

Round 2 came from an external reviewer's three themes — destructive
normalization in the oracle, falsely positive JS/PHP coverage, dishonest
ownership on a disabled backend — and all three were real. It found six
P1s that round 1 had not, five of them in work round 1 called clean:

- Two product defects. A disabled backend left no persisted evidence and
  every query answered `partial: false` over a half-unindexed project. And
  the harness resolved `@types` from the process working directory, so an
  unprovable import was reported `external`/`high` instead of
  `unresolved`/`low` — a fixture asserting that an unprovable target stays
  unproven was passing because the harness had made it provable, and the
  goldens encoded this checkout's installed type packages.
- Three oracle defects: a `node_modules` path rewrite that collapsed two
  monorepo declarations onto one path, an unanchored root strip that made
  two different diagnostics identical, and an edge order documented as
  total that was not.
- A JS/TS matrix row that produced no `unresolved` edge at all while its
  test accepted `external` in place of one.

One reported finding is rejected with its reasoning recorded: dropping
`undefined` inside `metadata` is correct, because SQLite cannot store that
distinction and preserving it would make an in-memory graph diverge from
the identical graph read back out.

Also found: six vacuous assertions that a positive guard now precedes, a
vacuity exception gated per row instead of per backend, a governance check
that ran in only one direction, and `biome.json` exempting the whole
fixture tree so four thousand lines of new harness logic were unlinted.

Two gaps remain, both pre-existing product behaviour the oracle now makes
visible rather than regressions: DEV-009 keeps AG-304's case-insensitivity
row half-met, and a disabled backend surfaces as a coverage gap rather
than a capability limit, which needs a shipped-but-disabled capability
table and belongs to DEV-004/NEW-002.

Verdict: READY_FOR_USER_VERIFICATION. No P0, and the oracle and
convergence matrix are green. But it took two rounds to get there and the
second round's themes came from outside this session, which is the honest
reading of this stage's maturity. DEV-014 stays open, and so does 0.1-C,
until the owner runs the commands in the review document and disposes of
the two remaining gaps and the initialization seam.

The handoff to 0.1-D is narrow: performance work may change how the
pipeline spends memory and CPU, and may not change what it persists or
what it claims.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Replace the TypeScript-compiler-backed core and its oracle/golden machinery
with a small pipeline: scan (git ls-files) → tree-sitter extract per file →
SQLite → link references by imports, namespaces and declared types.
CLI and MCP share one tool catalog; the MCP contract (astrograph_* tools,
serve --mcp) is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… add tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…resh skill, site and CI

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tion lives

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
They were stopwords, so "how does UserRepo find users" could never surface
UserRepo.find. Also let agents run the test suite.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Bun 1.4.2 in CI and release (1.3.14 cannot read the v2 bun.lock).
- macos-15-intel for the darwin-x64 build (macos-13 runners are retired).
- --version prints "astrograph X.Y.Z", which the release smoke test expects.
- apps/site gets its own lockfile; pin mdast-util-to-markdown 2.1.2, whose
  2.2.0 overflows the stack in fumadocs' MDX stringifier.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every tool takes maxTokens (lists also limit/offset) and fits its answer to
it through a budgeted buffer; anything cut is announced with how to get it.
New outline tool: signatures and line ranges of a file, directory or class.
Source is line-numbered and cut at line boundaries, callers and impact are
grouped by file, and MCP answers end with their approximate token cost.

Also: signatures run up to the body node on one line, and context ranking
uses per-word best matches, stems, multi-word coverage and lower weight for
fields.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Chofito Chofito changed the title Rewrite Rewrite: tree-sitter core, token budgets, simpler docs (v0.2.0) Oct 5, 2026
@Chofito
Chofito merged commit cf3a85d into main Oct 5, 2026
1 check passed
@Chofito Chofito mentioned this pull request Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant