Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions src/drift/checkers/grounding-shape.ts
Original file line number Diff line number Diff line change
@@ -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}`,
}];
}
7 changes: 4 additions & 3 deletions src/drift/checkers/grounding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion src/drift/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/graph/cli-ground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ grounds_to:
- node: "<exact graph node id>"
fingerprint: "<exact mh:64:... 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://<exact-node-id>)
Expand Down
104 changes: 88 additions & 16 deletions src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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");
Expand All @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/setup/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ shadow the source being populated.
- node: "<exact id from graph JSONL>"
fingerprint: "<exact fingerprint from the same graph fact>"

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.
Expand Down
5 changes: 4 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions src/wiki/__tests__/diagnostic-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,26 @@ const EMITTERS: Record<string, () => 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(
Expand Down
4 changes: 2 additions & 2 deletions src/wiki/markdown/__tests__/expectations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -572,7 +572,7 @@ export const FIXTURE_EXPECTATIONS: FixtureExpectation[] = [
bodyEnds: { at: "eof" },
},
],
diagnostics: [],
diagnostics: ["GROUNDING_MIXED_SHAPE"],
legacy: { groundsTo: 1, edges: 0 },
},
{
Expand Down
Loading
Loading