From d3890dbf91d615dc724e53691ee6c320ea784dec Mon Sep 17 00:00:00 2001 From: Yashasvi Date: Thu, 24 Sep 2026 21:16:00 +0530 Subject: [PATCH 1/2] fix(graph): check grounding against a graph stale only by changed source When the only reason the graph is stale is changed source files, mex check now grounds instead of skipping: unchanged files are checked against the snapshot, nodes in edited tree-sitter files are re-extracted and hashed exactly as a refresh would, and everything else (deleted files, edited TypeScript/JavaScript, nodes not located exactly) is reported as the new GROUNDING_UNVERIFIED warning. Nothing is reported gone or moved from a stale snapshot; every other non-fresh reason still skips grounding. Also release the graph.db descriptor when a read-only grounding runtime closes; on Windows the open handle broke the next in-process refresh. Closes #228 --- CHANGELOG.md | 6 + src/drift/checkers/grounding.ts | 45 +++ src/drift/index.ts | 34 +- src/graph/grounding.ts | 13 +- src/graph/runtime.ts | 166 +++++++++- src/graph/status.ts | 10 + src/types.ts | 6 +- test/graph-grounding-source-drift.test.ts | 361 ++++++++++++++++++++++ test/graph-migration.test.ts | 6 +- 9 files changed, 627 insertions(+), 20 deletions(-) create mode 100644 test/graph-grounding-source-drift.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 985430a4..fcac7b5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Changed +- `mex check` no longer switches grounding off after a source edit. When the only reason the graph is stale is that source files changed, and the complete list of changed files is known, groundings in unchanged files are checked against the last snapshot. A node in an edited tree-sitter file (Python, Go and the other non-TypeScript languages) is re-extracted from that file with the same extractor and body hash a refresh uses, so a changed body is a real `GROUNDING_DRIFT` without a refresh. What cannot be settled that way — a deleted file, an edited TypeScript or JavaScript file (its spans come from a whole compiler program), or a node no longer found in its file under the same identity — is the new `GROUNDING_UNVERIFIED` warning, counted in the score, with a message to run `mex graph refresh`. Nothing is reported `GROUNDING_GONE` or moved from a stale snapshot, and the freshness warning stays. Config, semantic-input, branch, grammar, schema or parse-health staleness still skips grounding exactly as before. Previously the score did not move after the very edit grounding exists to catch until a full refresh. The accepted cost: every grounding in an edited TypeScript or JavaScript file reads unverified rather than clean or drifted until the next refresh (#228). + +### Fixed +- Closing a read-only grounding runtime now releases the descriptor that binds `graph.db`, not only its SQLite reader. On Windows the open handle made the next refresh in the same process, after any check that opened grounding readers, fail with "The live graph changed before candidate publication" (#228). + ## [0.8.2] - Unreleased ### Added diff --git a/src/drift/checkers/grounding.ts b/src/drift/checkers/grounding.ts index cd488c3a..e3b4b48e 100644 --- a/src/drift/checkers/grounding.ts +++ b/src/drift/checkers/grounding.ts @@ -12,9 +12,26 @@ interface GroundingReconcilerCapabilities { getFingerprint?(nodeId: string): Fingerprint | null; } +/** What a snapshot stale only by changed source can still say about one node (#228). */ +export type SourceDriftResolution = + /** The node's body as a refresh would record it: from the snapshot when its + * file is unchanged, or re-derived exactly from the edited file. */ + | { kind: "current"; bodyHash: string | undefined } + /** Only a refresh can settle this node; `reason` says why. */ + | { kind: "unverified"; reason: string }; + +/** + * Grounding against a graph whose only fault is that source files changed. + * Supplied by the read-only runtime, which owns the exhaustive drifted-path set. + */ +export interface SourceDriftGrounding { + resolve(nodeId: string): SourceDriftResolution; +} + export function makeGroundingChecker( graph: GraphEngine, reconciler: Reconciler, + sourceDrift?: SourceDriftGrounding, ): GroundingChecker { const capabilities = reconciler as Reconciler & GroundingReconcilerCapabilities; @@ -52,6 +69,26 @@ export function makeGroundingChecker( if (!isGrounding(grounding)) continue; const current = graph.getNode(grounding.node); const baselineSource = capabilities.getGroundedSource?.(scaffoldFile, grounding.node) ?? null; + if (sourceDrift) { + // **A stale snapshot never reconciles.** Rename and move detection + // compares fingerprints across the whole corpus, and the corpus this + // snapshot describes is no longer the working tree. A node that looks + // gone may have moved into an edited file, so nothing here is reported + // GONE, MOVED or AMBIGUOUS: what cannot be settled is UNVERIFIED, and + // a definite DRIFT comes only from a body hash a refresh would record. + const resolution = sourceDrift.resolve(grounding.node); + if (resolution.kind === "unverified") { + issues.push(issue("GROUNDING_UNVERIFIED", "warning", source, + `Grounded node cannot be verified until \`mex graph refresh\`: ${grounding.node} (${resolution.reason})`)); + continue; + } + const baselineBodyHash = grounding.bodyHash ?? baselineSource?.bodyHash; + if (baselineBodyHash !== undefined && resolution.bodyHash !== baselineBodyHash) { + issues.push(issue("GROUNDING_DRIFT", "warning", source, + `Grounded node body changed: ${grounding.node}`)); + } + continue; + } if (current) { // **The committed hash wins, and the cached one is only a fallback.** // @@ -99,6 +136,14 @@ export function makeGroundingChecker( if (content === null) return issues; for (const anchor of findMexAnchors(content)) { + if (sourceDrift) { + const resolution = sourceDrift.resolve(anchor.nodeId); + if (resolution.kind === "unverified") { + issues.push(issue("GROUNDING_UNVERIFIED", "warning", source, + `Inline anchor cannot be verified until \`mex graph refresh\`: ${anchor.nodeId} (${resolution.reason})`)); + } + continue; + } if (graph.getNode(anchor.nodeId)) continue; const baselineSource = capabilities.getGroundedSource?.(scaffoldFile, anchor.nodeId) ?? null; const baseline = capabilities.getFingerprint?.(anchor.nodeId) diff --git a/src/drift/index.ts b/src/drift/index.ts index 0384068f..c564947d 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -72,7 +72,7 @@ export type GraphAwareDriftReport = DriftReport & { graphStatus: GraphStatus }; export interface GraphAwareRunDriftCheckOpts extends RunDriftCheckOpts { readOnlyGroundingRuntimeLoader?: ( config: MexConfig, - options?: { loadRuntime?: boolean }, + options?: { loadRuntime?: boolean; allowSourceDrift?: boolean }, ) => Promise; } @@ -124,13 +124,25 @@ export async function runDriftCheckWithGraphStatus( const groundingRelevant = hasGroundings || needsGroundingMigration; let groundingRuntime: GroundingRuntime | null = null; let graphStatus: GraphStatus | undefined; + // True when grounding runs against a snapshot stale only by changed source + // (#228). Before, any source edit switched grounding off until a full + // refresh, so the score could not move for the very edit it exists to catch. + // The runtime now checks unchanged files against the snapshot, re-derives + // nodes in edited files exactly or reports them GROUNDING_UNVERIFIED, and + // never reconciles. Every other non-fresh reason still skips grounding. + let sourceDriftGrounding = false; try { try { + const loadRuntime = opts.groundingRuntimeLoader ? false : groundingRelevant; const loaded = await ( opts.readOnlyGroundingRuntimeLoader ?? loadReadOnlyGroundingRuntime - )(config, { loadRuntime: opts.groundingRuntimeLoader ? false : groundingRelevant }); + )(config, { loadRuntime, ...(loadRuntime ? { allowSourceDrift: true } : {}) }); graphStatus = loaded.graphStatus; groundingRuntime = loaded.runtime; + sourceDriftGrounding = !usesInjectedGroundingRuntime + && groundingRuntime !== null + && loaded.sourceDrift === true + && graphStatus.status === "stale"; if (usesInjectedGroundingRuntime) { // Preserve the historical injection seam while still inspecting graph @@ -145,7 +157,8 @@ export async function runDriftCheckWithGraphStatus( : null; } - if (!usesInjectedGroundingRuntime && graphStatus.status !== "fresh" && groundingRuntime) { + if (!usesInjectedGroundingRuntime && graphStatus.status !== "fresh" && groundingRuntime + && !sourceDriftGrounding) { const staleRuntime = groundingRuntime; groundingRuntime = null; staleRuntime.close(); @@ -153,7 +166,7 @@ export async function runDriftCheckWithGraphStatus( if (!usesInjectedGroundingRuntime && groundingRelevant && graphStatus.status !== "fresh") { warnGraph( - graphFreshnessWarning(graphStatus, groundingRelevant, needsGroundingMigration), + graphFreshnessWarning(graphStatus, groundingRelevant, needsGroundingMigration, sourceDriftGrounding), ); } else if (groundingRelevant && !groundingRuntime && !graphUpgradeNudgeShown) { graphUpgradeNudgeShown = true; @@ -226,7 +239,11 @@ export async function runDriftCheckWithGraphStatus( } } - if (usesInjectedGroundingRuntime || graphStatus?.status === "fresh") { + // A guard that saw the graph change mid-check marks the status degraded, + // so a source-drift batch is published only while it is still `stale`. + if (usesInjectedGroundingRuntime + || graphStatus?.status === "fresh" + || (sourceDriftGrounding && graphStatus?.status === "stale")) { allIssues.push(...pendingGroundingIssues); checkerIssueCounts.push(...pendingGroundingIssueCounts); } @@ -322,8 +339,13 @@ function graphFreshnessWarning( status: GraphStatus, groundingRelevant: boolean, needsGroundingMigration: boolean, + sourceDriftGrounding = false, ): string { - const groundingNote = groundingRelevant ? "; grounding checks skipped" : ""; + const groundingNote = !groundingRelevant + ? "" + : sourceDriftGrounding + ? "; groundings in changed source files were re-read or marked unverified" + : "; grounding checks skipped"; const command = graphRemediationCommand(status); const primary = status.diagnostics.find((entry) => entry.code === "GRAPH_INDEX_READER_DATABASE_CHANGED" diff --git a/src/graph/grounding.ts b/src/graph/grounding.ts index 917c86dc..8ea2923b 100644 --- a/src/graph/grounding.ts +++ b/src/graph/grounding.ts @@ -25,11 +25,14 @@ // MOVED -> rebind identity; WARNING if the accepted body hash differs // AMBIGUOUS -> WARNING (GROUNDING_AMBIGUOUS) + candidate id // GONE -> ERROR (GROUNDING_GONE) +// Graph stale only by changed source (#228) — no reconciliation: +// file unchanged, or node re-derived exactly -> the Tier-1 rows above +// file deleted, node not located exactly -> WARNING (GROUNDING_UNVERIFIED) import type { DriftIssue, ScaffoldFrontmatter, Grounding } from "../types.js"; import type { GraphEngine } from "./engine.js"; import type { Reconciler } from "./reconcile.js"; -import { makeGroundingChecker } from "../drift/checkers/grounding.js"; +import { makeGroundingChecker, type SourceDriftGrounding } from "../drift/checkers/grounding.js"; export type { Grounding }; @@ -120,11 +123,15 @@ export type GroundingChecker = ( * graph), the drift pipeline simply does not construct this checker (spec §7); * the eleven filesystem/lexical checkers are unaffected. * - * Phase-0 stub: returns a checker that throws. Track B provides the real body. + * `sourceDrift` is the one additive seam since Phase 0 (#228): a graph that is + * stale only because source files changed is still checked, with every node + * the snapshot cannot vouch for reported as GROUNDING_UNVERIFIED and no + * reconciliation attempted. Omit it for the frozen fresh-graph behaviour. */ export function createGroundingChecker( graph: GraphEngine, reconciler: Reconciler, + sourceDrift?: SourceDriftGrounding, ): GroundingChecker { - return makeGroundingChecker(graph, reconciler); + return makeGroundingChecker(graph, reconciler, sourceDrift); } diff --git a/src/graph/runtime.ts b/src/graph/runtime.ts index a09ceff8..9cd9ca10 100644 --- a/src/graph/runtime.ts +++ b/src/graph/runtime.ts @@ -5,8 +5,12 @@ import { globSync } from "glob"; import type { MexConfig, Grounding } from "../types.js"; import { extractGroundings, findMexAnchors, rewriteMexAnchor, writeGroundings } from "../markdown.js"; import { createGroundingChecker, type GroundingChecker, type GroundedSource } from "./grounding.js"; +import type { SourceDriftGrounding, SourceDriftResolution } from "../drift/checkers/grounding.js"; import { createGraphEngine } from "./engine-impl.js"; import type { GraphEngine } from "./engine.js"; +import { detectLanguage, extractFile, loadGrammars } from "./extraction/index.js"; +import type { CompilerSourceLanguage } from "./extraction/compiler.js"; +import type { Language } from "./types.js"; import { GRAPH_CORPUS_GLOB_OPTIONS, GRAPH_CORPUS_LIMITS, @@ -24,6 +28,7 @@ import { inspectGraphSidecars, inspectGraphStatus, inspectGraphStatusWithFreshObservation, + readContainedRepositorySource, type InternalGraphStatusInspection, } from "./status.js"; import { @@ -76,6 +81,12 @@ const GROUNDING_REVIEW_MAX_ENTRIES = 64; export interface ReadOnlyGroundingRuntimeResult { graphStatus: GraphStatus; runtime: GroundingRuntime | null; + /** + * True when `runtime` is bound to a stale snapshot whose only fault is + * changed source (#228). Its checker never reconciles and reports what it + * cannot settle as GROUNDING_UNVERIFIED. Only set under `allowSourceDrift`. + */ + sourceDrift?: boolean; } export interface LoadReadOnlyGroundingRuntimeOptions { @@ -85,6 +96,12 @@ export interface LoadReadOnlyGroundingRuntimeOptions { inspectSidecars?: typeof inspectGraphSidecars; /** Inspect status without opening graph readers when grounding is irrelevant. */ loadRuntime?: boolean; + /** + * Also bind a stale snapshot when changed source is its only shortfall, and + * check groundings against it (#228). Every other non-fresh store still + * returns no runtime. Opt-in, because the caller owns labelling the result. + */ + allowSourceDrift?: boolean; } interface ReadOnlyGroundingRuntimeInternalHooks { @@ -106,6 +123,16 @@ type LoadReadOnlyGroundingRuntimeInternalOptions = LoadReadOnlyGroundingRuntimeO * Load grounding readers without synchronizing or otherwise repairing the * graph. A non-fresh snapshot is reported to the caller and never used for * grounding, because doing so could incorrectly present stale facts as clean. + * + * **One exception, opt-in (#228).** Under `allowSourceDrift`, a snapshot that + * is stale *only* because source files changed — engine identity, config, + * branch, grammar and parse health all intact, and the complete drifted-path + * list known — is bound as well. Before this, any source edit switched + * grounding off until a full refresh, so the score could not move for exactly + * the edit it exists to catch. Such a snapshot still vouches for every node in + * an unchanged file; a node in an edited file is re-derived from that file by + * the same extractor and body hash a refresh would use, or reported + * unverified. Nothing about staleness is hidden: the status stays `stale`. */ export async function loadReadOnlyGroundingRuntime( config: MexConfig, @@ -114,15 +141,22 @@ export async function loadReadOnlyGroundingRuntime( const dbPath = resolve(config.projectRoot, ".mex", "graph.db"); const internal = (options as LoadReadOnlyGroundingRuntimeInternalOptions).__internal; const statusOptions = { projectRoot: config.projectRoot, dbPath }; - const inspectObservation = options.inspectStatus + const inspectBase = options.inspectStatus ? async (): Promise => ({ graphStatus: await options.inspectStatus!(statusOptions), freshObservation: null, }) : internal?.inspectObservation ?? inspectGraphStatusWithFreshObservation; + // The read-session loader would also adopt config drift and parse health. + // Grounding accepts neither, so hide every degraded observation except the + // one whose sole shortfall is changed source; the rest behave as before. + const inspectObservation: typeof inspectGraphStatusWithFreshObservation = options.allowSourceDrift + ? async (inspectOptions) => onlySourceDrift(await inspectBase(inspectOptions)) + : inspectBase; const loaded = await loadFreshGraphReadSession(config.projectRoot, { dbPath, loadSession: options.loadRuntime, + allowDegradedReads: options.allowSourceDrift === true, inspectObservation, inspectSidecars: options.inspectSidecars, afterStatusInspection: internal?.afterStatusInspection, @@ -156,15 +190,31 @@ export async function loadReadOnlyGroundingRuntime( ]; }, }; + const sourceDrift = session.degradations.length > 0 + ? await prepareSourceDriftGrounding( + config.projectRoot, + session.graph, + session.driftedSources, + loaded.graphStatus.changes.modified, + ) + : undefined; + const runtime = assembleGroundingRuntime( + session.graph, + null, + fingerprints, + anchorFingerprints, + guard, + sourceDrift, + ); return { graphStatus: loaded.graphStatus, - runtime: assembleGroundingRuntime( - session.graph, - null, - fingerprints, - anchorFingerprints, - guard, - ), + // Close through the session, not just its graph: the session also owns + // the descriptor that binds `graph.db`'s identity. Closing only the graph + // left that descriptor open, and on Windows an open handle makes the next + // in-process refresh fail to replace the file ("The live graph changed + // before candidate publication"). + runtime: { ...runtime, close: () => session.close() }, + ...(sourceDrift ? { sourceDrift: true } : {}), }; } catch { session.close(); @@ -407,6 +457,7 @@ function assembleGroundingRuntime( fingerprints: FingerprintStore, anchorFingerprints: ReadonlyMap, guard?: GroundingRuntimeGuard, + sourceDrift?: SourceDriftGrounding, ): GroundingRuntime { const reconciler = new MinHashReconciler(fingerprints); const checkerReconciler: Reconciler & GroundingReconcilerCapabilities = { @@ -414,7 +465,7 @@ function assembleGroundingRuntime( getFingerprint: (nodeId) => anchorFingerprints.get(nodeId) ?? reconciler.getFingerprint(nodeId), getGroundedSource: (file, nodeId) => reconciler.getGroundedSource(file, nodeId), }; - const rawChecker = createGroundingChecker(graph, checkerReconciler); + const rawChecker = createGroundingChecker(graph, checkerReconciler, sourceDrift); const checker: GroundingChecker = guard ? (...args) => { if (!guard.validate()) { @@ -449,6 +500,103 @@ function assembleGroundingRuntime( }; } +/** Keep a degraded observation only when changed source is the store's sole shortfall. */ +function onlySourceDrift(inspection: InternalGraphStatusInspection): InternalGraphStatusInspection { + const degraded = inspection.degradedObservation ?? null; + if (!degraded) return inspection; + const sourceOnly = inspection.graphStatus.status === "stale" + && degraded.degradations.length === 1 + && degraded.degradations[0] === "source-drift"; + return sourceOnly ? inspection : { ...inspection, degradedObservation: null }; +} + +/** + * Compiler-extracted languages. Their spans and identities come from a whole + * TypeScript program, so one edited file cannot be re-derived exactly without + * a refresh. The `Record` makes a new compiler language a compile error here + * rather than a silent tree-sitter approximation of a compiler span. + */ +const COMPILER_SOURCE_LANGUAGES: Readonly> = { + typescript: true, + tsx: true, + javascript: true, + jsx: true, +}; + +function isCompilerSourceLanguage(language: Language): boolean { + return Object.hasOwn(COMPILER_SOURCE_LANGUAGES, language); +} + +/** + * Resolve grounded nodes against a snapshot that is stale only by changed + * source (#228). `driftedSources` is the exhaustive added/modified/deleted + * list the status inspection bound this session to. + * + * - A node whose file did not change: the snapshot row is exactly what a + * refresh would record, because nothing it was derived from moved. + * - A node in an edited tree-sitter file: re-run the single-file extractor a + * refresh uses (`extractFile`) over the current, contained read of that + * file, find the node by its Tier-1 id (path, kind, qualified name), and + * hash its line span with the graph's own body normalization. + * - Everything else — a deleted file, an edited compiler-language file, a + * node the extractor no longer emits under that id, a file that cannot be + * read within the corpus limits — is unverified. None of it is reported + * gone or moved: those need the whole refreshed corpus. + */ +async function prepareSourceDriftGrounding( + projectRoot: string, + graph: GraphEngine, + driftedSources: readonly string[], + modifiedSources: readonly string[], +): Promise { + const drifted = new Set(driftedSources); + const modified = new Set(modifiedSources.filter((path) => drifted.has(path))); + const treeLanguages = [...new Set([...modified].map((path) => detectLanguage(path)))] + .filter((language) => !isCompilerSourceLanguage(language)); + // A grammar that fails to load leaves `extractFile` returning null, which + // resolves to unverified below; it never fails the check. + try { await loadGrammars(treeLanguages); } catch { /* unverified, not fatal */ } + const rederived = new Map | null>(); + const rederive = (filePath: string): Map | null => { + if (rederived.has(filePath)) return rederived.get(filePath)!; + let nodes: Map | null = null; + try { + const source = readContainedRepositorySource(projectRoot, filePath); + const extraction = extractFile(filePath, source); + if (extraction) { + // Same span, same normalization as the graph's `bodyHash`. + const lines = source.split("\n"); + nodes = new Map(extraction.nodes.map((node) => [node.id, { + kind: node.kind, + bodyHash: hashBody(lines.slice(node.startLine - 1, node.endLine).join("\n")), + }])); + } + } catch { + nodes = null; + } + rederived.set(filePath, nodes); + return nodes; + }; + const unverified = (reason: string): SourceDriftResolution => ({ kind: "unverified", reason }); + return { + resolve(nodeId) { + const node = graph.getNode(nodeId); + if (!node) return unverified("not in the last graph snapshot"); + if (!drifted.has(node.filePath)) return { kind: "current", bodyHash: node.bodyHash }; + if (!modified.has(node.filePath)) return unverified(`${node.filePath} was deleted`); + if (isCompilerSourceLanguage(node.language) || isCompilerSourceLanguage(detectLanguage(node.filePath))) { + return unverified(`${node.filePath} changed; its compiler-derived span needs a refresh`); + } + const current = rederive(node.filePath)?.get(nodeId); + if (!current || current.kind !== node.kind) { + return unverified(`${node.filePath} changed; the node could not be located there exactly`); + } + // Only body-bearing kinds carry a hash, and the kind is part of the id. + return { kind: "current", bodyHash: node.bodyHash === undefined ? undefined : current.bodyHash }; + }, + }; +} + function snapshotAnchorFingerprints(config: MexConfig, store: FingerprintStore): Map { const snapshots = new Map(); const files = [ diff --git a/src/graph/status.ts b/src/graph/status.ts index 6af3f52a..8265d170 100644 --- a/src/graph/status.ts +++ b/src/graph/status.ts @@ -1771,6 +1771,16 @@ function inspectSemanticInputs( return { hashes, changedPaths, unavailablePaths, complete, diagnostics }; } +/** + * @internal Read one repository-relative source through the same contained, + * identity-stable, size-capped descriptor read that status inspection hashes. + * The UTF-8 decode matches indexing, so a caller that re-derives a node's body + * from this text sees exactly the text a refresh would stage. + */ +export function readContainedRepositorySource(projectRoot: string, path: string): string { + return readStableContainedUtf8File(projectRoot, resolveRealPath(projectRoot), path); +} + function readStableContainedUtf8File( projectRoot: string, projectRootRealPath: string, diff --git a/src/types.ts b/src/types.ts index f6b60c74..ddec1d66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -190,7 +190,11 @@ export type IssueCode = // never has to reopen this shared union. See src/graph/grounding.ts. | "GROUNDING_GONE" // grounded node deleted / unrecoverable (error) | "GROUNDING_DRIFT" // grounded node still exists but its body changed (warning) - | "GROUNDING_AMBIGUOUS"; // reconciler found an uncertain move candidate (warning) + | "GROUNDING_AMBIGUOUS" // reconciler found an uncertain move candidate (warning) + // The graph is stale only because source changed, and this grounding cannot be + // settled without a refresh: its file was deleted, or its node could not be + // re-derived exactly from the edited file (warning). See #228. + | "GROUNDING_UNVERIFIED"; export interface DriftIssue { code: IssueCode; diff --git a/test/graph-grounding-source-drift.test.ts b/test/graph-grounding-source-drift.test.ts new file mode 100644 index 00000000..112908bb --- /dev/null +++ b/test/graph-grounding-source-drift.test.ts @@ -0,0 +1,361 @@ +/** + * `mex check` grounds against a graph that is stale only because source + * changed (#228). + * + * Before this, any source edit made the graph `stale` and switched grounding + * off entirely until a full refresh, so the one edit grounding exists to catch + * never moved the score. Now a node in an unchanged file is checked against the + * snapshot, a node in an edited tree-sitter file is re-derived with the + * extractor and body hash a refresh would use, and everything else is + * GROUNDING_UNVERIFIED. + * + * The guard is the differential test: every grounded node's verdict without a + * refresh must equal its verdict after a real rebuild, or be UNVERIFIED. A + * different definite verdict is the one outcome that is never acceptable. + */ + +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { DriftIssue, Grounding, MexConfig } from "../src/types.js"; +import { runDriftCheckWithGraphStatus, type GraphAwareDriftReport } from "../src/drift/index.js"; +import { createGraphEngine } from "../src/graph/engine-impl.js"; +import { loadGroundingRuntime, loadReadOnlyGroundingRuntime, refreshGroundingBaselines } from "../src/graph/runtime.js"; +import { serializeFingerprint } from "../src/graph/fingerprint.js"; +import { extractGroundings, writeGroundings } from "../src/markdown.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +const PY_BODY = `def compute_total(items): + subtotal = sum(items) + tax = subtotal * 0.18 + return subtotal + tax +`; +const PY_WHITESPACE = `def tidy(values): + cleaned = [value.strip() for value in values] + return cleaned +`; +const PY_RENAME = `def helper(value): + doubled = value * 2 + return doubled + 1 +`; +const PY_GONE = `def vanishing(value): + return value - 42 +`; +const PY_SHIFT = `def target(value): + total = value + 7 + return total * 3 +`; +const PY_STABLE = `def steady(value): + return value + 1 + + +def predrifted(value): + return value * 5 +`; +const TS_BODY = `export function calculateOrderTotal(items: number[]): number { + const subtotal = items.reduce((sum, item) => sum + item, 0); + const tax = subtotal * 0.18; + return subtotal + tax; +} +`; +const TS_GONE = `export function removedLater(value: number): number { + return value - 42; +} +`; +const TS_SHIFT = `export function shiftedLater(value: number): number { + const total = value + 7; + return total * 3; +} +`; +const TS_STABLE = `export function stableTs(value: number): number { + return value + 1; +} +`; + +const SOURCES: Record = { + "py/body.py": PY_BODY, + "py/whitespace.py": PY_WHITESPACE, + "py/rename.py": PY_RENAME, + "py/gone.py": PY_GONE, + "py/shift.py": PY_SHIFT, + "py/stable.py": PY_STABLE, + "src/body.ts": TS_BODY, + "src/gone.ts": TS_GONE, + "src/shift.ts": TS_SHIFT, + "src/stable.ts": TS_STABLE, +}; + +/** Grounded symbol → the file that defines it. */ +const GROUNDED: Record = { + compute_total: "py/body.py", + tidy: "py/whitespace.py", + helper: "py/rename.py", + vanishing: "py/gone.py", + target: "py/shift.py", + steady: "py/stable.py", + predrifted: "py/stable.py", + calculateOrderTotal: "src/body.ts", + removedLater: "src/gone.ts", + shiftedLater: "src/shift.ts", + stableTs: "src/stable.ts", +}; + +/** The edits: body, comment-only, whitespace-only, rename, delete and line shift, in both languages. */ +const EDITS: Record = { + "py/body.py": PY_BODY.replace("subtotal * 0.18", "subtotal * 0.21"), + // A comment above the function plus re-indented body whitespace: no body change. + "py/whitespace.py": `# Normalizes user input.\n${PY_WHITESPACE.replace(" cleaned = ", " cleaned = ")}`, + "py/rename.py": PY_RENAME.replace("def helper(", "def helper_renamed("), + "py/gone.py": null, + "py/shift.py": `def inserted_above(value):\n return value\n\n\n${PY_SHIFT}`, + "src/body.ts": TS_BODY.replace("subtotal * 0.18", "subtotal * 0.21"), + "src/gone.ts": null, + "src/shift.ts": `export function insertedAbove(value: number): number {\n return value;\n}\n\n${TS_SHIFT}`, +}; + +interface Fixture { + root: string; + scaffold: string; + config: MexConfig; + ids: Record; +} + +async function fixture(): Promise { + const root = mkdtempSync(join(tmpdir(), "mex-grounding-source-drift-")); + roots.push(root); + mkdirSync(join(root, ".mex", "context"), { recursive: true }); + writeFileSync(join(root, ".mex", "ROUTER.md"), "# Router\n"); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "fixture", private: true, type: "module" })); + writeFileSync(join(root, "tsconfig.json"), JSON.stringify({ + compilerOptions: { target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext", strict: true }, + include: ["src"], + })); + for (const [path, text] of Object.entries(SOURCES)) write(root, path, text); + const scaffold = join(root, ".mex", "context", "architecture.md"); + writeFileSync(scaffold, "---\nname: architecture\n---\n\n# Architecture\n"); + const config: MexConfig = { projectRoot: root, scaffoldRoot: join(root, ".mex"), aiTools: [] }; + await rebuild(root); + + // Ground every symbol the way `mex graph ground` does, then capture the + // committed body hashes the way setup/sync does. + const ids: Record = {}; + const runtime = await loadGroundingRuntime(config); + try { + const groundings: Grounding[] = []; + for (const [symbol, file] of Object.entries(GROUNDED)) { + const node = runtime!.graph.searchNodes(symbol) + .find((entry) => entry.name === symbol && entry.filePath === file && entry.kind === "function"); + if (!node) throw new Error(`fixture node missing: ${symbol}`); + ids[symbol] = node.id; + groundings.push({ node: node.id, fingerprint: serializeFingerprint(runtime!.reconciler.getFingerprint(node.id)!) }); + } + const body = `\nSee [the deleted helper](mex://${ids.vanishing}) and [stable](mex://${ids.steady}).\n`; + writeFileSync(scaffold, writeGroundings(readFileSync(scaffold, "utf-8") + body, groundings)); + refreshGroundingBaselines(config, [scaffold], runtime!); + } finally { + runtime!.close(); + } + // A grounding that had already drifted before the edit, in a file that stays + // untouched: the snapshot must still report it. + const committed = extractGroundings(readFileSync(scaffold, "utf-8")); + expect(committed.every((entry) => typeof entry.bodyHash === "string")).toBe(true); + writeFileSync(scaffold, writeGroundings(readFileSync(scaffold, "utf-8"), committed.map((entry) => + entry.node === ids.predrifted ? { ...entry, bodyHash: "0".repeat(64) } : entry))); + return { root, scaffold, config, ids }; +} + +function write(root: string, path: string, text: string): void { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), text); +} + +function applyEdits(root: string, edits: Record): void { + for (const [path, text] of Object.entries(edits)) { + if (text === null) rmSync(join(root, path)); + else write(root, path, text); + } +} + +/** Delete the disposable index and rebuild it: exactly what a refresh would record. */ +async function rebuild(root: string): Promise { + for (const suffix of ["", "-wal", "-shm"]) rmSync(join(root, ".mex", `graph.db${suffix}`), { force: true }); + const engine = createGraphEngine({ rootDir: root }); + await engine.build(); + engine.close(); +} + +async function check(config: MexConfig): Promise<{ report: GraphAwareDriftReport; warnings: string[] }> { + const warnings: string[] = []; + const report = await runDriftCheckWithGraphStatus(config, { graphWarning: (message) => warnings.push(message) }); + return { report, warnings }; +} + +type Verdict = "OK" | DriftIssue["code"]; + +/** One verdict per grounded node id: the code of the grounding issue that names it, or OK. */ +function verdicts(report: GraphAwareDriftReport, ids: Record): Record { + const out: Record = {}; + for (const [symbol, id] of Object.entries(ids)) { + const named = report.issues.filter((entry) => + entry.code.startsWith("GROUNDING_") && entry.message.includes(id) && !entry.message.startsWith("Inline anchor")); + out[symbol] = named[0]?.code ?? "OK"; + } + return out; +} + +function anchorVerdicts(report: GraphAwareDriftReport, ids: Record): Record { + const out: Record = {}; + for (const symbol of ["vanishing", "steady"]) { + const named = report.issues.find((entry) => + entry.message.startsWith("Inline anchor") && entry.message.includes(ids[symbol])); + out[symbol] = named?.code ?? "OK"; + } + return out; +} + +function groundingCodes(report: GraphAwareDriftReport): string[] { + return report.issues.filter((entry) => entry.code.startsWith("GROUNDING_")).map((entry) => entry.code).sort(); +} + +describe("check grounds against a graph stale only by changed source (#228)", () => { + it("never gives a definite verdict that differs from the verdict after a real refresh", async () => { + const { root, config, ids } = await fixture(); + const baseline = await check(config); + expect(baseline.report.graphStatus.status).toBe("fresh"); + expect(verdicts(baseline.report, ids)).toEqual({ + compute_total: "OK", tidy: "OK", helper: "OK", vanishing: "OK", target: "OK", steady: "OK", + predrifted: "GROUNDING_DRIFT", calculateOrderTotal: "OK", removedLater: "OK", shiftedLater: "OK", + stableTs: "OK", + }); + + applyEdits(root, EDITS); + const stale = await check(config); + expect(stale.report.graphStatus.status).toBe("stale"); + expect(stale.report.graphStatus.diagnostics.map((entry) => entry.code)).toContain("GRAPH_SOURCE_CORPUS_MISMATCH"); + const before = verdicts(stale.report, ids); + const anchorsBefore = anchorVerdicts(stale.report, ids); + + await rebuild(root); + const refreshed = await check(config); + expect(refreshed.report.graphStatus.status).toBe("fresh"); + const after = verdicts(refreshed.report, ids); + const anchorsAfter = anchorVerdicts(refreshed.report, ids); + + // The guard: identical, or UNVERIFIED. Never a different definite verdict. + for (const symbol of Object.keys(ids)) { + expect([after[symbol], "GROUNDING_UNVERIFIED"], symbol).toContain(before[symbol]); + } + for (const symbol of Object.keys(anchorsBefore)) { + expect([anchorsAfter[symbol], "GROUNDING_UNVERIFIED"], `anchor ${symbol}`).toContain(anchorsBefore[symbol]); + } + + // What each edit resolves to without a refresh, and after one. + expect(before).toEqual({ + compute_total: "GROUNDING_DRIFT", // tree-sitter body edit: re-derived exactly + tidy: "OK", // comment above + whitespace-only body edit: same normalized hash + helper: "GROUNDING_UNVERIFIED", // renamed: never reconciled from a stale snapshot + vanishing: "GROUNDING_UNVERIFIED", // file deleted: never reported GONE + target: "OK", // function inserted above: lines shift, body unchanged + steady: "OK", // unchanged file: the snapshot row stands + predrifted: "GROUNDING_DRIFT", // unchanged file with a pre-existing drift + calculateOrderTotal: "GROUNDING_UNVERIFIED", // compiler span needs a refresh + removedLater: "GROUNDING_UNVERIFIED", + shiftedLater: "GROUNDING_UNVERIFIED", + stableTs: "OK", // unchanged TypeScript file + }); + expect(after).toMatchObject({ + compute_total: "GROUNDING_DRIFT", + tidy: "OK", + vanishing: "GROUNDING_GONE", + target: "OK", + steady: "OK", + predrifted: "GROUNDING_DRIFT", + calculateOrderTotal: "GROUNDING_DRIFT", + removedLater: "GROUNDING_GONE", + shiftedLater: "OK", + stableTs: "OK", + }); + expect(anchorsBefore).toEqual({ vanishing: "GROUNDING_UNVERIFIED", steady: "OK" }); + expect(anchorsAfter.steady).toBe("OK"); + expect(stale.report.issues.some((entry) => entry.code === "GROUNDING_GONE")).toBe(false); + expect(stale.warnings.join("\n")).toContain("re-read or marked unverified"); + expect(stale.warnings.join("\n")).not.toContain("grounding checks skipped"); + }, 120_000); + + it("detects a grounded-body edit before the refresh, and the score moves", async () => { + const { root, config } = await fixture(); + const fresh = await check(config); + expect(fresh.report.graphStatus.status).toBe("fresh"); + + // Hono `compose()` / mex `extractGroundings`: a TypeScript body edit. + applyEdits(root, { "src/body.ts": EDITS["src/body.ts"]! }); + const tsEdit = await check(config); + expect(tsEdit.report.graphStatus.status).toBe("stale"); + expect(tsEdit.report.issues.filter((entry) => entry.code === "GROUNDING_UNVERIFIED")).toHaveLength(1); + expect(tsEdit.report.issues.find((entry) => entry.code === "GROUNDING_UNVERIFIED")!.message) + .toContain("mex graph refresh"); + expect(tsEdit.report.score).toBeLessThan(fresh.report.score); + + // A tree-sitter body edit is a definite drift, still without a refresh. + applyEdits(root, { "py/body.py": EDITS["py/body.py"]! }); + const pyEdit = await check(config); + expect(pyEdit.report.graphStatus.status).toBe("stale"); + expect(groundingCodes(pyEdit.report)).toEqual(["GROUNDING_DRIFT", "GROUNDING_DRIFT", "GROUNDING_UNVERIFIED"]); + expect(pyEdit.report.score).toBeLessThan(tsEdit.report.score); + }, 120_000); + + it("detects a deleted grounded symbol before the refresh", async () => { + // Hono `testClient` / mex `createRepositoryGraphPort`: the defining code is removed. + const { root, config, ids } = await fixture(); + const fresh = await check(config); + writeFileSync(join(root, "src", "gone.ts"), "export const placeholder = 1;\n"); + applyEdits(root, { "py/gone.py": null }); + const stale = await check(config); + expect(stale.report.graphStatus.status).toBe("stale"); + const unverified = stale.report.issues.filter((entry) => entry.code === "GROUNDING_UNVERIFIED"); + expect(unverified.some((entry) => entry.message.includes(ids.removedLater))).toBe(true); + expect(unverified.some((entry) => entry.message.includes(ids.vanishing) && entry.message.includes("was deleted"))) + .toBe(true); + expect(stale.report.score).toBeLessThan(fresh.report.score); + }, 120_000); + + it("releases the bound graph descriptor when a read-only grounding runtime closes", async () => { + // Stale checks now open readers too, so the leak this pins would reach + // every check-then-refresh sequence. On Windows the open descriptor made + // the next in-process refresh fail to replace graph.db. + const { root, config } = await fixture(); + for (const stale of [false, true]) { + if (stale) applyEdits(root, { "py/body.py": EDITS["py/body.py"]! }); + let descriptorClosed = false; + const loaded = await loadReadOnlyGroundingRuntime(config, { + allowSourceDrift: true, + __internal: { afterDatabaseDescriptorClose: () => { descriptorClosed = true; } }, + } as unknown as Parameters[1]); + expect(loaded.graphStatus.status).toBe(stale ? "stale" : "fresh"); + expect(loaded.sourceDrift === true).toBe(stale); + expect(descriptorClosed).toBe(false); + loaded.runtime!.close(); + expect(descriptorClosed, stale ? "stale runtime" : "fresh runtime").toBe(true); + } + const refreshed = await loadGroundingRuntime(config); + expect(refreshed).not.toBeNull(); + refreshed!.close(); + }, 120_000); + + it("still skips grounding when config changed, exactly as before", async () => { + const { root, config } = await fixture(); + applyEdits(root, { "py/body.py": EDITS["py/body.py"]! }); + writeFileSync(join(root, "package.json"), JSON.stringify({ name: "fixture", private: true, type: "commonjs" })); + const stale = await check(config); + expect(stale.report.graphStatus.status).toBe("stale"); + expect(stale.report.graphStatus.changes.configChanged).toBe(true); + expect(groundingCodes(stale.report)).toEqual([]); + expect(stale.warnings.join("\n")).toContain("grounding checks skipped"); + }, 120_000); +}); diff --git a/test/graph-migration.test.ts b/test/graph-migration.test.ts index fe7d4626..f3ecfd59 100644 --- a/test/graph-migration.test.ts +++ b/test/graph-migration.test.ts @@ -119,7 +119,11 @@ describe("pre-0.7 graph grounding migration", () => { const warning = vi.fn(); let drift = await runDriftCheckWithGraphStatus(config, { graphWarning: warning }); expect(drift.graphStatus?.status).toBe("stale"); - expect(drift.issues.some((issue) => issue.code.startsWith("GROUNDING_"))).toBe(false); + // Source-only staleness no longer hides the edit (#228). The edited file is + // compiler-extracted, so without a refresh both the grounding and its + // inline anchor are unverified — never clean, never a guessed verdict. + expect(drift.issues.filter((issue) => issue.code.startsWith("GROUNDING_")).map((issue) => issue.code)) + .toEqual(["GROUNDING_UNVERIFIED", "GROUNDING_UNVERIFIED"]); expect(warning).toHaveBeenCalledWith(expect.stringContaining("Run `mex graph refresh`")); const refreshRuntime = await loadGroundingRuntime(config); From 37f156b3718b17f191accecb4c08bfe7f7b7add0 Mon Sep 17 00:00:00 2001 From: Yashasvi Date: Thu, 24 Sep 2026 21:29:37 +0530 Subject: [PATCH 2/2] test(setup): expect unverified grounding after a source-only stale edit (#228) --- test/setup-grounding-e2e.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/setup-grounding-e2e.test.ts b/test/setup-grounding-e2e.test.ts index a54763a7..7ca1f103 100644 --- a/test/setup-grounding-e2e.test.ts +++ b/test/setup-grounding-e2e.test.ts @@ -152,7 +152,16 @@ export function calculateCheckoutTotal(items: number[], member: boolean): number const warnings: string[] = []; let drift = await runDriftCheckWithGraphStatus(config, { graphWarning: (message) => warnings.push(message) }); expect(drift.graphStatus?.status).toBe("stale"); - expect(drift.issues.some((issue) => issue.code.startsWith("GROUNDING_"))).toBe(false); + // Source-only staleness no longer hides the edit (#228). checkout.ts is + // compiler-extracted, so without a refresh its groundings are unverified: + // never clean, and never a definite verdict guessed from a stale snapshot. + const staleGrounding = drift.issues.filter((issue) => issue.code.startsWith("GROUNDING_")); + expect(staleGrounding.length).toBeGreaterThan(0); + expect(staleGrounding.every((issue) => issue.code === "GROUNDING_UNVERIFIED")).toBe(true); + expect(staleGrounding).toContainEqual(expect.objectContaining({ + code: "GROUNDING_UNVERIFIED", + file: ".mex/patterns/calculate-checkout.md", + })); expect(warnings).toContainEqual(expect.stringContaining("Run `mex graph refresh`")); const refreshRuntime = await loadGroundingRuntime(config);