From 857a90df3dbcc6478d5aa327cd08d85be7340bed Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Sat, 19 Sep 2026 18:25:04 +0300 Subject: [PATCH 1/2] fix(graph): report uninspected status fields as not inspected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When immutable inspection is skipped — a stranded graph.db-wal, an unreadable sidecar, a containment failure or a failed invariant audit — mex graph status printed the placeholder values in the same shape as measurements: "Last successful index: never", "Sources: 0 changed" and "Parse health: 0 ok", all false for an intact store. Add an additive inspected flag to GraphStatus, set false on every early return that fills parseHealth with an empty placeholder, and render those three lines as "not inspected" in the text output. parseHealth and changes keep their shape so no consumer changes. Fixes #204 --- CHANGELOG.md | 1 + src/graph/__tests__/cli-graph.test.ts | 34 ++++++++++++++++++++++++++- src/graph/__tests__/status.test.ts | 11 +++++++++ src/graph/cli-graph.ts | 18 +++++++++----- src/graph/status.ts | 18 ++++++++++++++ src/team/contracts/graph.ts | 6 +++++ 6 files changed, 81 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 989d8b3b..9e1d24db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All notable changes to this project will be documented in this file. - Project Hub Overview now opens with a compact Context card above the atlas instead of a header button. It links to Context when the Wiki index is fresh and to Health otherwise, so a stale or unavailable index no longer leads to a page that cannot load. ### Fixed +- `mex graph status` no longer prints placeholder zeros as measurements when immutable inspection is skipped. While a stranded `graph.db-wal`, an unreadable sidecar, a containment failure or a failed invariant audit blocks the read, the text output now says `Last successful index: not inspected`, `Sources: not inspected` and `Parse health: not inspected` instead of `never` and `0 ok`, and `--json` carries an additive `inspected: false` so a consumer can tell an uninspected store from one that genuinely parsed nothing. `parseHealth` and `changes` keep their shape (#204). - Agent population failures retain the real copyable manual prompt for retry or manual continuation. Integration pointer notes are visible as non-blocking guidance. - Setup and Overview share the computer's contact preference so completing or skipping the invitation does not immediately trigger another request. diff --git a/src/graph/__tests__/cli-graph.test.ts b/src/graph/__tests__/cli-graph.test.ts index e899eed6..9df664ae 100644 --- a/src/graph/__tests__/cli-graph.test.ts +++ b/src/graph/__tests__/cli-graph.test.ts @@ -4,7 +4,13 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { GraphSourceChanges } from "../../team/contracts/graph.js"; import { runGraphScope } from "../cli-agent.js"; -import { formatGraphSourceChanges, runGraph, runGraphRefresh, runGraphRebuild } from "../cli-graph.js"; +import { + formatGraphSourceChanges, + runGraph, + runGraphRefresh, + runGraphRebuild, + runGraphStatus, +} from "../cli-graph.js"; function changes(overrides: Partial = {}): GraphSourceChanges { return { @@ -35,6 +41,32 @@ describe("graph CLI status formatting", () => { ); expect(rendered).not.toContain("1 added, 2 modified, 0 deleted"); }); + + it("prints not inspected instead of zeros while a stranded WAL blocks inspection", async () => { + const root = mkdtempSync(join(tmpdir(), "mex-status-wal-cli-")); + const output: string[] = []; + const log = vi.spyOn(console, "log").mockImplementation((line) => output.push(String(line))); + try { + writeFileSync(join(root, "api.ts"), "export const api = true;"); + await runGraph({ root, json: true }); + writeFileSync(join(root, ".mex", "graph.db-wal"), "stranded"); + + output.length = 0; + await runGraphStatus({ root }); + expect(output).toContain("Last successful index: not inspected"); + expect(output).toContain("Sources: not inspected"); + expect(output).toContain("Parse health: not inspected"); + expect(output.some((line) => line.startsWith("WARNING GRAPH_INDEX_SIDECAR_ACTIVE"))).toBe(true); + expect(output.some((line) => line.includes("0 ok") || line.includes("never"))).toBe(false); + + output.length = 0; + await runGraphStatus({ root, json: true }); + expect(JSON.parse(output.join(""))).toMatchObject({ status: "degraded", inspected: false }); + } finally { + log.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/src/graph/__tests__/status.test.ts b/src/graph/__tests__/status.test.ts index b6dc79b4..1f325230 100644 --- a/src/graph/__tests__/status.test.ts +++ b/src/graph/__tests__/status.test.ts @@ -508,6 +508,7 @@ describe("inspectGraphStatus", () => { const corruptBefore = treeState(corruptRoot); const corrupt = await inspect(corruptRoot); expect(corrupt.status).toBe("corrupt"); + expect(corrupt.inspected).toBe(false); expect(corrupt.changes.total).toBe(0); expect(corrupt.diagnostics).toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_CORRUPT" })); expect(executableRemediations(corrupt)).toContain("mex graph rebuild"); @@ -524,6 +525,7 @@ describe("inspectGraphStatus", () => { expect(statSync(`${dbPath}-wal`).size).toBeGreaterThan(0); const transient = await inspect(transientRoot); expect(transient.status).toBe("degraded"); + expect(transient.inspected).toBe(false); expect(transient.changes.total).toBe(0); expect(transient.diagnostics).toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_SIDECAR_ACTIVE" })); expect(transient.diagnostics).not.toContainEqual(expect.objectContaining({ code: "GRAPH_INDEX_CORRUPT" })); @@ -531,6 +533,15 @@ describe("inspectGraphStatus", () => { } finally { writer.close(); } + const checkpointed = openSqlite(dbPath); + try { + checkpointed.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + } finally { + checkpointed.close(); + } + const measured = await inspect(transientRoot); + expect(measured.inspected).toBe(true); + expect(measured.parseHealth.total).toBe(1); }); it("reports sidecars deterministically and refuses immutable interpretation while one is active or unavailable", async () => { diff --git a/src/graph/cli-graph.ts b/src/graph/cli-graph.ts index 43258590..92e150a0 100644 --- a/src/graph/cli-graph.ts +++ b/src/graph/cli-graph.ts @@ -184,12 +184,18 @@ function printStatus(status: GraphStatus): void { const changes = status.changes; console.log(`Graph status: ${status.status}`); console.log(`Repository: ${branch} @ ${head}${status.currentRepo.dirty ? " (dirty)" : ""}`); - console.log(`Last successful index: ${status.lastSuccessfulIndexAt ?? "never"}`); - console.log(formatGraphSourceChanges(changes)); - console.log( - `Parse health: ${status.parseHealth.ok} ok, ${status.parseHealth.partial} partial, ` - + `${status.parseHealth.failed} failed`, - ); + if (status.inspected === false) { + console.log("Last successful index: not inspected"); + console.log("Sources: not inspected"); + console.log("Parse health: not inspected"); + } else { + console.log(`Last successful index: ${status.lastSuccessfulIndexAt ?? "never"}`); + console.log(formatGraphSourceChanges(changes)); + console.log( + `Parse health: ${status.parseHealth.ok} ok, ${status.parseHealth.partial} partial, ` + + `${status.parseHealth.failed} failed`, + ); + } for (const diagnostic of status.diagnostics) { console.log(`${diagnostic.severity.toUpperCase()} ${diagnostic.code}: ${diagnostic.message}`); } diff --git a/src/graph/status.ts b/src/graph/status.ts index c12d0fef..1098467f 100644 --- a/src/graph/status.ts +++ b/src/graph/status.ts @@ -491,6 +491,7 @@ async function inspectGraphStatusAttempt( status: "degraded", observedAt, currentRepo, + inspected: false, parseHealth: emptyParseHealth(), changes: emptySourceChanges(), diagnostics: [contained.diagnostic], @@ -534,6 +535,7 @@ async function inspectGraphStatusAttempt( status: "missing", observedAt, currentRepo, + inspected: true, parseHealth: emptyParseHealth(), changes, diagnostics, @@ -548,6 +550,7 @@ async function inspectGraphStatusAttempt( status: classified.status, observedAt, currentRepo, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -566,6 +569,7 @@ async function inspectGraphStatusAttempt( status: "corrupt", observedAt, currentRepo, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -586,6 +590,7 @@ async function inspectGraphStatusAttempt( status: "degraded", observedAt, currentRepo, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -602,6 +607,7 @@ async function inspectGraphStatusAttempt( status: "degraded", observedAt, currentRepo, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -667,6 +673,7 @@ async function inspectGraphStatusAttempt( status: partialSchema ? "corrupt" : "rebuild_required", observedAt, currentRepo, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -685,6 +692,7 @@ async function inspectGraphStatusAttempt( observedAt, currentRepo, schemaVersion, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -716,6 +724,7 @@ async function inspectGraphStatusAttempt( observedAt, currentRepo, schemaVersion, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -737,6 +746,7 @@ async function inspectGraphStatusAttempt( observedAt, currentRepo, schemaVersion, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -755,6 +765,7 @@ async function inspectGraphStatusAttempt( observedAt, currentRepo, schemaVersion, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -781,6 +792,7 @@ async function inspectGraphStatusAttempt( observedAt, currentRepo, schemaVersion, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -801,6 +813,7 @@ async function inspectGraphStatusAttempt( observedAt, currentRepo, schemaVersion, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -821,6 +834,7 @@ async function inspectGraphStatusAttempt( observedAt, currentRepo, schemaVersion, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -845,6 +859,7 @@ async function inspectGraphStatusAttempt( observedAt, currentRepo, schemaVersion, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -1121,6 +1136,7 @@ async function inspectGraphStatusAttempt( status: classified.status, observedAt, currentRepo, + inspected: false, parseHealth: emptyParseHealth(), changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, @@ -1148,12 +1164,14 @@ function graphStatus(input: { schemaVersion?: number | null; extractorVersion?: string | null; grammarVersion?: string | null; + inspected?: boolean; parseHealth: GraphParseHealth; changes: GraphSourceChanges; diagnostics: readonly Diagnostic[]; }): GraphStatus { return { status: input.status, + inspected: input.inspected ?? true, observedAt: input.observedAt, currentRepo: input.currentRepo, lastSuccessfulIndexAt: input.lastSuccessfulIndexAt ?? null, diff --git a/src/team/contracts/graph.ts b/src/team/contracts/graph.ts index 4db83cc1..2bc3914f 100644 --- a/src/team/contracts/graph.ts +++ b/src/team/contracts/graph.ts @@ -50,6 +50,12 @@ export interface GraphSourceChanges { export interface GraphStatus { status: GraphStatusKind; + /** + * False when the store could not be opened for immutable inspection, so + * `lastSuccessfulIndexAt`, `parseHealth` and `changes` are placeholders + * rather than measurements. Optional and additive. + */ + inspected?: boolean; observedAt: string; currentRepo: RepoState; lastSuccessfulIndexAt: string | null; From 84a50586e2b453aca29c2939ed2069897500c540 Mon Sep 17 00:00:00 2001 From: Vladimir Babin Date: Tue, 22 Sep 2026 05:18:05 +0300 Subject: [PATCH 2/2] fix(graph): mark the snapshot-mismatch and loader-failure statuses as not inspected The GRAPH_SNAPSHOT_CONTENT_MISMATCH early return had read parse health but never compared sources or populated the index timestamps, so the placeholder changes and null lastSuccessfulIndexAt shipped as inspected: true. Mark that partial inspection as not inspected, and give the mex check fallback status the same explicit flag when the status loader throws. Regressions: build a graph, corrupt the snapshot digest, edit a source, and verify text + JSON output; inject a loader failure and verify the fallback carries inspected: false. --- CHANGELOG.md | 2 +- src/drift/index.ts | 1 + src/graph/__tests__/cli-graph.test.ts | 50 +++++++++++++++++++++++++++ src/graph/__tests__/status.test.ts | 6 ++++ src/graph/status.ts | 5 +++ test/graph-integration.test.ts | 21 +++++++++++ 6 files changed, 84 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e1d24db..5e4a888b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ All notable changes to this project will be documented in this file. - Project Hub Overview now opens with a compact Context card above the atlas instead of a header button. It links to Context when the Wiki index is fresh and to Health otherwise, so a stale or unavailable index no longer leads to a page that cannot load. ### Fixed -- `mex graph status` no longer prints placeholder zeros as measurements when immutable inspection is skipped. While a stranded `graph.db-wal`, an unreadable sidecar, a containment failure or a failed invariant audit blocks the read, the text output now says `Last successful index: not inspected`, `Sources: not inspected` and `Parse health: not inspected` instead of `never` and `0 ok`, and `--json` carries an additive `inspected: false` so a consumer can tell an uninspected store from one that genuinely parsed nothing. `parseHealth` and `changes` keep their shape (#204). +- `mex graph status` no longer prints placeholder zeros as measurements when immutable inspection is skipped. While a stranded `graph.db-wal`, an unreadable sidecar, a containment failure or a failed invariant audit blocks the read, the text output now says `Last successful index: not inspected`, `Sources: not inspected` and `Parse health: not inspected` instead of `never` and `0 ok`, and `--json` carries an additive `inspected: false` so a consumer can tell an uninspected store from one that genuinely parsed nothing. The same applies when a `GRAPH_SNAPSHOT_CONTENT_MISMATCH` stops the source comparison before the index timestamps are read, and to the `mex check` fallback status when the loader itself throws. `parseHealth` and `changes` keep their shape (#204). - Agent population failures retain the real copyable manual prompt for retry or manual continuation. Integration pointer notes are visible as non-blocking guidance. - Setup and Overview share the computer's contact preference so completing or skipping the invitation does not immediately trigger another request. diff --git a/src/drift/index.ts b/src/drift/index.ts index dbaa8cda..0384068f 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -363,6 +363,7 @@ function unavailableGraphStatus(message: string): GraphStatus { const observedAt = new Date().toISOString(); return { status: "degraded", + inspected: false, observedAt, currentRepo: { branch: null, head: null, dirty: false, observedAt }, lastSuccessfulIndexAt: null, diff --git a/src/graph/__tests__/cli-graph.test.ts b/src/graph/__tests__/cli-graph.test.ts index 9df664ae..f21bbff0 100644 --- a/src/graph/__tests__/cli-graph.test.ts +++ b/src/graph/__tests__/cli-graph.test.ts @@ -4,6 +4,12 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import type { GraphSourceChanges } from "../../team/contracts/graph.js"; import { runGraphScope } from "../cli-agent.js"; +import { openSqlite } from "../db/sqlite.js"; +import { + GRAPH_SNAPSHOT_METADATA_KEY, + parseGraphSnapshot, + serializeGraphSnapshot, +} from "../snapshot.js"; import { formatGraphSourceChanges, runGraph, @@ -67,6 +73,50 @@ describe("graph CLI status formatting", () => { rmSync(root, { recursive: true, force: true }); } }, 60_000); + + it("prints not inspected when a snapshot digest mismatch stops source comparison", async () => { + const root = mkdtempSync(join(tmpdir(), "mex-status-snapshot-cli-")); + const output: string[] = []; + const log = vi.spyOn(console, "log").mockImplementation((line) => output.push(String(line))); + try { + writeFileSync(join(root, "api.ts"), "export const api = true;"); + await runGraph({ root, json: true }); + const db = openSqlite(join(root, ".mex", "graph.db")); + try { + const row = db.prepare("SELECT value FROM project_metadata WHERE key = ?") + .get(GRAPH_SNAPSHOT_METADATA_KEY) as { value: string }; + const snapshot = parseGraphSnapshot(row.value); + if (!snapshot) throw new Error("test fixture has no valid graph snapshot"); + db.prepare("UPDATE project_metadata SET value = ? WHERE key = ?").run( + serializeGraphSnapshot({ ...snapshot, sourceCorpusDigest: "0".repeat(64) }), + GRAPH_SNAPSHOT_METADATA_KEY, + ); + } finally { + db.close(); + } + writeFileSync(join(root, "api.ts"), "export const api = false;"); + + output.length = 0; + await runGraphStatus({ root }); + expect(output).toContain("Graph status: corrupt"); + expect(output).toContain("Last successful index: not inspected"); + expect(output).toContain("Sources: not inspected"); + expect(output).toContain("Parse health: not inspected"); + expect(output.some((line) => line.startsWith("ERROR GRAPH_SNAPSHOT_CONTENT_MISMATCH"))).toBe(true); + expect(output.some((line) => line.includes("0 changed") || line.includes("never"))).toBe(false); + + output.length = 0; + await runGraphStatus({ root, json: true }); + expect(JSON.parse(output.join(""))).toMatchObject({ + status: "corrupt", + inspected: false, + lastSuccessfulIndexAt: null, + }); + } finally { + log.mockRestore(); + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); }); diff --git a/src/graph/__tests__/status.test.ts b/src/graph/__tests__/status.test.ts index 1f325230..06864e31 100644 --- a/src/graph/__tests__/status.test.ts +++ b/src/graph/__tests__/status.test.ts @@ -776,12 +776,18 @@ describe("inspectGraphStatus", () => { ...snapshot, sourceCorpusDigest: "0".repeat(64), })); + source(digestRoot, "src/a.ts", "export const a = 2;\n"); const digestMismatch = await inspect(digestRoot); expect(digestMismatch.status).toBe("corrupt"); expect(digestMismatch.diagnostics).toContainEqual(expect.objectContaining({ code: "GRAPH_SNAPSHOT_CONTENT_MISMATCH", })); expect(executableRemediations(digestMismatch)).toContain("mex graph rebuild"); + // Sources were never compared and the index timestamps never populated: + // the placeholder changes and null timestamp must not read as measured. + expect(digestMismatch.inspected).toBe(false); + expect(digestMismatch.lastSuccessfulIndexAt).toBeNull(); + expect(digestMismatch.changes.total).toBe(0); const inconsistentRoot = temporaryRoot("mex-graph-snapshot-mismatch-"); source(inconsistentRoot, "src/a.ts", "export const a = 1;\n"); diff --git a/src/graph/status.ts b/src/graph/status.ts index 1098467f..6af3f52a 100644 --- a/src/graph/status.ts +++ b/src/graph/status.ts @@ -896,11 +896,16 @@ async function inspectGraphStatusAttempt( severity: "error", message: "Graph snapshot source or parse-health totals disagree with the published SQLite rows.", }); + // Parse health was read, but sources were never compared against the + // snapshot and the index timestamps were never populated, so the + // aggregate is a partial inspection: report it as not inspected rather + // than presenting the placeholder changes and null timestamps as facts. return finishDatabaseResult(graphStatus({ status: "corrupt", observedAt, currentRepo, schemaVersion, + inspected: false, parseHealth, changes: changesWithoutIndex(live, currentRepo, maxChangedPaths), diagnostics, diff --git a/test/graph-integration.test.ts b/test/graph-integration.test.ts index c576a42d..f19cb5ec 100644 --- a/test/graph-integration.test.ts +++ b/test/graph-integration.test.ts @@ -163,6 +163,27 @@ describe("code-graph grounding integration", () => { expect(warning).not.toHaveBeenCalled(); }); + it("marks the fallback status as not inspected when the status loader throws", async () => { + const { config } = fixture(); + const warning = vi.fn(); + + const report = await runDriftCheckWithGraphStatus(config, { + scaffoldPatterns: ["ROUTER.md"], + readOnlyGroundingRuntimeLoader: async () => { + throw new Error("simulated status loader failure"); + }, + graphWarning: warning, + }); + + expect(report.graphStatus.status).toBe("degraded"); + expect(report.graphStatus.inspected).toBe(false); + expect(report.graphStatus.diagnostics).toContainEqual(expect.objectContaining({ + code: "GRAPH_STATUS_UNAVAILABLE", + message: "simulated status loader failure", + })); + expect(warning).toHaveBeenCalledWith(expect.stringContaining("Code graph status unavailable")); + }); + it("leaves graph-aware output to first-party renderers unless a warning sink is supplied", async () => { const { config } = fixture(); const warning = vi.spyOn(console, "warn").mockImplementation(() => {});