Repository navigation
Conversation
…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>
…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>
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A rewrite of Astrograph as one small tree-sitter package. The CLI commands, the MCP tool names (
astrograph_*) andserve --mcpstay the same; the index format changes, so existing indexes rebuild on first use.What changed
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.tsconfigpaths, workspace packages, PHP namespaces/use, and declared types (annotations,new, fields, parameter properties, return types, PHP DI assignments,@var,catch). Every reference is labeledexact/inferred/ambiguous/external/unresolved.bun run benchguards regressions.maxTokens(lists alsolimit/offset); cuts are always announced with how to continue. Newoutlinetool (signatures + line ranges, no bodies), line-numbered source, results grouped by file, approximate cost in each MCP answer's footer.macos-15-intelfor darwin-x64,--versionprintsastrograph 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.Part of #2 (the release itself follows the merge).
🤖 Generated with Claude Code