Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
2879188
feat(graph): separate engine identity from config content in the buil…
Yashasvi2229 Sep 8, 2026
7fbf0a5
feat(graph): classify a config-only drifted store as bindable for rea…
Yashasvi2229 Sep 8, 2026
f021824
feat(graph): let a reader opt in to a config-drifted graph session
Yashasvi2229 Sep 8, 2026
a074d0e
feat(graph): answer targeted reads from a config-drifted graph, labelled
Yashasvi2229 Sep 8, 2026
9357959
fix(graph): give scope the same freshness classification and refusal …
Yashasvi2229 Sep 8, 2026
37010ea
test(graph): assert the new answer for a compiler-configuration change
Yashasvi2229 Sep 8, 2026
7eaf670
docs(graph): record config drift as a read state, not a rebuild trigger
Yashasvi2229 Sep 8, 2026
e584c99
test(graph): pin that a drifted read leaves the store byte-identical
Yashasvi2229 Sep 8, 2026
d80328e
fix(graph): name the input that actually refused the read
Yashasvi2229 Sep 9, 2026
2b61b5f
fix(graph): publish a candidate whose only fault is a file the policy…
Yashasvi2229 Sep 9, 2026
ed0efeb
fix(graph): explain a failed graph maintenance run instead of one sen…
Yashasvi2229 Sep 9, 2026
b78a40c
feat(graph): read a store that parsed some files incompletely, and sa…
Yashasvi2229 Sep 9, 2026
1f5c31e
feat(graph): answer around drifted source files instead of refusing t…
Yashasvi2229 Sep 9, 2026
3a681a9
perf(graph): identify config inputs by what they change, not by their…
Yashasvi2229 Sep 9, 2026
60ef59b
fix(graph): compare a re-resolved path as a name, not as bytes
Yashasvi2229 Sep 9, 2026
99d5ad0
docs(graph): record degraded reads, degraded publication, and config …
Yashasvi2229 Sep 9, 2026
84e881a
test(graph): drift config by a field that still counts
Yashasvi2229 Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .mex/ROUTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ edges:
condition: when starting a task — check the pattern index for a matching pattern file
- target: patterns/release-readme-visuals.md
condition: when refreshing the release README, badges, community links, or architecture illustrations
last_updated: 2026-09-06
last_updated: 2026-09-09
---

# Session Bootstrap
Expand Down Expand Up @@ -75,6 +75,21 @@ Then read this file fully before doing anything else in this session.
the last trustworthy index behind one cross-process maintenance lease.
- Targeted graph get/query/impact consumers use one provenance-bound immutable
snapshot and discard output if graph or exact source identity changes.
- Graph reads separate engine identity from bounded, reportable shortfalls. A
store built by incompatible code still refuses every read. A store whose
config inputs drifted, whose files parsed partially, or whose indexed source
changed is answered and labelled: resolved edges are marked stale under config
drift, an incomplete parse reports its affected files, and drifted source is
excluded by an exhaustive path set the response names. Definitions,
containment and verified source stay unlabelled. Scope classifies through the
same predicate and refuses through the same record while keeping its own
per-file text-only fallback.
- Publication applies the same judgement: a candidate whose only fault is a
skipped or partially parsed file is published rather than discarded, and a
failed maintenance run reports the diagnostics that blocked it.
- Config inputs are identified by the fields that affect extraction rather than
by raw bytes, so a dependency bump or a reformat no longer invalidates an
index; anything unparseable falls back to exact bytes.
- The graph half of Checkpoint 2 is working in the Project Hub: grouped symbol
and source Search, the read-only Code workspace, structured graph Health, and
explicit refresh/rebuild jobs all use the repository-bound GraphPort adapter.
Expand Down
54 changes: 53 additions & 1 deletion .mex/patterns/safe-graph-snapshot-evolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ edges:
condition: "when changing the graph data plane or its consumers"
- target: "context/conventions.md"
condition: "when verifying a graph implementation change"
last_updated: 2026-09-07
last_updated: 2026-09-09
mex:
id: mx_01M1M0CJP81C590FCKTSN5HA3Q
type: pattern
Expand Down Expand Up @@ -128,6 +128,58 @@ only to explicit maintenance workflows.
enter the corpus policy hash, or changing it leaves a stale index silently
describing files that are no longer in the corpus. Hash to the existing
constant when nothing is configured, so existing indexes stay valid.
- A freshness input is not one kind of thing. Engine identity — schema,
compiler, extractor, resolver, grammar, corpus policy — says the store was
written by code that is gone, and must fail closed. Config content says the
build inputs moved under a store that still describes its source exactly.
Folding both into one hash means the second is served the punishment of the
first, and a dependency bump takes every structural read with it.
- Prove identity by reconstruction, not by a new stored field. Re-folding the
current inputs with a store's recorded config hash classifies stores written
before the check existed — which are exactly the stores that need it — and
covers inputs no snapshot records at all.
- Separate the race check from the freshness check inside one validation. Two
observations disagreeing with each other is a race; either of them disagreeing
with the stored snapshot is the question the caller already answered. Mixing
them reports a race that did not happen and refuses a read that was safe.
- A degraded answer must say which half of itself is degraded. Definitions,
containment and verified source bytes survive a config change; anything
reached by following an edge does not. Labelling everything is honest but
wastes a trustworthy answer; labelling nothing is a lie.
- Commit output under the class it was labelled with. If the store changes class
between opening and output, discard the response rather than relabelling it —
the records were built under a claim they no longer earn.
- Do not unify two gates by giving both the stricter one. Scope tolerates
drifted source because it re-admits a moved file as text-only evidence; the
targeted commands cannot, because they return exact node coordinates. One
vocabulary and one classifier is the unification; one tolerance is a
regression wearing its clothes.
- `degraded` is not `unusable`, and conflating them costs a repository its
graph twice over. A candidate whose only fault is a file the policy skipped
must publish, or the skip path produces a candidate the publish gate throws
away; a store with a partial parse must read, or one unparseable file answers
nothing. Enumerate which shortfalls are known, bounded and reportable, and
admit exactly those.
- Adding a diagnostic code is half the change. Every allowlist that enumerates
codes — publication, repair, refusal ranking — has to learn it in the same
commit, or the new code silently means "refuse".
- Serving around a gap requires the gap's *complete* extent. Excluding drifted
files is only safe while the drifted list is exhaustive, so bind it to the
ceiling that truncates the list and refuse past it. A partial exclusion set is
worse than refusing outright.
- Distinguish out-of-date from incomplete when labelling. Drifted config makes
resolved edges untrustworthy; an unfinished parse makes the answer smaller
while everything in it stays true. One label for both teaches the reader to
ignore the label.
- Hash a config input by what it changes, not by its bytes — and fail towards
over-invalidation. A version bump or a reindent invalidating an index is
noise; a resolution-affecting field missing from the projection is a stale
index reading as current with no label at all.
- A re-resolved path comparison is a name check, not an identity check. On a
case-insensitive volume the same file can come back spelled differently — a
path routed through the TypeScript compiler host arrives lowercased — and a
byte comparison then rejects a file whose device, inode, size and timestamps
all match.
- Wall-clock status timings vary by machine and process-start overhead. Keep
the benchmark non-gating, record its environment, and protect correctness
with deterministic race, non-mutation, and bounded-work tests.
Expand Down
67 changes: 62 additions & 5 deletions docs/design/graph-freshness-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ database is retained locally as `.mex/graph.db.recovery-*`.

## Targeted retrieval handshake

`graph get`, `graph query`, and `impact` first require a stable `fresh` status
observation. They then adopt one immutable SQLite connection for graph and
`graph get`, `graph query`, and `impact` first require a stable status
observation that is either `fresh` or config-drifted (below). They then adopt one immutable SQLite connection for graph and
grounding reads, bind it to the inspected inode and exact `graph_snapshot_v1`
bytes, and buffer the complete JSONL response. Source ranges come from one
contained, fd-stable byte buffer whose UTF-8 decoded hash matches the indexed
Expand All @@ -36,9 +36,66 @@ validation; any mismatch discards the whole response and emits one bounded
`GRAPH_UNAVAILABLE` record.

`graph scope` deliberately retains its existing stale-file text-only fallback.
It now uses a single stable immutable database snapshot, but it does not claim
that stale live text is an indexed graph fact. Retrieval ranking and successful
protocol-v3 records remain unchanged.
It uses a single stable immutable database snapshot, but it does not claim
that stale live text is an indexed graph fact. It is not bound to the exact
freshness handshake: that fallback is what lets it answer while source files
are being edited, and binding it would turn one edited file into a refused
retrieval. It classifies build identity through the same predicate as the
targeted commands and refuses through the same record. Retrieval ranking and
successful protocol-v3 records remain unchanged.

## Reading a store that is not provably fresh

The build manifest folds seven inputs. Six are engine identity — schema,
compiler, extractor and resolver versions, grammar, and corpus policy — and a
difference in any of them means the store was written by code that is no longer
here. The seventh is the content of every `package.json`, `tsconfig*.json` and
`jsconfig*.json` in the repository, which moves for reasons that change nothing
about the graph: a dependency version, a script, a reformat.

Collapsing all seven into one comparison made a dependency bump indistinguishable
from an incompatible store, and both refused every structural read until a full
rebuild. A store is now classified as config-drifted when it would read `fresh`
except that its config inputs moved: engine identity reproduces from the current
inputs and that store's recorded config hash, the indexed corpus, branch, corpus
digest and grammar all still match, parse health is clean, and every inspection
completed. Anything short of that still refuses.

Two further shortfalls are bounded in the same way. A store whose files parsed
partially is *incomplete* rather than out of date — every fact in it is still
true — so it is read and the response reports how many files are affected and
which failed. A store whose indexed source has changed is read by excluding the
complete set of drifted paths and answering from the rest; the response names
every file it left out, a node whose own file drifted is reported as excluded
rather than missing, and a target that resolves only into excluded files says
so. Completeness of that set is the safety property, so it is bound to the
change-path ceiling: a truncated change list cannot be exhaustively excluded
from and refuses as before.

A degraded store is bound and read exactly like a fresh one, and the response
says so. Definitions, containment and returned source are unlabelled:
they do not depend on compiler configuration, and the source bytes are already
proven identical to what was indexed. Resolution does depend on it — `paths`,
`moduleResolution`, `references` and a package `type` decide what a reference
binds to — so callers, call relations, flows and unresolved references carry
`stale: true`, and the response opens with a `status` record naming the drift
and the recovery command. Every one of those fields is absent while the graph is
fresh.

Publication applies the same judgement in the other direction. A candidate whose
only fault is a file the corpus policy skipped, or one that parsed partially, is
published: refusing would discard every other file's facts to punish a gap a
rebuild would reproduce exactly. Corpus-wide breaches and incomplete inspections
still block, because those mean the observation itself is untrustworthy.

Config inputs are identified by the fields that decide what the compiler
resolves, not by their bytes, so a dependency version, a script or a reindent
does not invalidate an index. Anything unparseable or unrecognized falls back to
exact bytes: over-invalidation is noisy, but under-invalidation would serve a
stale index as current with nothing to say otherwise.

Reading a drifted store writes nothing to it. The label is not a substitute for
`mex graph refresh`; it is what the graph can honestly say until then.

## Evaluator identity

Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { isSameResolvedPath, toPosix } from "../paths.js";

const caseInsensitive = process.platform === "win32" || process.platform === "darwin";

describe("isSameResolvedPath", () => {
it("accepts identical paths", () => {
expect(isSameResolvedPath("/a/b/c.ts", "/a/b/c.ts")).toBe(true);
});

it("rejects different paths", () => {
expect(isSameResolvedPath("/a/b/c.ts", "/a/b/d.ts")).toBe(false);
});

it("treats a case-only difference as the same name only where the volume does", () => {
// A path that reached the graph through the TypeScript compiler host
// arrives lowercased; on a case-insensitive volume it names the same file,
// and comparing it as bytes once failed an entire repository's build.
expect(isSameResolvedPath("C:\\Users\\a\\File.ts", "c:\\users\\a\\file.ts"))
.toBe(caseInsensitive);
});

it("handles a missing side without throwing", () => {
expect(isSameResolvedPath(null, null)).toBe(true);
expect(isSameResolvedPath(null, "/a")).toBe(false);
expect(isSameResolvedPath("/a", null)).toBe(false);
});
});

describe("toPosix", () => {
it("leaves forward-slash paths alone", () => {
expect(toPosix("src/graph/status.ts")).toBe("src/graph/status.ts");
});
});
9 changes: 6 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,8 @@ const graphCommand = program
const { runGraph } = await import("./graph/cli-graph.js");
await runGraph({ root: opts.root, json: opts.json });
} catch (err) {
console.error((err as Error).message);
const { describeGraphMaintenanceFailure } = await import("./graph/cli-graph.js");
console.error(describeGraphMaintenanceFailure(err));
process.exit(1);
}
});
Expand Down Expand Up @@ -531,7 +532,8 @@ graphCommand
json: opts.json ?? graphCommand.opts().json,
});
} catch (err) {
console.error((err as Error).message);
const { describeGraphMaintenanceFailure } = await import("./graph/cli-graph.js");
console.error(describeGraphMaintenanceFailure(err));
process.exit(1);
}
});
Expand All @@ -549,7 +551,8 @@ graphCommand
json: opts.json ?? graphCommand.opts().json,
});
} catch (err) {
console.error((err as Error).message);
const { describeGraphMaintenanceFailure } = await import("./graph/cli-graph.js");
console.error(describeGraphMaintenanceFailure(err));
process.exit(1);
}
});
Expand Down
Loading
Loading