From 2cabeeac0facba1b696b503388e00f50b09407da Mon Sep 17 00:00:00 2001 From: Yashasvi Date: Thu, 24 Sep 2026 23:48:37 +0530 Subject: [PATCH] fix(grounding): read root grounds_to beside a mex map and consolidate on write (#226) check and wiki for-code read both grounding stores; writes, wiki migrate and set-grounding fold root groundings into the file-level entity's mex.grounds_to, never a section. Report GROUNDING_MIXED_SHAPE (info, warning on a same-node conflict). Setup no longer produces the shape. --- CHANGELOG.md | 1 + src/drift/checkers/grounding-shape.ts | 39 ++ src/drift/checkers/grounding.ts | 7 +- src/drift/index.ts | 8 +- src/graph/cli-ground.ts | 5 + src/markdown.ts | 104 ++++- src/setup/prompts.ts | 4 + src/types.ts | 5 +- .../__tests__/diagnostic-coverage.test.ts | 20 + src/wiki/markdown/__tests__/expectations.ts | 4 +- src/wiki/markdown/codec.ts | 72 ++- src/wiki/markdown/contract.ts | 8 +- src/wiki/markdown/grounding-stores.ts | 88 ++++ .../migration/__tests__/adversarial.test.ts | 47 +- src/wiki/migration/classify.ts | 10 +- src/wiki/migration/ids.ts | 14 + src/wiki/migration/legacy.ts | 87 +++- src/wiki/migration/migrate.ts | 34 +- src/wiki/model/diagnostic.ts | 11 + src/wiki/model/operation.ts | 18 + .../operations/__tests__/operations.test.ts | 55 +++ src/wiki/operations/operations.ts | 73 ++- test/grounding-mixed-shape.test.ts | 427 ++++++++++++++++++ 23 files changed, 1081 insertions(+), 60 deletions(-) create mode 100644 src/drift/checkers/grounding-shape.ts create mode 100644 src/wiki/markdown/grounding-stores.ts create mode 100644 test/grounding-mixed-shape.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fcac7b5e..c8d5cb33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - `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 +- Groundings kept in a root `grounds_to` are no longer invisible in a file that also has a `mex:` map, the shape setup itself produced on every multi-entity context file it populated. `mex check` now reads both keys, deduplicated by node, so drift in code grounded only at the root is reported; on a setup-populated Hono scaffold it had skipped 4 of 24 groundings. The Wiki attaches a root `grounds_to` to the file's file-level entity, never to a section entity, so `wiki for-code` returns it; a file with only section entities still keeps its root groundings unattributed and reported. `wiki migrate` now moves root groundings into the file-level entity whatever the number of sections, including files an earlier run already adopted, and any `mex ground`, sync or setup write that touches such a file moves them under `mex.grounds_to` and removes the root key, leaving the rest of the file byte-identical. `mex check` and `wiki validate` report the split as the new info-level `GROUNDING_MIXED_SHAPE`, raised to a warning when the two keys ground one node differently. In that case the `mex.grounds_to` entry is the one checked and the root one is kept at the root until someone resolves it. Setup's population and `mex graph ground` prompts now tell agents to write under `mex.grounds_to` once a file has a `mex:` map. The accepted cost: the first write to a split file moves its root groundings, and on an adopted file bumps its entity revision, so that file shows a frontmatter diff; a file-level entity whose section children each had their own claim now also carries the file's root groundings (#226). - 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 diff --git a/src/drift/checkers/grounding-shape.ts b/src/drift/checkers/grounding-shape.ts new file mode 100644 index 00000000..bff81aaa --- /dev/null +++ b/src/drift/checkers/grounding-shape.ts @@ -0,0 +1,39 @@ +import type { DriftIssue, ScaffoldFrontmatter } from "../../types.js"; +import { groundingShape } from "../../markdown.js"; + +const FIX = "Any `mex ground` write or `mex wiki migrate` consolidates it under `mex.grounds_to`."; + +/** + * Report a file that keeps groundings both at the root and under `mex:` (#226). + * + * Both are read — the grounding checker sees their union — so this is not a + * missed grounding, only a second store that the next write will fold into + * the first. That is why it is info. A node the two keys ground differently is + * a warning: the checker uses the `mex.grounds_to` entry, and the root one is + * an authored claim nobody is checking until a person picks one. + * + * Needs no graph. The shape is a fact about the Markdown, so it is reported on + * a fresh clone and in CI exactly as it is with an index. + */ +export function checkGroundingShape(frontmatter: ScaffoldFrontmatter | null, source: string): DriftIssue[] { + const shape = groundingShape(frontmatter); + if (!shape.mixed) return []; + if (shape.conflicts.length > 0) { + const nodes = [...new Set(shape.conflicts.map((entry) => entry.node))].join(", "); + return [{ + code: "GROUNDING_MIXED_SHAPE", + severity: "warning", + file: source, + line: null, + message: `Root \`grounds_to\` and \`mex.grounds_to\` ground the same node differently: ${nodes}. ` + + "The `mex.grounds_to` entry is checked; keep the right one and delete the other.", + }]; + } + return [{ + code: "GROUNDING_MIXED_SHAPE", + severity: "info", + file: source, + line: null, + message: `Groundings are split between a root \`grounds_to\` and \`mex.grounds_to\`; both are checked. ${FIX}`, + }]; +} diff --git a/src/drift/checkers/grounding.ts b/src/drift/checkers/grounding.ts index e3b4b48e..21ec7b1d 100644 --- a/src/drift/checkers/grounding.ts +++ b/src/drift/checkers/grounding.ts @@ -60,9 +60,10 @@ export function makeGroundingChecker( // // That is silent, and it is worse than a false positive, because a // scaffold that checks clean is one nobody looks at. `extractGroundings` - // resolves the key path the same way the writer does, so the two ends - // agree; the frontmatter value is the fallback for a file that cannot be - // re-read here, which is the only case the old path still covers. + // reads both keys, because the same silence came back through a file that + // carries a root `grounds_to` beside a `mex:` map (#226); the writer folds + // the two into one. The frontmatter value is the fallback for a file that + // cannot be re-read here, which is the only case the old path still covers. const declared = content === null ? (frontmatter?.grounds_to ?? []) : extractGroundings(content); for (const grounding of declared) { diff --git a/src/drift/index.ts b/src/drift/index.ts index c564947d..91094307 100644 --- a/src/drift/index.ts +++ b/src/drift/index.ts @@ -10,6 +10,7 @@ import { checkEdges } from "./checkers/edges.js"; import { checkIndexSync } from "./checkers/index-sync.js"; import { checkStalePatterns } from "./checkers/stale-pattern.js"; import { checkFrontmatterCompleteness } from "./checkers/frontmatter-completeness.js"; +import { checkGroundingShape } from "./checkers/grounding-shape.js"; import { checkStaleness } from "./checkers/staleness.js"; import { checkCommands } from "./checkers/command.js"; import { checkDependencies } from "./checkers/dependency.js"; @@ -114,7 +115,7 @@ export async function runDriftCheckWithGraphStatus( // root `grounds_to` missed every migrated scaffold, where §13.4 has moved the // key under the `mex` map — so `groundingRelevant` came out false, the // grounding runtime was never opened, and the checker that would have found - // the groundings was never constructed. `extractGroundings` resolves the path. + // the groundings was never constructed. `extractGroundings` reads both keys. const hasGroundings = scaffoldFiles.some((filePath) => { let content: string; try { content = readFileSync(filePath, "utf-8"); } catch { return false; } @@ -224,6 +225,11 @@ export async function runDriftCheckWithGraphStatus( checkerIssueCounts.push([`frontmatter-completeness:${source}`, frontmatterCompletenessIssues.length]); checkerIssueCounts.push([`staleness:${source}`, stalenessIssues.length]); + // Graph-independent: the split is a fact about the Markdown (#226). + const groundingShapeIssues = checkGroundingShape(frontmatter, source); + allIssues.push(...groundingShapeIssues); + checkerIssueCounts.push([`grounding-shape:${source}`, groundingShapeIssues.length]); + if (groundingRuntime) { const groundingIssues = groundingRuntime.checker( frontmatter, filePath, source, projectRoot, scaffoldRoot, diff --git a/src/graph/cli-ground.ts b/src/graph/cli-ground.ts index 945a1a5c..dea894cf 100644 --- a/src/graph/cli-ground.ts +++ b/src/graph/cli-ground.ts @@ -54,6 +54,11 @@ grounds_to: - node: "" fingerprint: "" +If the frontmatter already has a mex: map, the list belongs inside it as +mex.grounds_to (indented under mex:), not at the root. Keep one grounds_to per +file: never add a root one beside a mex: map, and if a file already has both, +move the root entries under mex.grounds_to. + When existing prose already names a load-bearing function, method, or class, wrap only that existing visible mention, preserving its text: [\`symbolName()\`](mex://) diff --git a/src/markdown.ts b/src/markdown.ts index 684ccf20..8cb94294 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -3,7 +3,9 @@ import remarkParse from "remark-parse"; import remarkFrontmatter from "remark-frontmatter"; import { visit } from "unist-util-visit"; import YAML from "yaml"; -import { renderKeyValue, spliceKeyPath, spliceTopLevelKey } from "./wiki/markdown/frontmatter.js"; +import { keyPathEdit, keyPathRemoveEdit, renderKeyValue, spliceTopLevelKey } from "./wiki/markdown/frontmatter.js"; +import { mergeGroundingStores, rootGroundingsNotInEffect } from "./wiki/markdown/grounding-stores.js"; +import { applyEdits, type PatchEdit } from "./wiki/markdown/patch.js"; import { parseDocument } from "./wiki/markdown/parse.js"; import type { Grounding, ScaffoldFrontmatter } from "./types.js"; import type { Root, Content, Link } from "mdast"; @@ -34,35 +36,79 @@ export function extractFrontmatter( } /** - * Where a file's groundings live: under `mex:` once it has one, else at the root. + * Where a file's groundings are **written**: under `mex:` once it has one, else at the root. * * A pre-wiki scaffold keeps `grounds_to` as a root frontmatter key, and that is * the key `mex ground` has always read and written. Once migration adopts a * file as a wiki entity, the entity's metadata is the `mex:` map and the * grounding belongs inside it — section 13.4's "move it under `mex.grounds_to`". * - * **Both the read and the write follow the same rule, and that is the point.** - * Teaching only the reader would leave `writeGroundings` splicing the root key - * back in on the next `mex ground` run, so the file would end up carrying the - * same grounding in two places, maintained by two writers that drift the moment - * either updates — the two-stores-of-one-fact failure D1 exists to forbid, - * arriving through a door D1 did not name. Migration removes the root key as it - * moves the values (`ABSORBABLE_ROOT_KEYS`), so exactly one store survives. + * **The read and the write must agree on one store, and that is the point.** + * Two stores of one fact, maintained by two writers, drift apart the moment + * either updates — the failure D1 exists to forbid. This used to be enforced by + * having the reader follow this path too, so a file with a `mex:` map was read + * only at `mex.grounds_to`. That made the root key invisible rather than + * absent: setup itself produced files with both (population wrote root + * groundings, then migration added a `mex:` map to a multi-entity file without + * moving them), and `mex check` skipped every root entry in them (#226). + * + * So the reader now takes the union of both keys ({@link extractGroundings}), + * and one store is restored by the writer instead: `writeGroundings` on a file + * carrying both **consolidates**, moving root entries under `mex.grounds_to` + * and removing the root key. The rule for the union, and for the one kind of + * root entry a write keeps, is in `src/wiki/markdown/grounding-stores.ts`. * * A file with no `mex:` key is untouched by this: the path is the root key, and * every shipped grounding test exercises that case unchanged. */ export function groundingKeyPath(content: string): readonly string[] { const frontmatter = extractFrontmatter(content) as (ScaffoldFrontmatter & { mex?: unknown }) | null; + return hasMexMap(frontmatter) ? ["mex", "grounds_to"] : ["grounds_to"]; +} + +function hasMexMap(frontmatter: (ScaffoldFrontmatter & { mex?: unknown }) | null): boolean { const mex = frontmatter?.mex; - return mex !== null && typeof mex === "object" ? ["mex", "grounds_to"] : ["grounds_to"]; + return mex !== null && typeof mex === "object"; +} + +/** The groundings a file carries, per store, as its frontmatter declares them. */ +export interface GroundingShape { + /** True when the file has a `mex:` map and a non-empty root `grounds_to`. */ + mixed: boolean; + /** The union a reader sees: `mex` entries, then root entries for other nodes. */ + merged: Grounding[]; + /** Root entries whose node `mex.grounds_to` carries with a different fingerprint or bodyHash. */ + conflicts: Grounding[]; +} + +/** + * Read both grounding stores from an already-parsed frontmatter. + * + * Taking the parsed map rather than the file lets `mex check` report the shape + * from the frontmatter it already read, without a second read of the file. + * Each store is validated as a set on its own, as the single store always was, + * so one malformed key cannot hide the other's entries. + */ +export function groundingShape(frontmatter: ScaffoldFrontmatter | null): GroundingShape { + const withMex = frontmatter as (ScaffoldFrontmatter & { mex?: { grounds_to?: unknown } }) | null; + const rootValue: unknown = withMex?.grounds_to; + const root = isGroundingArray(rootValue) ? rootValue : []; + if (!hasMexMap(withMex)) return { mixed: false, merged: root, conflicts: [] }; + const mexValue: unknown = withMex?.mex?.grounds_to; + const mex = isGroundingArray(mexValue) ? mexValue : []; + const { merged, conflicts } = mergeGroundingStores(mex, root); + return { mixed: Array.isArray(rootValue) && rootValue.length > 0, merged, conflicts }; } -/** Return validated code-graph groundings; malformed entries are rejected as a set. */ +/** + * Return validated code-graph groundings from both stores (#226). + * + * On a file with a `mex:` map the result is the union, deduplicated by node; + * where the two keys disagree about one node, the `mex.grounds_to` entry is + * returned and the disagreement is left for `mex check` to report. + */ export function extractGroundings(content: string): Grounding[] { - const frontmatter = extractFrontmatter(content) as (ScaffoldFrontmatter & { mex?: { grounds_to?: unknown } }) | null; - const value = groundingKeyPath(content)[0] === "mex" ? frontmatter?.mex?.grounds_to : frontmatter?.grounds_to; - return isGroundingArray(value) ? value : []; + return groundingShape(extractFrontmatter(content)).merged; } /** @@ -97,6 +143,17 @@ export function isGroundingArray(value: unknown): value is Grounding[] { * * It now splices the one key's own range. Everything else in the file, byte for * byte, is left as the author wrote it. + * + * `groundings` is the file's whole grounding set, as {@link extractGroundings} + * returned it and the caller changed it. On a file that also carries a root + * `grounds_to` beside its `mex:` map, the write **consolidates** (#226): the + * set goes to `mex.grounds_to` and the root key is removed in the same splice, + * so the next read finds one store. The one exception is a root entry that + * conflicts with the old `mex.grounds_to`. The reader never returned it, so + * this write is not carrying it forward, and it stays at the root for a person + * to resolve. A malformed root key is left alone, as the single-store writer + * always left a value it could not read. A second write of the same set is a + * no-op. */ export function writeGroundings(content: string, groundings: Grounding[]): string { if (!isGroundingArray(groundings)) throw new Error("Invalid grounds_to entries"); @@ -108,10 +165,25 @@ export function writeGroundings(content: string, groundings: Grounding[]): strin if (frontmatter === null) { return spliceTopLevelKey(content, "grounds_to", renderKeyValue("grounds_to", groundings)).text; } - const spliced = spliceKeyPath(content, frontmatter, path, groundings); + const edit = keyPathEdit(content, frontmatter, path, groundings); // A `mex` map that cannot hold the key is not a reason to write a second copy // at the root; it is a reason to leave the file alone and let validation say so. - return spliced === null ? content : spliced.text; + if (edit === null) return content; + const edits: PatchEdit[] = [edit]; + const before = extractFrontmatter(content); + const rootValue: unknown = before?.grounds_to; + if (isGroundingArray(rootValue)) { + const kept = rootGroundingsNotInEffect(groundingShape(before).merged, rootValue); + // An empty root list is a second store too, only an empty one. + if (kept.length < rootValue.length || rootValue.length === 0) { + const root = kept.length === 0 + ? keyPathRemoveEdit(content, frontmatter, ["grounds_to"]) + : keyPathEdit(content, frontmatter, ["grounds_to"], kept); + if (root === null) return content; + if (root !== "absent") edits.push(root); + } + } + return applyEdits(content, edits).text; } export interface MexAnchor { diff --git a/src/setup/prompts.ts b/src/setup/prompts.ts index a9dbe351..f155baf3 100644 --- a/src/setup/prompts.ts +++ b/src/setup/prompts.ts @@ -43,6 +43,10 @@ shadow the source being populated. - node: "" fingerprint: "" + If the file's frontmatter already has a \`mex:\` map, the list belongs inside + it as \`mex.grounds_to\` (indented under \`mex:\`), not at the root. Keep one + \`grounds_to\` per file: never add a root one beside a \`mex:\` map. + Never ground every node returned by scope. Callers/callees provide reading context; they are not automatically grounding targets. Do not ground file, import, parameter, or vague component nodes. diff --git a/src/types.ts b/src/types.ts index ddec1d66..765405b4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -194,7 +194,10 @@ export type IssueCode = // 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"; + | "GROUNDING_UNVERIFIED" + // A file keeps groundings both at the root and under `mex:`. Both are read; + // info, or a warning when the two ground one node differently. See #226. + | "GROUNDING_MIXED_SHAPE"; export interface DriftIssue { code: IssueCode; diff --git a/src/wiki/__tests__/diagnostic-coverage.test.ts b/src/wiki/__tests__/diagnostic-coverage.test.ts index 1dd8f265..db98d897 100644 --- a/src/wiki/__tests__/diagnostic-coverage.test.ts +++ b/src/wiki/__tests__/diagnostic-coverage.test.ts @@ -258,6 +258,26 @@ const EMITTERS: Record readonly WikiDiagnostic[]> = { MALFORMED_GROUNDING: () => validateGrounding({ node: "rotateToken", fingerprint: "x" }, rootContext()).diagnostics, GROUNDING_UNVERIFIED: () => verifyGroundingProvenance([grounding()], () => false), + GROUNDING_MIXED_SHAPE: () => + // #226: a root `grounds_to` beside the file-level `mex` map. + parseWikiMarkdown({ + path: "patterns/rotate.md", + text: [ + "---", + "name: rotate", + "grounds_to:", + ` - node: "${grounding().node}"`, + ` fingerprint: "${grounding().fingerprint}"`, + "mex:", + ` id: ${ids(1)[0]}`, + " type: pattern", + " status: promoted", + "---", + "", + "# Rotate", + "", + ].join("\n"), + }).diagnostics, INVALID_OPERATION_ENVELOPE: () => validateOperation( diff --git a/src/wiki/markdown/__tests__/expectations.ts b/src/wiki/markdown/__tests__/expectations.ts index faacf184..24e06524 100644 --- a/src/wiki/markdown/__tests__/expectations.ts +++ b/src/wiki/markdown/__tests__/expectations.ts @@ -556,7 +556,7 @@ export const FIXTURE_EXPECTATIONS: FixtureExpectation[] = [ // ------------------------------------------------------------------- legacy { path: "legacy/grounds-to-single.md", - note: "A root-level grounds_to on a single-entity file: unambiguous, preserved, and readable alongside the mex key.", + note: "A root-level grounds_to beside the file-level mex key: unambiguous, preserved, attached to the file-level entity, and reported as a split store (#226).", covers: [26], entities: [ { @@ -572,7 +572,7 @@ export const FIXTURE_EXPECTATIONS: FixtureExpectation[] = [ bodyEnds: { at: "eof" }, }, ], - diagnostics: [], + diagnostics: ["GROUNDING_MIXED_SHAPE"], legacy: { groundsTo: 1, edges: 0 }, }, { diff --git a/src/wiki/markdown/codec.ts b/src/wiki/markdown/codec.ts index 503453a5..62bca4a5 100644 --- a/src/wiki/markdown/codec.ts +++ b/src/wiki/markdown/codec.ts @@ -8,9 +8,19 @@ * bad file, and prose must never be lost. * * **It never guesses.** Unbound metadata, two blocks competing for one heading, - * a root `grounds_to` on a multi-entity file — each is reported and left alone. - * A parser that picks a plausible answer attaches somebody's decision record to - * the wrong section, and nobody finds out. + * a root `grounds_to` in a file with no file-level entity — each is reported + * and left alone. A parser that picks a plausible answer attaches somebody's + * decision record to the wrong section, and nobody finds out. + * + * A root `grounds_to` in a file that *has* a file-level entity is not a guess, + * and is attached to that entity (#226). Frontmatter is file-level by nature, + * and the file-level `mex` map is itself frontmatter, so a root grounding beside + * it is a claim about the same document. This used to be refused on any + * multi-entity file, which is how setup's own groundings went missing from + * `wiki for-code`: population wrote them at the root, and migration then gave + * the file a `mex` map and section entities. The narrowing is deliberate and + * one-directional: **a root grounding is never attached to a section entity**, + * and a file with only section entities keeps its root groundings unattributed. * * **Every character is accounted for.** Regions belonging to no entity are * emitted as explicit gaps, so the partition property can prove nothing was @@ -43,6 +53,8 @@ import { bindComments, resolveBodyExtents, type Binding } from "./bind.js"; import { findTopLevelKeyRange, topLevelKeys } from "./frontmatter.js"; import { lineAt, lineStarts } from "./positions.js"; import { associateAnchors } from "./anchors.js"; +import { mergeGroundingStores } from "./grounding-stores.js"; +import { validateGrounding, type WikiGrounding } from "../model/grounding.js"; /** An already-parsed metadata value, as a map, or null when it is not one. */ function asMetadataMap(value: unknown): Record | null { @@ -227,6 +239,58 @@ function bindFrontmatter( }; } +/** + * The file-level `mex` map with the file's root `grounds_to` attached (#226). + * + * The union rule is `grounding-stores.ts`, shared with `mex check`'s reader so + * the two resolve duplicates and conflicts the same way. Each root entry must + * pass the model's own grounding validator first: one malformed root entry + * would otherwise reject the whole entity, turning a legacy key into the loss + * of the entity it sits beside. The one difference from `check` follows from + * that: `check` drops a malformed root list as a set, as it always has, while + * this keeps its valid entries. + * + * Anything this cannot read as a map is returned untouched, so the entity + * validator reports it exactly as it did before. + */ +function withRootGroundings( + path: string, + root: Record, + diagnostics: WikiDiagnostic[], +): unknown { + const mex = root["mex"]; + const map = asMetadataMap(mex); + const rootGroundings = root["grounds_to"]; + if (map === null || !Array.isArray(rootGroundings) || rootGroundings.length === 0) return mex; + const own = map["grounds_to"] ?? []; + if (!Array.isArray(own)) return mex; + + const context = rootContext({ file: path }); + const valid = rootGroundings.filter((entry) => validateGrounding(entry, context).ok) as WikiGrounding[]; + const readable = own.filter((entry): entry is WikiGrounding => { + const record = asMetadataMap(entry); + return record !== null && typeof record["node"] === "string" && typeof record["fingerprint"] === "string"; + }); + const { merged, conflicts } = mergeGroundingStores(readable, valid); + const entityId = typeof map["id"] === "string" ? map["id"] : undefined; + const nodes = [...new Set(conflicts.map((entry) => entry.node))].join(", "); + diagnostics.push(conflicts.length > 0 + ? diagnostic( + "GROUNDING_MIXED_SHAPE", + `${path} grounds ${nodes} differently in its root \`grounds_to\` and in \`mex.grounds_to\`; ` + + "the `mex.grounds_to` entry is the one in effect.", + { file: path, entityId, severity: "warning" }, + ) + : diagnostic( + "GROUNDING_MIXED_SHAPE", + `${path} keeps groundings in both a root \`grounds_to\` and \`mex.grounds_to\`; ` + + "both belong to its file-level entity.", + { file: path, entityId }, + )); + // Append only what the root adds, after the entity's own list as written. + return { ...map, grounds_to: [...own, ...merged.slice(readable.length)] }; +} + /** Every region belonging to no entity, in position order. */ function collectGaps(text: string, entities: readonly ParsedEntity[]): LabeledRange[] { const claimed: { start: number; end: number }[] = []; @@ -318,7 +382,7 @@ export function parseWikiMarkdown(options: ParseOptions): ParsedFile { }), ); } else if (mexRange !== null) { - frontmatterBinding = bindFrontmatter(text, document, block, mexRange, rootMap["mex"]); + frontmatterBinding = bindFrontmatter(text, document, block, mexRange, withRootGroundings(path, rootMap, diagnostics)); } } diff --git a/src/wiki/markdown/contract.ts b/src/wiki/markdown/contract.ts index 5a22146c..659af7ae 100644 --- a/src/wiki/markdown/contract.ts +++ b/src/wiki/markdown/contract.ts @@ -114,9 +114,11 @@ export interface ParsedFrontmatter { /** * Legacy root-level fields. * - * Read but not interpreted. On a multi-entity file a root `grounds_to` is - * genuinely ambiguous — it cannot be attributed to a section without guessing — - * so it is preserved here and reported, never assigned. + * Read but not interpreted. The root `grounds_to` is always recorded here as + * written. Where the file has a file-level entity, the codec also attaches it + * to that entity (#226). Where the file has only section entities it cannot be + * attributed to a section without guessing, so it stays here, reported and + * never assigned. */ export interface ParsedLegacy { groundsTo: WikiGrounding[]; diff --git a/src/wiki/markdown/grounding-stores.ts b/src/wiki/markdown/grounding-stores.ts new file mode 100644 index 00000000..c2145f04 --- /dev/null +++ b/src/wiki/markdown/grounding-stores.ts @@ -0,0 +1,88 @@ +/** + * One file, two places a grounding can live, and the single rule for reading both (#226). + * + * A pre-wiki scaffold keeps `grounds_to` as a root frontmatter key. Once a file + * is adopted as a wiki entity its metadata is the `mex:` map, and groundings + * belong at `mex.grounds_to`. Setup used to leave both behind: population wrote + * root groundings, and migration then added a `mex:` map to a multi-entity file + * without moving them. Reading only one key made the other invisible — `mex + * check` skipped the root entries and `wiki for-code` dropped them — which is + * the silent loss of committed groundings the issue calls the one outcome to + * avoid. + * + * So readers take the **union**, deduplicated by node. Where both keys carry + * the same node with a different `fingerprint` or `bodyHash`, nothing here + * guesses which one the author meant: the `mex.grounds_to` entry is the one + * returned, because it is the store every writer maintains, and the node is + * reported as a conflict so `mex check` and `wiki validate` can say so. + * + * Writers keep one store: they move the root entries under `mex.grounds_to` + * and remove the root key. A root entry that was not in effect is the + * exception — a conflicting one, or one the reader could not accept. No writer + * can claim to have carried it forward, and deleting it would settle the + * question by fiat. It stays at the root, and the diagnostic stays with it, + * until a person resolves it. + * + * Kept free of any parser so the drift reader (`src/markdown.ts`) and the wiki + * codec apply exactly the same rule — two copies of it would be two stores of + * one rule. + */ + +interface GroundingLike { + node: string; + fingerprint: string; + bodyHash?: string; +} + +export interface GroundingStores { + /** `mex.grounds_to` entries, then root entries for nodes it does not carry. */ + merged: T[]; + /** Root entries that carry a node `mex.grounds_to` also carries, differently. */ + conflicts: T[]; +} + +/** Same node with different identity or change evidence. */ +function differs(left: GroundingLike, right: GroundingLike): boolean { + return left.fingerprint !== right.fingerprint || left.bodyHash !== right.bodyHash; +} + +/** Merge the two stores under the rule above. Order: `mex` first, then root. */ +export function mergeGroundingStores( + mex: readonly T[], + root: readonly T[], +): GroundingStores { + const byNode = new Map(); + for (const entry of mex) if (!byNode.has(entry.node)) byNode.set(entry.node, entry); + const merged = [...mex]; + const conflicts: T[] = []; + const seen = new Set(mex.map((entry) => entry.node)); + for (const entry of root) { + const existing = byNode.get(entry.node); + if (existing !== undefined) { + if (differs(existing, entry)) conflicts.push(entry); + continue; + } + if (seen.has(entry.node)) continue; + seen.add(entry.node); + merged.push(entry); + } + return { merged, conflicts }; +} + +/** + * The root entries a consolidating write must leave at the root: every one + * that was **not in effect** before the write — a conflict the `mex.grounds_to` + * entry shadowed, or an entry the reader could not accept. No writer carried + * those forward, so removing them would delete an authored claim rather than + * move it. + * + * Measured against the groundings in effect **before** the write, not after, + * because a write that updates a root-sourced entry's fingerprint is carrying + * that entry forward, not disagreeing with it. + */ +export function rootGroundingsNotInEffect( + inEffect: readonly GroundingLike[], + root: readonly T[], +): T[] { + return root.filter((entry) => !inEffect.some((effective) => effective.node === entry.node && !differs(effective, entry))); +} diff --git a/src/wiki/migration/__tests__/adversarial.test.ts b/src/wiki/migration/__tests__/adversarial.test.ts index 3e57deef..37a2f682 100644 --- a/src/wiki/migration/__tests__/adversarial.test.ts +++ b/src/wiki/migration/__tests__/adversarial.test.ts @@ -27,6 +27,7 @@ import { GENERATED_END, } from "../generated.js"; import { checkOnlyRangesChanged } from "../../markdown/ranges.js"; +import { parseWikiMarkdown } from "../../markdown/codec.js"; function scaffoldOf(files: Record): string { const root = mkdtempSync(join(tmpdir(), "mig-adv-")); @@ -163,24 +164,54 @@ describe("tier 3 — malformed and explicitly null frontmatter", () => { }); describe("tier 3 — a root grounding on a multi-entity file", () => { - it("is reported and left exactly where it was", () => { - const text = - FRONT('grounds_to:\n - node: "function:1c9d4b7e2f5a8036c4e1b9d7a2f60358"\n fingerprint: "mh:64:4b1c7e29"\n') + - "# Architecture\n\nIntro.\n\n## Ingest\n\nProse enough to clear the threshold, over several lines of it here.\nA second line of prose that carries several more words along with it.\nAnd a third line of prose to be certain the bar is cleared.\n\n## Routing\n\nMore prose, also enough to clear the threshold, over several lines here.\nA second line of prose that carries several more words along with it.\nAnd a third line of prose to be certain the bar is cleared.\n"; - const root = scaffoldOf({ "context/architecture.md": text }); + const SECTIONS = + "Intro.\n\n## Ingest\n\nProse enough to clear the threshold, over several lines of it here.\nA second line of prose that carries several more words along with it.\nAnd a third line of prose to be certain the bar is cleared.\n\n## Routing\n\nMore prose, also enough to clear the threshold, over several lines here.\nA second line of prose that carries several more words along with it.\nAnd a third line of prose to be certain the bar is cleared.\n"; + const ROOT = 'grounds_to:\n - node: "function:1c9d4b7e2f5a8036c4e1b9d7a2f60358"\n fingerprint: "mh:64:4b1c7e29"\n'; + + it("moves to the file-level entity, never to a section (#226)", () => { + // This used to stay at the root, which is what setup produced on every + // multi-entity file it populated. The file-level entity is the document the + // frontmatter describes, so attaching it there guesses no section. + const root = scaffoldOf({ "context/architecture.md": FRONT(ROOT) + "# Architecture\n\n" + SECTIONS }); + + const report = migrateScaffold({ scaffoldRoot: root }); + expect(report.groundingsMoved).toBe(1); + expect(report.groundingsAmbiguous).toBe(0); + + const after = readFileSync(join(root, "context", "architecture.md"), "utf-8"); + expect(after).not.toMatch(/^grounds_to:/m); + expect(after).toMatch(/^ fingerprint: "?mh:64:4b1c7e29"?$/m); + // Exactly one copy, and it is the file-level entity's. + expect((after.match(/grounds_to:/g) ?? []).length).toBe(1); + const entities = parseWikiMarkdown({ path: "context/architecture.md", text: after }).entities; + const fileLevel = entities.filter((entry) => entry.metadataKind === "frontmatter"); + expect(fileLevel).toHaveLength(1); + expect(fileLevel[0]!.entity.groundsTo.map((entry) => entry.node)).toEqual(["function:1c9d4b7e2f5a8036c4e1b9d7a2f60358"]); + const sections = entities.filter((entry) => entry.metadataKind !== "frontmatter"); + expect(sections.length).toBeGreaterThan(0); + for (const section of sections) expect(section.entity.groundsTo).toEqual([]); + }); + + it("is reported and left exactly where it was when no file-level entity can own it", () => { + // A risk register yields section entities only. Nothing can say which + // risk a document-level grounding describes (section 9.4), so it stays at + // the root, unattributed and intact. + const text = FRONT(ROOT) + "# Risks\n\n" + SECTIONS; + const root = scaffoldOf({ "context/risks.md": text }); const report = migrateScaffold({ scaffoldRoot: root }); expect(report.groundingsMoved).toBe(0); expect(report.groundingsAmbiguous).toBe(1); expect(report.diagnostics.filter((entry) => entry.code === "AMBIGUOUS_MIGRATION").length).toBe(1); - const after = readFileSync(join(root, "context", "architecture.md"), "utf-8"); - // Section 9.4: nothing can say which section a document-level grounding - // describes, so it stays at the root, unattributed and intact. + const after = readFileSync(join(root, "context", "risks.md"), "utf-8"); expect(after).toMatch(/^grounds_to:/m); expect(after).toContain(' fingerprint: "mh:64:4b1c7e29"'); // And there is exactly one copy of it: reported does not mean duplicated. expect((after.match(/grounds_to:/g) ?? []).length).toBe(1); + const entities = parseWikiMarkdown({ path: "context/risks.md", text: after }).entities; + expect(entities.length).toBeGreaterThan(0); + for (const entry of entities) expect(entry.entity.groundsTo).toEqual([]); }); }); diff --git a/src/wiki/migration/classify.ts b/src/wiki/migration/classify.ts index b83e6f20..e40e00f0 100644 --- a/src/wiki/migration/classify.ts +++ b/src/wiki/migration/classify.ts @@ -200,6 +200,14 @@ function targetOf(heading: RawHeading, ordinal: number): AdoptionTarget { return { at: "heading", ordinal, text: heading.title, depth: heading.depth, start: heading.start }; } +/** + * Why a file that already carries entities is skipped. Exported because it is + * the one skip reason that still lets migration fold a root `grounds_to` into + * the file's existing file-level entity (#226); every other skip means the file + * is not migration's to write. + */ +export const ALREADY_ADOPTED_REASON = "already carries entity metadata"; + /** * Classify one file. * @@ -232,7 +240,7 @@ export function classifyFile(file: InventoryFile): FileClassification { // A file that already carries entities has been migrated. Section 13.3: a // file with valid ids is skipped, never regenerated. if (file.parsed.entities.length > 0) { - return { ...result, skipped: true, skipReason: "already carries entity metadata" }; + return { ...result, skipped: true, skipReason: ALREADY_ADOPTED_REASON }; } // A file the codec could not read is a file migration must not write into. diff --git a/src/wiki/migration/ids.ts b/src/wiki/migration/ids.ts index ad055676..1aea1692 100644 --- a/src/wiki/migration/ids.ts +++ b/src/wiki/migration/ids.ts @@ -84,3 +84,17 @@ export function opIdForCandidate(file: InventoryFile, candidate: Candidate): str export function opIdForEdge(sourceFile: string, edgeTarget: string, index: number): string { return migrationOpId("add-relation", sourceFile, `e:${index}:${edgeTarget}`); } + +/** + * The `opId` for folding a file's root `grounds_to` into its existing + * file-level entity (#226). + * + * Keyed on the groundings moved as well as the file. Once applied the root key + * is gone and nothing is planned again; if someone later writes a new root + * `grounds_to` into the same file, that is new work with a different payload, + * and reusing the old `opId` for it would be refused as a replay mismatch. + */ +export function opIdForRootGroundings(file: string, groundings: readonly unknown[]): string { + const digest = createHash("sha256").update(JSON.stringify(groundings), "utf8").digest("hex"); + return migrationOpId("set-grounding", file, `r:${digest.slice(0, 16)}`); +} diff --git a/src/wiki/migration/legacy.ts b/src/wiki/migration/legacy.ts index dd251652..6c2bdd77 100644 --- a/src/wiki/migration/legacy.ts +++ b/src/wiki/migration/legacy.ts @@ -29,21 +29,35 @@ * has a file-level entity — the file-level entity *is* the document — or when * it yields exactly one entity of any kind. * - * A **grounding** is stricter than either, and deliberately: it is a claim that - * *this prose* is implemented by *that code*. Attributing a file's grounding to - * a file-level entity that has four component children claims the grounding - * describes the parent rather than one of the children, which is precisely the - * guess section 9.4 refuses. So a root `grounds_to` moves only when the file - * yields **exactly one** entity in total, matching the codec's own rule and the - * two shipped fixtures that pin it. + * A **grounding** is a claim that *this prose* is implemented by *that code*, + * and a root `grounds_to` is frontmatter: a claim the file makes about itself, + * with nothing in it naming a section. So it moves to the **file-level** entity + * whenever the file has or gets one, however many section entities sit beside + * it, and it is **never** attributed to a section entity (#226). + * + * This used to be stricter: a root `grounds_to` moved only when the file + * yielded exactly one entity, on the reasoning that attaching it to a parent + * with section children claimed it described the parent rather than a child. + * But the file-level entity is the whole document, which is what the author + * put it on, and refusing left it at the root beside a new `mex:` map. Setup + * produced exactly that on every multi-entity file it populated, and the Wiki + * then dropped those groundings. Attaching to the file-level entity narrows + * "never guesses" rather than abandoning it: the only guess refused was ever + * which *section*, and that is still refused. A file with only section + * entities keeps its root groundings, reported and unattributed. + * + * A file adopted before this rule is folded the same way: its root groundings + * move into the existing file-level entity's `mex.grounds_to`, as a move of + * values already in the file, never re-derived from the graph. */ import { diagnostic, type WikiDiagnostic } from "../model/diagnostic.js"; import type { EntityId } from "../model/ids.js"; import type { WikiGrounding } from "../model/grounding.js"; import type { LegacyEdge } from "../markdown/contract.js"; +import { rootGroundingsNotInEffect } from "../markdown/grounding-stores.js"; import type { GroundingGraph } from "../grounding/adapter.js"; import type { InventoryFile, ScaffoldInventory } from "./inventory.js"; -import type { Candidate, FileClassification } from "./classify.js"; +import { ALREADY_ADOPTED_REASON, type Candidate, type FileClassification } from "./classify.js"; /** Frontmatter keys migration preserves untouched. Section 13.4's first bullet. */ export const PRESERVED_LEGACY_KEYS = ["name", "description", "triggers", "last_updated", "edges"] as const; @@ -159,6 +173,12 @@ export function planLegacyEdges( export interface GroundingPlan { /** Groundings to move under `mex.grounds_to`, keyed by file. */ moved: Map; + /** + * Files adopted by an earlier run whose root `grounds_to` folds into their + * existing file-level entity (#226), keyed by file. `groundsTo` is that + * entity's grounding set as the codec reads it, root entries included. + */ + absorbed: Map; diagnostics: WikiDiagnostic[]; } @@ -175,6 +195,14 @@ export interface GroundingPlan { * the scaffold, written by a previous `mex ground`. So section 12.4's * re-derivation requirement, which governs *new* groundings, is not what * applies here; moving a fact is not asserting a new one. + * + * A file that already has a file-level entity is folded without `backfill`: + * the codec already reads those groundings as the entity's, so the move must + * leave them exactly as they read. Its root entries that are not in effect — + * conflicting with the entity's own `mex.grounds_to`, or rejected by the + * grounding validator — stay at the root and are reported, because choosing + * between two authored values for one node, or repairing a malformed one, is + * a decision migration does not make. */ export function planGroundingMoves( inventory: ScaffoldInventory, @@ -183,24 +211,48 @@ export function planGroundingMoves( graph: GroundingGraph | null, ): GroundingPlan { const moved = new Map(); + const absorbed: GroundingPlan["absorbed"] = new Map(); const diagnostics: WikiDiagnostic[] = []; for (const file of inventory.files) { const groundings = file.parsed.legacy.groundsTo; if (groundings.length === 0) continue; - const candidates = candidatesFor(file.path); - const existing = file.parsed.entities.length; - const total = candidates.length + existing; - const fileLevel = candidates.find((candidate) => candidate.target.at === "file"); + const adopted = file.parsed.entities.find((entry) => entry.metadataKind === "frontmatter"); + if (adopted !== undefined) { + // Any other skip — a Team-owned path, a non-knowledge file — means the + // file is not migration's to write, so nothing here touches it. + if (classifications.get(file.path)?.skipReason !== ALREADY_ADOPTED_REASON) continue; + const kept = rootGroundingsNotInEffect(adopted.entity.groundsTo, groundings); + if (kept.length > 0) { + diagnostics.push( + diagnostic( + "AMBIGUOUS_MIGRATION", + `${file.path} has root \`grounds_to\` entries for ${[...new Set(kept.map((entry) => entry.node))].join(", ")} ` + + "that are not in effect: each conflicts with `mex.grounds_to` or is malformed. They are preserved " + + "at the root; keep the right entry under `mex.grounds_to` and delete the root one.", + { file: file.path, entityId: adopted.entity.id }, + ), + ); + } + if (kept.length < groundings.length) { + absorbed.set(file.path, { + entityId: adopted.entity.id, + groundsTo: adopted.entity.groundsTo, + count: groundings.length - kept.length, + }); + } + continue; + } - if (total !== 1 || fileLevel === undefined) { + const fileLevel = candidatesFor(file.path).find((candidate) => candidate.target.at === "file"); + if (fileLevel === undefined) { diagnostics.push( diagnostic( "AMBIGUOUS_MIGRATION", - `${file.path} carries a root \`grounds_to\` and yields ${total} entities. A grounding claims that ` + - "particular prose is implemented by particular code, and nothing here says which section it " + - "describes. It is preserved at the root and left unattributed.", + `${file.path} carries a root \`grounds_to\` but has no file-level entity to own it. A grounding ` + + "claims that particular prose is implemented by particular code, and nothing here says which " + + "section it describes. It is preserved at the root and left unattributed.", { file: file.path }, ), ); @@ -208,10 +260,9 @@ export function planGroundingMoves( } moved.set(file.path, groundings.map((grounding) => backfill(grounding, graph))); - void classifications; } - return { moved, diagnostics }; + return { moved, absorbed, diagnostics }; } /** Add a `bodyHash` the graph can re-derive; never invent one. */ diff --git a/src/wiki/migration/migrate.ts b/src/wiki/migration/migrate.ts index e3e1bf82..d9cacaa0 100644 --- a/src/wiki/migration/migrate.ts +++ b/src/wiki/migration/migrate.ts @@ -28,6 +28,7 @@ import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { ENTITY_ID_LENGTH, ENTITY_ID_PREFIX, isEntityId, type EntityId } from "../model/ids.js"; +import type { WikiGrounding } from "../model/grounding.js"; import type { AbsorbableRootKey, WikiActor, WikiOperation } from "../model/operation.js"; import type { WikiEntityType } from "../model/entity.js"; import type { EntityTypeRegistry } from "../model/entity.js"; @@ -49,7 +50,7 @@ import { acquireWikiMaintenanceLease, type WikiMaintenanceLease } from "../index import { readContainedSource } from "../index/source-read.js"; import { inventoryScaffold, type InventoryFile, type ScaffoldInventory } from "./inventory.js"; import { classifyFile, orderForAdoption, type Abstention, type Candidate, type FileClassification } from "./classify.js"; -import { opIdForCandidate, opIdForEdge } from "./ids.js"; +import { opIdForCandidate, opIdForEdge, opIdForRootGroundings } from "./ids.js"; import { planGroundingMoves, planLegacyEdges, type FileOutcome } from "./legacy.js"; export interface MigrateOptions { @@ -338,7 +339,8 @@ function migrationReportFromInventory( (path) => classifications.get(path)?.candidates ?? [], options.graph ?? null, ); - report.groundingsMoved = [...groundings.moved.values()].reduce((sum, list) => sum + list.length, 0); + report.groundingsMoved = [...groundings.moved.values()].reduce((sum, list) => sum + list.length, 0) + + [...groundings.absorbed.values()].reduce((sum, entry) => sum + entry.count, 0); report.groundingsAmbiguous = groundings.diagnostics.length; report.diagnostics.push(...groundings.diagnostics); @@ -437,6 +439,21 @@ function envelope( }; } +/** A `set-grounding` that only moves a file's root `grounds_to` into its file-level entity. */ +function absorbEnvelope( + path: string, + absorb: { entityId: EntityId; groundsTo: WikiGrounding[] }, + options: MigrateOptions, +): unknown { + return envelope( + opIdForRootGroundings(path, absorb.groundsTo), + "set-grounding", + { groundsTo: absorb.groundsTo, absorbRootGroundings: true }, + options, + absorb.entityId, + ); +} + /** * Section 13 — run the migration. * @@ -567,6 +584,15 @@ function migrateScaffoldHeld(options: MigrateOptions & { maintenanceLease: WikiM minted.set(file.path, ids); } + // -- pass 1b: root groundings of files an earlier run adopted (#226) -------- + for (const [path, absorb] of groundingPlan.absorbed) { + const result = applyOperation(absorbEnvelope(path, absorb, options), applyOptions()); + report.diagnostics.push(...result.diagnostics); + if (!result.ok) continue; + report.groundingsMoved += absorb.count; + if (!result.replayed) for (const changedPath of result.changedFiles) changed.add(changedPath); + } + // -- pass 2: legacy edges, over a fresh inventory --------------------------- // // Re-read rather than reasoned about: pass 1 wrote bytes, so the entities on @@ -825,6 +851,10 @@ export function planPinnedMigration(options: MigrateOptions): PinnedMigrationPla minted.set(file.path, ids); } + for (const [path, absorb] of groundingPlan.absorbed) { + remember(planOperation(absorbEnvelope(path, absorb, options), planOptions())); + } + // Resolve legacy edges over the virtual post-adoption tree, exactly as the // applying migration's second pass would see it. const afterObserved = inventoryScaffold({ diff --git a/src/wiki/model/diagnostic.ts b/src/wiki/model/diagnostic.ts index 08b98fde..2be61d54 100644 --- a/src/wiki/model/diagnostic.ts +++ b/src/wiki/model/diagnostic.ts @@ -206,6 +206,17 @@ export const WIKI_DIAGNOSTICS = { severity: "error", remediation: "Node id and fingerprint must come from live graph output. MEX will not accept caller-supplied values.", }, + /** + * Groundings split between a root `grounds_to` and the file-level `mex` map + * (#226). Info, because both are read and the root ones belong to the + * file-level entity; raised to a warning where the two keys ground one node + * differently, since only the `mex.grounds_to` entry is then in effect. + */ + GROUNDING_MIXED_SHAPE: { + severity: "info", + remediation: + "Any `mex ground` write or `mex wiki migrate` moves the root `grounds_to` under `mex.grounds_to`. A node grounded differently in both stays at the root until you keep one and delete the other.", + }, // -- Operations ------------------------------------------------------------ INVALID_OPERATION_ENVELOPE: { diff --git a/src/wiki/model/operation.ts b/src/wiki/model/operation.ts index 5346270d..8e226154 100644 --- a/src/wiki/model/operation.ts +++ b/src/wiki/model/operation.ts @@ -218,6 +218,19 @@ export interface SetGroundingPayload { * a change the author did not request. */ updateAnchors?: boolean; + /** + * Move the file's root `grounds_to` under this file-level entity's + * `mex.grounds_to`, and assert nothing else (#226). + * + * Migration's consolidation of a file it adopted before root groundings were + * attachable. Like `adopt.absorbRootKeys`, this relocates values already in + * the user's Markdown rather than minting new ones, so it is not re-derived + * from the graph — re-deriving would record today's body hash and quietly + * accept whatever drift the old one would have caught. In exchange, + * `groundsTo` must equal the entity's current groundings exactly, so the + * operation can move a grounding and cannot introduce one. + */ + absorbRootGroundings?: boolean; } export interface SupersedeEntryPayload { @@ -510,6 +523,11 @@ const PAYLOAD_VALIDATORS: { [K in WikiOperationType]: Validator + typeof value === "boolean" + ? succeed(value) + : reject(context, "INVALID_OPERATION_PAYLOAD", "`absorbRootGroundings` must be a boolean."), + ), }), "supersede-entry": supersedeValidator, "move-entry": validateShape({ diff --git a/src/wiki/operations/__tests__/operations.test.ts b/src/wiki/operations/__tests__/operations.test.ts index f7a7dbd6..0dbb45d0 100644 --- a/src/wiki/operations/__tests__/operations.test.ts +++ b/src/wiki/operations/__tests__/operations.test.ts @@ -24,6 +24,7 @@ import type { GroundingGraph } from "../../grounding/adapter.js"; import { acceptedOperations, operationLogPath, readAuditLog } from "../audit.js"; import { ARCH, + ARCH_MD, GATEWAY, JWT, PATTERN, @@ -963,3 +964,57 @@ describe("approval evidence in one Wiki operation", () => { expect(acceptedOperations(readAuditLog(target.root))).toHaveLength(0); }); }); + +describe("set-grounding and a root `grounds_to` (#226)", () => { + const ROOT_ENTRY = { node: "function:1c9d4b7e2f5a8036c4e1b9d7a2f60358", fingerprint: "mh:64:4b1c7e29", bodyHash: "a".repeat(64) }; + const withRoot = (): string => ARCH_MD.replace( + "last_updated: 2026-08-22\n", + `last_updated: 2026-08-22\ngrounds_to:\n - node: ${ROOT_ENTRY.node}\n fingerprint: ${ROOT_ENTRY.fingerprint}\n bodyHash: ${ROOT_ENTRY.bodyHash}\n`, + ); + + it("absorbs the root key into the file-level entity without re-deriving it", () => { + const target = scaffold({ "context/architecture.md": withRoot() }); + expect(target.entity(ARCH).groundsTo).toEqual([ROOT_ENTRY]); + // No graph at all: a move of values already in the file needs none. + const applied = applyOperation(envelope(target, "set-grounding", { + groundsTo: [ROOT_ENTRY], absorbRootGroundings: true, + }, { entityId: ARCH }), { scaffoldRoot: target.root }); + expect(applied.ok ? [] : codesOf(applied.diagnostics)).toEqual([]); + const after = target.read("context/architecture.md"); + expect(after).not.toMatch(/^grounds_to:/m); + expect(target.entity(ARCH).groundsTo).toEqual([ROOT_ENTRY]); + expect(parseWikiMarkdown({ path: "context/architecture.md", text: after }).diagnostics).toEqual([]); + expect(after).toContain("# keep this note; a whole-map rewrite would eat it"); + }); + + it("refuses to absorb into a section entity", () => { + const target = scaffold({ "context/architecture.md": withRoot() }); + const before = target.files(); + const applied = applyOperation(envelope(target, "set-grounding", { + groundsTo: [], absorbRootGroundings: true, + }, { entityId: GATEWAY }), { scaffoldRoot: target.root }); + expect(applied.ok).toBe(false); + expect(codesOf(applied.diagnostics)).toContain("INVALID_OPERATION_PAYLOAD"); + expect(target.files()).toEqual(before); + }); + + it("refuses an absorb that would change the groundings it moves", () => { + const target = scaffold({ "context/architecture.md": withRoot() }); + const before = target.files(); + const applied = applyOperation(envelope(target, "set-grounding", { + groundsTo: [{ ...ROOT_ENTRY, bodyHash: "c".repeat(64) }], absorbRootGroundings: true, + }, { entityId: ARCH }), { scaffoldRoot: target.root }); + expect(applied.ok).toBe(false); + expect(codesOf(applied.diagnostics)).toContain("INVALID_OPERATION_PAYLOAD"); + expect(target.files()).toEqual(before); + }); + + it("replaces the root key too on an explicit set-grounding, so removed groundings stay removed", () => { + const target = scaffold({ "context/architecture.md": withRoot() }); + const applied = applyOperation(envelope(target, "set-grounding", groundingPayload(), { entityId: ARCH }), + { scaffoldRoot: target.root, graph: stubGraph } as never); + expect(applied.ok ? [] : codesOf(applied.diagnostics)).toEqual([]); + expect(target.read("context/architecture.md")).not.toMatch(/^grounds_to:/m); + expect(target.entity(ARCH).groundsTo.map((entry) => entry.node)).toEqual([NODE]); + }); +}); diff --git a/src/wiki/operations/operations.ts b/src/wiki/operations/operations.ts index 7b22cadb..9da01ed9 100644 --- a/src/wiki/operations/operations.ts +++ b/src/wiki/operations/operations.ts @@ -31,7 +31,9 @@ import type { WikiEntity, WikiLifecycleState, WikiProvenance } from "../model/en import type { WikiGrounding } from "../model/grounding.js"; import { deriveVerifiedGroundings } from "../grounding/provenance.js"; import type { PatchEdit } from "../markdown/patch.js"; -import { keyPathRemoveEdit, renderKeyValues } from "../markdown/frontmatter.js"; +import YAML from "yaml"; +import { keyPathEdit, keyPathRemoveEdit, renderKeyValues } from "../markdown/frontmatter.js"; +import { rootGroundingsNotInEffect } from "../markdown/grounding-stores.js"; import { entityTextOf } from "../markdown/codec.js"; import { parseDocument } from "../markdown/parse.js"; import { entityContentHash } from "../model/hash.js"; @@ -693,6 +695,7 @@ function removeSource(context: OperationContext, operation: Extract): OperationEdits { const located = context.located!; + if (operation.payload.absorbRootGroundings === true) return absorbRootGroundings(context, operation.payload.groundsTo); const derived = deriveVerifiedGroundings(operation.payload.groundsTo, context.options.graph ?? null); if (!derived.ok) { return { files: [], entityIds: [], createdIds: [], preconditions: [], revisions: [], diagnostics: derived.diagnostics }; @@ -705,9 +708,77 @@ function setGrounding(context: OperationContext, operation: Extract canonicalJson(entry) !== canonicalJson(current[index]))) { + return reject( + "INVALID_OPERATION_PAYLOAD", + `absorbRootGroundings moves ${located.entity.id}'s existing groundings and cannot change them; the requested list differs.`, + located.entity.id, + ); + } + const kept = rootGroundingsNotInEffect(current, located.parsed.legacy.groundsTo); + const root = rootGroundingsEdit(located, kept); + if (root === null) return reject("WIKI_PARSE_ERROR", `${located.path}: could not locate the root \`grounds_to\` key.`, located.entity.id); + return mutateSubject(context, [[METADATA_KEYS.groundsTo, current.map((entry) => ({ ...entry }))]], root); +} + +/** Remove the root `grounds_to` of a file-level entity's file, or narrow it to `kept`. */ +function rootGroundingsEdit(located: LocatedEntity, kept: readonly WikiGrounding[]): PatchEdit[] | null { + if (located.metadataKind !== "frontmatter" || !located.parsed.frontmatter?.keys.includes("grounds_to")) return []; + const region = parseDocument(located.text).frontmatter; + if (region === null) return null; + const root = located.parsed.legacy.groundsTo; + // A root value with entries the codec could not read as groundings is left + // alone, as every grounding writer leaves a value it cannot read. + let raw: unknown; + try { raw = (YAML.parse(region.text) as Record | null)?.["grounds_to"]; } catch { return []; } + if (!Array.isArray(raw) || raw.length !== root.length) return []; + if (kept.length === root.length && kept.length > 0) return []; + const edit = kept.length === 0 + ? keyPathRemoveEdit(located.text, region, ["grounds_to"]) + : keyPathEdit(located.text, region, ["grounds_to"], kept); + if (edit === null) return null; + return edit === "absent" ? [] : [edit]; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value !== null && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson((value as Record)[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + /** * Rewrite inline anchors to match a new grounding — only where it is unambiguous. * diff --git a/test/grounding-mixed-shape.test.ts b/test/grounding-mixed-shape.test.ts new file mode 100644 index 00000000..3f1b45b1 --- /dev/null +++ b/test/grounding-mixed-shape.test.ts @@ -0,0 +1,427 @@ +/** + * #226 — a file that keeps groundings both at the root and under `mex:`. + * + * Setup produced this shape itself: population wrote root `grounds_to`, then + * migration gave multi-entity files a `mex:` map without moving them. On a + * setup-populated Hono scaffold `mex check` then skipped 4 of 24 groundings and + * `wiki for-code` returned 11 of 24. These tests pin the four halves of the + * fix: the union read, the consolidating write, the file-level Wiki attachment, + * and a setup path that no longer produces the shape. + */ + +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import YAML from "yaml"; +import type { Grounding, MexConfig } from "../src/types.js"; +import { extractGroundings, writeGroundings } from "../src/markdown.js"; +import { runDriftCheckWithGraphStatus } from "../src/drift/index.js"; +import { createGraphEngine } from "../src/graph/engine-impl.js"; +import { loadGroundingRuntime } from "../src/graph/runtime.js"; +import { serializeFingerprint } from "../src/graph/fingerprint.js"; +import { rebuildWikiIndex } from "../src/wiki/index/rebuild.js"; +import { knowledgeRecordsFor } from "../src/wiki/cli/for-code.js"; +import { migrateScaffold } from "../src/wiki/migration/migrate.js"; +import { validateScaffold } from "../src/wiki/validation/validate.js"; +import { parseWikiMarkdown } from "../src/wiki/markdown/codec.js"; +import { finalizeCodeRepoSetup } from "../src/setup/index.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +const NODE_A = "function:1c9d4b7e2f5a8036c4e1b9d7a2f60358"; +const NODE_B = "function:a3f8c21d9e4b7f60a1c2d3e4f5061728"; +const A: Grounding = { node: NODE_A, fingerprint: "mh:64:4b1c7e29", bodyHash: "a".repeat(64) }; +const B: Grounding = { node: NODE_B, fingerprint: "mh:64:9f2a4c6e", bodyHash: "b".repeat(64) }; + +const ARCH_ID = "mx_01KR2E4K002H3ZYA9G0C4XV531"; +const INGEST_ID = "mx_01KR2E4K002H3ZYA9G0C4XV532"; +const ROUTING_ID = "mx_01KR2E4K002H3ZYA9G0C4XV533"; +const CONV_ID = "mx_01KR2E4K002H3ZYA9G0C4XV534"; +const ERRORS_ID = "mx_01KR2E4K002H3ZYA9G0C4XV535"; +const PATTERN_ID = "mx_01KR2E4K002H3ZYA9G0C4XV536"; + +function yamlList(key: string, groundings: readonly Grounding[], indent = ""): string { + if (groundings.length === 0) return `${indent}${key}: []\n`; + return `${indent}${key}:\n` + groundings.map((entry) => + `${indent} - node: ${entry.node}\n${indent} fingerprint: ${entry.fingerprint}\n` + + (entry.bodyHash === undefined ? "" : `${indent} bodyHash: ${entry.bodyHash}\n`)).join(""); +} + +/** Hono's `architecture.md` shape: root groundings, then a `mex:` map added by migration. */ +function mixedDoc(root: readonly Grounding[], mex: readonly Grounding[] | null, id = ARCH_ID): string { + return "---\nname: architecture\n# authored comment, kept byte for byte\ndescription: \"How it fits\"\n" + + yamlList("grounds_to", root) + + "last_updated: 2026-09-21\n" + + `mex:\n id: ${id}\n type: architecture\n status: promoted\n revision: 2\n` + + (mex === null ? "" : yamlList("grounds_to", mex, " ")) + + "---\n\n# Architecture\n\nBody prose.\n"; +} + +const SECTION_PROSE = + "Prose enough to clear the threshold, over several lines of it here.\n" + + "A second line of prose that carries several more words along with it.\n" + + "And a third line of prose to be certain the bar is cleared.\n"; + +describe("extractGroundings reads both stores (#226)", () => { + it("reads a root-only file", () => { + const text = `---\nname: x\n${yamlList("grounds_to", [A, B])}---\n\n# X\n`; + expect(extractGroundings(text).map((entry) => entry.node)).toEqual([NODE_A, NODE_B]); + }); + + it("reads a mex-only file", () => { + expect(extractGroundings(mixedDoc([], [A])).map((entry) => entry.node)).toEqual([NODE_A]); + }); + + it("reads the union of a mixed file, mex entries first", () => { + expect(extractGroundings(mixedDoc([B], [A])).map((entry) => entry.node)).toEqual([NODE_A, NODE_B]); + // Hono's own shape: root groundings beside a `mex:` map that has none. + expect(extractGroundings(mixedDoc([A, B], null)).map((entry) => entry.node)).toEqual([NODE_A, NODE_B]); + }); + + it("deduplicates a node both stores carry identically", () => { + expect(extractGroundings(mixedDoc([A, B], [A]))).toEqual([A, B]); + }); + + it("returns the mex entry for a node the two stores ground differently", () => { + const stale = { ...A, bodyHash: "c".repeat(64) }; + expect(extractGroundings(mixedDoc([stale, B], [A]))).toEqual([A, B]); + }); + + it("keeps one store's entries when the other is malformed", () => { + const text = mixedDoc([], [A]).replace("grounds_to: []\n", "grounds_to:\n - node: 42\n"); + expect(extractGroundings(text)).toEqual([A]); + }); +}); + +describe("writeGroundings consolidates a mixed file (#226)", () => { + it("moves root entries under mex.grounds_to, removes the root key, and touches nothing else", () => { + const before = mixedDoc([A, B], null); + const after = writeGroundings(before, extractGroundings(before)); + + const frontmatter = YAML.parse(after.split("---\n")[1]!) as Record; + expect(frontmatter["grounds_to"]).toBeUndefined(); + expect((frontmatter["mex"] as Record)["grounds_to"]).toEqual([A, B]); + expect(extractGroundings(after)).toEqual([A, B]); + + // Everything outside the two keys is byte-identical. + const strip = (text: string) => text + .replace(/^grounds_to:\n(?: {2}.*\n)*/m, "") + .replace(/^ {2}grounds_to:\n(?: {4}.*\n)*/m, ""); + expect(strip(after)).toBe(strip(before)); + expect(after).toContain("# authored comment, kept byte for byte\n"); + + // A second write of the same set is a no-op. + expect(writeGroundings(after, extractGroundings(after))).toBe(after); + }); + + it("merges into an existing mex.grounds_to and drops an identical root duplicate", () => { + const before = mixedDoc([A, B], [A]); + const after = writeGroundings(before, extractGroundings(before)); + expect(after).not.toMatch(/^grounds_to:/m); + expect((after.match(/grounds_to:/g) ?? []).length).toBe(1); + expect(extractGroundings(after)).toEqual([A, B]); + }); + + it("removes an empty root list beside a mex map", () => { + const before = mixedDoc([], [A]); + const after = writeGroundings(before, [A]); + expect(after).not.toMatch(/^grounds_to:/m); + expect(after.replace(/^grounds_to: \[\]\n/m, "")).toBe(before.replace(/^grounds_to: \[\]\n/m, "")); + }); + + it("keeps a conflicting root entry at the root, and moves the rest", () => { + const stale = { ...A, bodyHash: "c".repeat(64) }; + const before = mixedDoc([stale, B], [A]); + const after = writeGroundings(before, extractGroundings(before)); + const frontmatter = YAML.parse(after.split("---\n")[1]!) as Record; + expect(frontmatter["grounds_to"]).toEqual([stale]); + expect((frontmatter["mex"] as Record)["grounds_to"]).toEqual([A, B]); + expect(writeGroundings(after, extractGroundings(after))).toBe(after); + }); + + it("consolidates a CRLF file whose root key is the last frontmatter key", () => { + const before = ("---\nname: x\nmex:\n id: " + ARCH_ID + "\n type: pattern\n status: promoted\n" + + yamlList("grounds_to", [A]) + "---\n\n# X\n").replace(/\n/g, "\r\n"); + const after = writeGroundings(before, extractGroundings(before)); + expect(after).not.toMatch(/^grounds_to:/m); + expect(after).not.toMatch(/(? { + const multi = (rootGroundings: readonly Grounding[]) => + "---\nname: architecture\n" + yamlList("grounds_to", rootGroundings) + + `mex:\n id: ${ARCH_ID}\n type: architecture\n status: promoted\n revision: 1\n---\n\n# Architecture\n\nIntro.\n\n` + + `\n## Ingest\n\n${SECTION_PROSE}\n` + + `\n## Routing\n\n${SECTION_PROSE}`; + + it("gives the file-level entity the root groundings and every section none", () => { + const parsed = parseWikiMarkdown({ path: "context/architecture.md", text: multi([A, B]) }); + const byId = new Map(parsed.entities.map((entry) => [entry.entity.id as string, entry.entity])); + expect(byId.get(ARCH_ID)!.groundsTo.map((entry) => entry.node)).toEqual([NODE_A, NODE_B]); + expect(byId.get(INGEST_ID)!.groundsTo).toEqual([]); + expect(byId.get(ROUTING_ID)!.groundsTo).toEqual([]); + expect(parsed.diagnostics.map((entry) => [entry.code, entry.severity])).toEqual([["GROUNDING_MIXED_SHAPE", "info"]]); + }); + + it("warns on a same-node conflict and keeps the mex entry", () => { + const stale = { ...A, bodyHash: "c".repeat(64) }; + const text = multi([stale]).replace(" revision: 1\n---", " revision: 1\n" + yamlList("grounds_to", [A], " ") + "---"); + const parsed = parseWikiMarkdown({ path: "context/architecture.md", text }); + const fileLevel = parsed.entities.find((entry) => entry.entity.id === ARCH_ID)!; + expect(fileLevel.entity.groundsTo).toEqual([A]); + expect(parsed.diagnostics.map((entry) => [entry.code, entry.severity])).toEqual([["GROUNDING_MIXED_SHAPE", "warning"]]); + }); + + it("never attaches a root grounding in a file with only section entities", () => { + const text = "---\nname: risks\n" + yamlList("grounds_to", [A]) + "---\n\n# Risks\n\n" + + `\n## One\n\n${SECTION_PROSE}`; + const parsed = parseWikiMarkdown({ path: "context/risks.md", text }); + expect(parsed.entities).toHaveLength(1); + expect(parsed.entities[0]!.entity.groundsTo).toEqual([]); + expect(parsed.legacy.groundsTo).toHaveLength(1); + }); + + it("drops a malformed root entry rather than the entity beside it", () => { + const text = multi([A]).replace(`fingerprint: ${A.fingerprint}`, "fingerprint: not-a-fingerprint"); + const parsed = parseWikiMarkdown({ path: "context/architecture.md", text }); + expect(parsed.entities.map((entry) => entry.entity.id)).toContain(ARCH_ID); + expect(parsed.entities.find((entry) => entry.entity.id === ARCH_ID)!.entity.groundsTo).toEqual([]); + }); +}); + +describe("wiki migrate folds root groundings into an adopted file-level entity (#226)", () => { + function scaffold(files: Record): string { + const root = mkdtempSync(join(tmpdir(), "mex-226-migrate-")); + roots.push(root); + for (const [path, text] of Object.entries(files)) { + mkdirSync(join(root, path, ".."), { recursive: true }); + writeFileSync(join(root, path), text); + } + return root; + } + + it("moves them, leaves one store, validates clean of the shape, and a second run changes nothing", () => { + const root = scaffold({ "context/architecture.md": mixedDoc([A, B], null) }); + expect(validateScaffold({ scaffoldRoot: root }).diagnostics.map((entry) => entry.code)).toContain("GROUNDING_MIXED_SHAPE"); + + const report = migrateScaffold({ scaffoldRoot: root }); + expect(report.groundingsMoved).toBe(2); + expect(report.groundingsAmbiguous).toBe(0); + const after = readFileSync(join(root, "context", "architecture.md"), "utf-8"); + expect(after).not.toMatch(/^grounds_to:/m); + expect((after.match(/grounds_to:/g) ?? []).length).toBe(1); + expect(extractGroundings(after)).toEqual([A, B]); + expect(after).toContain("# authored comment, kept byte for byte\n"); + expect(validateScaffold({ scaffoldRoot: root }).diagnostics.map((entry) => entry.code)).not.toContain("GROUNDING_MIXED_SHAPE"); + + const again = migrateScaffold({ scaffoldRoot: root }); + expect(again.groundingsMoved).toBe(0); + expect(readFileSync(join(root, "context", "architecture.md"), "utf-8")).toBe(after); + }); + + it("keeps a malformed root entry it could not move, rather than deleting it", () => { + const bad = { node: NODE_B, fingerprint: "not-a-fingerprint" }; + const root = scaffold({ "context/architecture.md": mixedDoc([A, bad], null) }); + const report = migrateScaffold({ scaffoldRoot: root }); + expect(report.groundingsMoved).toBe(1); + expect(report.diagnostics.filter((entry) => entry.code === "AMBIGUOUS_MIGRATION")).toHaveLength(1); + const frontmatter = YAML.parse(readFileSync(join(root, "context", "architecture.md"), "utf-8").split("---\n")[1]!) as Record; + expect(frontmatter["grounds_to"]).toEqual([bad]); + expect((frontmatter["mex"] as Record)["grounds_to"]).toEqual([A]); + }); + + it("keeps a conflicting root entry and reports it", () => { + const stale = { ...A, bodyHash: "c".repeat(64) }; + const root = scaffold({ "context/architecture.md": mixedDoc([stale, B], [A]) }); + const report = migrateScaffold({ scaffoldRoot: root }); + expect(report.groundingsMoved).toBe(1); + expect(report.diagnostics.filter((entry) => entry.code === "AMBIGUOUS_MIGRATION")).toHaveLength(1); + const frontmatter = YAML.parse(readFileSync(join(root, "context", "architecture.md"), "utf-8").split("---\n")[1]!) as Record; + expect(frontmatter["grounds_to"]).toEqual([stale]); + expect((frontmatter["mex"] as Record)["grounds_to"]).toEqual([A, B]); + }); +}); + +describe("check reports the split without a graph (#226)", () => { + it("is info for a plain split and a warning for a same-node conflict", async () => { + const root = mkdtempSync(join(tmpdir(), "mex-226-shape-")); + roots.push(root); + const scaffoldRoot = join(root, ".mex"); + mkdirSync(join(scaffoldRoot, "context"), { recursive: true }); + writeFileSync(join(scaffoldRoot, "ROUTER.md"), "# Router\n"); + writeFileSync(join(scaffoldRoot, "context", "architecture.md"), mixedDoc([B], [A])); + writeFileSync(join(scaffoldRoot, "context", "conventions.md"), + mixedDoc([{ ...A, bodyHash: "c".repeat(64) }], [A], CONV_ID).replace("name: architecture", "name: conventions")); + writeFileSync(join(scaffoldRoot, "context", "stack.md"), mixedDoc([], [A], PATTERN_ID).replace("name: architecture", "name: stack")); + + const report = await runDriftCheckWithGraphStatus({ projectRoot: root, scaffoldRoot, aiTools: [] }, { graphWarning: () => {} }); + const shape = report.issues.filter((issue) => issue.code === "GROUNDING_MIXED_SHAPE"); + expect(shape.map((issue) => [issue.file, issue.severity]).sort()).toEqual([ + [".mex/context/architecture.md", "info"], + [".mex/context/conventions.md", "warning"], + ]); + expect(shape.find((issue) => issue.severity === "warning")!.message).toContain(NODE_A); + }); +}); + +// -- Against a real graph ----------------------------------------------------- + +const APP = `export function compose(middleware: Array<(next: () => number) => number>): () => number { + let index = -1; + const dispatch = (i: number): number => { + if (i <= index) throw new Error("next() called multiple times"); + index = i; + const handler = middleware[i]; + return handler ? handler(() => dispatch(i + 1)) : 0; + }; + return () => dispatch(0); +} + +export function errorHandler(error: Error): { status: number; body: string } { + // Unknown errors are reported as a generic 500. + const status = error.name === "HTTPException" ? 400 : 500; + return { status, body: status === 500 ? "Internal Server Error" : error.message }; +} +`; + +interface Project { root: string; config: MexConfig; scaffoldRoot: string } + +function project(prefix: string): Project { + const root = mkdtempSync(join(tmpdir(), prefix)); + roots.push(root); + mkdirSync(join(root, "src"), { recursive: true }); + mkdirSync(join(root, ".mex", "context"), { recursive: true }); + mkdirSync(join(root, ".mex", "patterns"), { recursive: true }); + writeFileSync(join(root, ".mex", "ROUTER.md"), "# Router\n"); + writeFileSync(join(root, "src", "app.ts"), APP); + const scaffoldRoot = join(root, ".mex"); + return { root, scaffoldRoot, config: { projectRoot: root, scaffoldRoot, aiTools: [] } }; +} + +async function buildGraph(root: string): Promise { + const engine = createGraphEngine({ rootDir: root }); + await engine.build(); + engine.close(); +} + +/** Graph-derived grounding for one symbol, as setup's population agent copies it. */ +async function groundingFor(config: MexConfig, symbol: string, withBodyHash: boolean): Promise { + const runtime = await loadGroundingRuntime(config); + try { + const node = runtime!.graph.searchNodes(symbol).find((entry) => entry.kind === "function" && entry.name === symbol)!; + const fingerprint = serializeFingerprint(runtime!.reconciler.getFingerprint(node.id)!); + return withBodyHash ? { node: node.id, fingerprint, bodyHash: node.bodyHash! } : { node: node.id, fingerprint }; + } finally { + runtime!.close(); + } +} + +const multiEntityDoc = (name: string, type: string, rootGroundings: readonly Grounding[], ids: readonly string[] | null) => + `---\nname: ${name}\ndescription: "${name}"\n` + yamlList("grounds_to", rootGroundings) + "last_updated: 2026-09-21\n" + + (ids === null ? "" : `mex:\n id: ${ids[0]}\n type: ${type}\n status: promoted\n revision: 2\n`) + + `---\n\n# ${name}\n\nIntro.\n\n` + + (ids === null ? "" : `\n`) + + `## First\n\n${SECTION_PROSE}\n` + + (ids === null ? "" : `\n`) + + `## Second\n\n${SECTION_PROSE}`; + +/** The survey on a fixture: what `check` and `wiki for-code` each see. */ +async function seen(p: Project, nodes: readonly string[]): Promise<{ forCode: string[] }> { + rebuildWikiIndex({ scaffoldRoot: p.scaffoldRoot, indexPath: join(p.scaffoldRoot, "wiki.db") }); + const records = knowledgeRecordsFor(nodes, { scaffoldRoot: p.scaffoldRoot }, p.root); + return { forCode: records.map((record) => record.id).sort() }; +} + +describe("a setup-shaped scaffold against a real graph (#226)", () => { + it("check reports drift on root groundings in mixed files, as the issue's compose()/errorHandler scenario", async () => { + const p = project("mex-226-check-"); + await buildGraph(p.root); + const compose = await groundingFor(p.config, "compose", true); + const errorHandler = await groundingFor(p.config, "errorHandler", true); + + // The pattern grounds compose() under mex.grounds_to; architecture.md and + // conventions.md carry setup's root groundings beside their `mex:` maps. + writeFileSync(join(p.scaffoldRoot, "patterns", "compose.md"), + `---\nname: compose\nmex:\n id: ${PATTERN_ID}\n type: pattern\n status: promoted\n revision: 1\n` + + yamlList("grounds_to", [compose], " ") + "---\n\n# Compose\n\nHow middleware composes.\n"); + writeFileSync(join(p.scaffoldRoot, "context", "architecture.md"), + multiEntityDoc("architecture", "architecture", [compose], [ARCH_ID, INGEST_ID, ROUTING_ID])); + writeFileSync(join(p.scaffoldRoot, "context", "conventions.md"), + multiEntityDoc("conventions", "convention", [errorHandler], [CONV_ID, ERRORS_ID, "mx_01KR2E4K002H3ZYA9G0C4XV537"])); + + const clean = await runDriftCheckWithGraphStatus(p.config, { graphWarning: () => {} }); + expect(clean.graphStatus?.status).toBe("fresh"); + expect(clean.issues.filter((issue) => issue.code === "GROUNDING_DRIFT")).toEqual([]); + + // Body edit in compose(), comment-only edit inside errorHandler(). + writeFileSync(join(p.root, "src", "app.ts"), APP + .replace("if (i <= index) throw", "if (i < index + 1) throw") + .replace("reported as a generic 500", "reported as an opaque 500")); + (await loadGroundingRuntime(p.config))!.close(); + + const drifted = await runDriftCheckWithGraphStatus(p.config, { graphWarning: () => {} }); + expect(drifted.graphStatus?.status).toBe("fresh"); + expect(drifted.issues.filter((issue) => issue.code === "GROUNDING_DRIFT").map((issue) => issue.file).sort()).toEqual([ + ".mex/context/architecture.md", + ".mex/context/conventions.md", + ".mex/patterns/compose.md", + ]); + // Both mixed files are reported as split stores, at info. + expect(clean.issues.filter((issue) => issue.code === "GROUNDING_MIXED_SHAPE") + .map((issue) => [issue.file, issue.severity]).sort()).toEqual([ + [".mex/context/architecture.md", "info"], + [".mex/context/conventions.md", "info"], + ]); + }, 60_000); + + it("wiki for-code returns root groundings on the file-level entity, never on a section", async () => { + const p = project("mex-226-forcode-"); + await buildGraph(p.root); + const compose = await groundingFor(p.config, "compose", true); + writeFileSync(join(p.scaffoldRoot, "context", "architecture.md"), + multiEntityDoc("architecture", "architecture", [compose], [ARCH_ID, INGEST_ID, ROUTING_ID])); + // A file with only section entities: its root grounding has no owner. + writeFileSync(join(p.scaffoldRoot, "context", "risks.md"), + "---\nname: risks\n" + yamlList("grounds_to", [compose]) + "---\n\n# Risks\n\n" + + `\n## One\n\n${SECTION_PROSE}`); + + expect((await seen(p, [compose.node])).forCode).toEqual([ARCH_ID]); + }, 60_000); + + it("setup finalization leaves no file with both stores, and every grounding reachable", async () => { + const p = project("mex-226-setup-"); + await buildGraph(p.root); + const compose = await groundingFor(p.config, "compose", false); + const errorHandler = await groundingFor(p.config, "errorHandler", false); + + // What population writes on a fresh scaffold: root groundings, no `mex:` map + // yet, in files migration will split into a file-level entity plus sections. + const files = { + architecture: join(p.scaffoldRoot, "context", "architecture.md"), + conventions: join(p.scaffoldRoot, "context", "conventions.md"), + }; + writeFileSync(files.architecture, multiEntityDoc("architecture", "architecture", [compose], null)); + writeFileSync(files.conventions, multiEntityDoc("conventions", "convention", [errorHandler], null)); + + await finalizeCodeRepoSetup(p.root, p.scaffoldRoot); + + for (const path of Object.values(files)) { + const text = readFileSync(path, "utf-8"); + const frontmatter = YAML.parse(text.split("---\n")[1]!) as Record; + expect(frontmatter["mex"]).toBeDefined(); + expect(frontmatter["grounds_to"]).toBeUndefined(); + expect(extractGroundings(text)).toHaveLength(1); + } + expect(existsSync(join(p.scaffoldRoot, "wiki.db"))).toBe(true); + const forCode = knowledgeRecordsFor([compose.node, errorHandler.node], { scaffoldRoot: p.scaffoldRoot }, p.root); + expect(forCode.map((record) => record.file).sort()).toEqual(["context/architecture.md", "context/conventions.md"]); + }, 90_000); +});