@henols/vice-mcp 0.1.12 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,531 @@
1
+ #!/usr/bin/env node
2
+ // r2000-memmap-render.ts -- the ONE authoritative place in this repo that
3
+ // renders the human-readable Markdown memory map from the r2000 annotation
4
+ // store (D-24) plus a validated run-scoped provenance sidecar (D-27's
5
+ // reconciliation, recorded in 11-10-PLAN.md's objective).
6
+ //
7
+ // WHY THIS MODULE EXISTS (D-24): the store is canonical; the Markdown memory
8
+ // map becomes a rendered VIEW. Criterion 1 says findings must be queryable
9
+ // "instead of re-deriving from Markdown prose" -- that is only true by
10
+ // construction if the prose is GENERATED from the queryable thing. Nothing
11
+ // downstream of this module may hand-author an address row: every row in
12
+ // the Range/Contents/Confidence/Evidence table comes from
13
+ // `r2000_get_blocks`/`r2000_get_symbols`/`r2000_get_comments`, never from a
14
+ // human editing the output file directly.
15
+ //
16
+ // THE D-24/D-27 RECONCILIATION THIS FILE IMPLEMENTS: run-scoped facts (the
17
+ // capture's SHA-256, `$01`, `$DD00`, the derived graphics chain, the video
18
+ // standard, the live vector pair, observed raster positions) are facts about
19
+ // a RUN, not about an address -- the store is address-keyed and has no shape
20
+ // for them. They arrive here as an INPUT to the renderer (a JSON sidecar,
21
+ // `parseProvenanceHeader()`'s own schema), never as a hand-edited region of
22
+ // this module's OUTPUT. A missing or malformed required sidecar key is a
23
+ // named error listing every problem at once; this module never substitutes a
24
+ // `<placeholder>` for one.
25
+ //
26
+ // WHY THE LAYOUT IS EMBEDDED IN TYPESCRIPT RATHER THAN READ FROM A TEMPLATE
27
+ // FILE AT RUNTIME (the second decision this plan records): Phase 10's D-06
28
+ // established that `.claude/mcp/vice/*.ts` exists as files on disk only
29
+ // under the Claude Code plugin route -- both npm-installer routes launch via
30
+ // `npx`. A renderer that resolved a template path into the skills tree at
31
+ // runtime would silently fail to resolve for an npm-installed user. The
32
+ // recon skill's own template becomes prose pointing at this generator
33
+ // instead (a later plan's job); this module hardcodes the target shape.
34
+ //
35
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR:
36
+ // - the provenance sidecar schema (`ProvenanceHeader`,
37
+ // `parseProvenanceHeader()`) -- nowhere else in this repo may hand-parse
38
+ // or hand-validate that JSON shape;
39
+ // - rendering the memory map (`renderMemoryMap()`) -- nowhere else may
40
+ // assemble the Range/Contents/Confidence/Evidence table or the banner;
41
+ // - drift detection (`checkRenderedMemoryMap()`) -- the one place a
42
+ // rendered file on disk is compared against what the store (plus the
43
+ // sidecar) would produce right now;
44
+ // - Markdown-cell escaping (`escapeMarkdownCell()`, WR-04, closed) --
45
+ // every store-derived text interpolation in the generated document
46
+ // (comment evidence, symbol names) is escaped through this one
47
+ // function, never a second ad hoc `.replace()` at a call site.
48
+ //
49
+ // WHAT NOT TO DO, named concretely:
50
+ // - Never hand-edit the rendered output. The banner exists precisely so a
51
+ // human editor is caught by `checkRenderedMemoryMap()` -- see the
52
+ // `render_digest` comment below for exactly what it covers.
53
+ // - Never read the layout from the skills tree at runtime (Phase 10 D-06).
54
+ // This module's own non-vacuity test asserts a zero-count grep for the
55
+ // recon skill's template filename -- if you are tempted to add a
56
+ // `readFileSync()` call reaching into `.claude/skills/`, don't; the
57
+ // layout lives here, in TypeScript, by design.
58
+ // - Never substitute a placeholder for a missing or malformed sidecar key.
59
+ // `parseProvenanceHeader()` throws, naming every problem at once, rather
60
+ // than rendering a document that LOOKS complete but silently carries a
61
+ // `<hash>`-shaped lie.
62
+ // - Never write an address row from anywhere but the store. If a future
63
+ // caller wants to add a derived-but-not-address-keyed fact (a new
64
+ // run-scoped field), it joins `ProvenanceHeader`'s schema, not a second
65
+ // ad hoc parameter to `renderMemoryMap()`.
66
+ import { existsSync, readFileSync } from "node:fs";
67
+ import { createHash } from "node:crypto";
68
+
69
+ import { runR2000Tool } from "./r2000-tools.ts";
70
+ import { CONFIDENCE_GRADES, parseConfidencePrefix } from "./r2000-confidence.ts";
71
+ import type { ConfidenceGrade } from "./r2000-confidence.ts";
72
+
73
+ function errMsg(err: unknown): string {
74
+ return err instanceof Error ? err.message : String(err);
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // The r2000 query result shapes this renderer consumes, measured live
79
+ // against a real regenerator2000-core-0.9.20 `--mcp-server-stdio` child
80
+ // (never transcribed from a document): `r2000_get_blocks` returns
81
+ // `{start_address, end_address, type}`; `r2000_get_symbols` returns
82
+ // `{address, name, kind, type}`; `r2000_get_comments` returns
83
+ // `{address, comment, type}`.
84
+ // ---------------------------------------------------------------------------
85
+
86
+ interface R2000Block {
87
+ start_address: number;
88
+ end_address: number;
89
+ type: string;
90
+ }
91
+
92
+ interface R2000Symbol {
93
+ address: number;
94
+ name: string;
95
+ kind: string;
96
+ type: string;
97
+ }
98
+
99
+ interface R2000Comment {
100
+ address: number;
101
+ comment: string;
102
+ type: "line" | "side";
103
+ }
104
+
105
+ async function queryR2000Json<T>(name: string, args: Record<string, unknown>): Promise<T> {
106
+ const result = await runR2000Tool(name, args);
107
+ if (result.isError) {
108
+ throw new Error(`${name} failed: ${result.content[0]?.text ?? "(no message)"}`);
109
+ }
110
+ return JSON.parse(result.content[0]!.text) as T;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // The provenance sidecar schema.
115
+ // ---------------------------------------------------------------------------
116
+
117
+ export interface ProvenanceHeader {
118
+ capturePath: string;
119
+ /** 64 lowercase or uppercase hex characters -- the capture's SHA-256. */
120
+ captureSha256: string;
121
+ /** The `$01` port value, e.g. `"$35"`. */
122
+ port01: string;
123
+ /** The `$DD00` value, e.g. `"$06"`. */
124
+ dd00: string;
125
+ /** The VIC bank derived from `$DD00` bits 0-1 (inverted), e.g. `"0 ($0000-$3FFF)"`. */
126
+ vicBank: string;
127
+ /** Screen RAM derived from `$D018` bits 4-7, e.g. `"$0400"`. */
128
+ screenRam: string;
129
+ /** Charset/bitmap derived from `$D018` bits 1-3, e.g. `"$1000 (ROM shadow)"`. */
130
+ charsetOrBitmap: string;
131
+ /** The graphics mode derived from `$D011` bits 5-6 and `$D016` bit 4, e.g. `"text, multicolor off"`. */
132
+ mode: string;
133
+ videoStandard: "PAL" | "NTSC";
134
+ /** The live vector pair in effect, e.g. `"$0314/$0315"` or `"$FFFE/$FFFF"`. */
135
+ liveVectorPair: string;
136
+ /** The address (or label) the live vector pair points at. */
137
+ vectorHandler: string;
138
+ /** One entry per observed `$D012` write on the way out of a handler. Optional. */
139
+ rasterPositions?: string[];
140
+ }
141
+
142
+ const REQUIRED_STRING_KEYS: readonly (keyof ProvenanceHeader)[] = [
143
+ "capturePath",
144
+ "captureSha256",
145
+ "port01",
146
+ "dd00",
147
+ "vicBank",
148
+ "screenRam",
149
+ "charsetOrBitmap",
150
+ "mode",
151
+ "videoStandard",
152
+ "liveVectorPair",
153
+ "vectorHandler",
154
+ ];
155
+
156
+ /** A template placeholder is anything shaped like `<...>` -- the recon
157
+ * template's own placeholders (`<hash>`, `<PAL/NTSC>`, `<n>`, `<value>`,
158
+ * `<handler>`, ...) are exactly this shape, and the most likely thing to be
159
+ * copied into a sidecar by accident. */
160
+ const PLACEHOLDER_PATTERN = /^<.*>$/;
161
+
162
+ export class R2000ProvenanceHeaderError extends Error {
163
+ /** Every problem found, one entry per offending key -- a caller filling a
164
+ * sidecar wants the whole list, not one problem at a time. */
165
+ problems: readonly string[];
166
+
167
+ constructor(message: string, problems: readonly string[]) {
168
+ super(message);
169
+ this.name = "R2000ProvenanceHeaderError";
170
+ this.problems = problems;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Parses and validates a provenance sidecar. Collects EVERY problem (a
176
+ * missing key, a non-string value, a template placeholder, a malformed
177
+ * `captureSha256`, an invalid `videoStandard`, a malformed
178
+ * `rasterPositions`) into one list and throws `R2000ProvenanceHeaderError`
179
+ * naming all of them at once -- never one at a time.
180
+ */
181
+ export function parseProvenanceHeader(json: unknown): ProvenanceHeader {
182
+ const problems: string[] = [];
183
+
184
+ if (typeof json !== "object" || json === null || Array.isArray(json)) {
185
+ throw new R2000ProvenanceHeaderError(
186
+ `provenance sidecar must be a JSON object, got ${Array.isArray(json) ? "an array" : typeof json}`,
187
+ ["<root>: must be a JSON object"],
188
+ );
189
+ }
190
+ const obj = json as Record<string, unknown>;
191
+
192
+ for (const key of REQUIRED_STRING_KEYS) {
193
+ const value = obj[key];
194
+ if (typeof value !== "string" || value.trim() === "") {
195
+ problems.push(`${key}: missing or not a non-empty string`);
196
+ continue;
197
+ }
198
+ if (PLACEHOLDER_PATTERN.test(value.trim())) {
199
+ problems.push(`${key}: still carries a template placeholder (${value}) -- fill in the real value`);
200
+ }
201
+ }
202
+
203
+ const sha = obj.captureSha256;
204
+ if (typeof sha === "string" && sha.trim() !== "" && !PLACEHOLDER_PATTERN.test(sha.trim())) {
205
+ if (!/^[0-9a-fA-F]{64}$/.test(sha.trim())) {
206
+ problems.push(`captureSha256: must be exactly 64 hex characters, got "${sha}" (length ${sha.trim().length})`);
207
+ }
208
+ }
209
+
210
+ const vs = obj.videoStandard;
211
+ if (typeof vs === "string" && vs.trim() !== "" && !PLACEHOLDER_PATTERN.test(vs.trim())) {
212
+ if (vs !== "PAL" && vs !== "NTSC") {
213
+ problems.push(`videoStandard: must be exactly "PAL" or "NTSC", got "${vs}"`);
214
+ }
215
+ }
216
+
217
+ let rasterPositions: string[] | undefined;
218
+ if (obj.rasterPositions !== undefined) {
219
+ const rp = obj.rasterPositions;
220
+ if (!Array.isArray(rp) || rp.some((v) => typeof v !== "string")) {
221
+ problems.push("rasterPositions: when present must be an array of strings");
222
+ } else {
223
+ rasterPositions = rp as string[];
224
+ }
225
+ }
226
+
227
+ if (problems.length > 0) {
228
+ throw new R2000ProvenanceHeaderError(
229
+ `provenance sidecar has ${problems.length} problem(s):\n` + problems.map((p) => ` - ${p}`).join("\n"),
230
+ problems,
231
+ );
232
+ }
233
+
234
+ return {
235
+ capturePath: obj.capturePath as string,
236
+ captureSha256: (obj.captureSha256 as string).trim(),
237
+ port01: obj.port01 as string,
238
+ dd00: obj.dd00 as string,
239
+ vicBank: obj.vicBank as string,
240
+ screenRam: obj.screenRam as string,
241
+ charsetOrBitmap: obj.charsetOrBitmap as string,
242
+ mode: obj.mode as string,
243
+ videoStandard: obj.videoStandard as "PAL" | "NTSC",
244
+ liveVectorPair: obj.liveVectorPair as string,
245
+ vectorHandler: obj.vectorHandler as string,
246
+ rasterPositions,
247
+ };
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+ // The render digest -- documented exactly, because a digest whose inputs are
252
+ // unclear is a digest nobody trusts. It covers, in order: a canonical JSON
253
+ // serialisation of the SORTED `r2000_get_blocks`/`r2000_get_symbols`/
254
+ // `r2000_get_comments` results (so a store-side change, e.g. a comment's
255
+ // confidence grade, changes the digest even with the rendered file
256
+ // untouched), the raw provenance sidecar BYTES (not the parsed object, so
257
+ // even whitespace-only sidecar edits are covered), and this renderer's own
258
+ // version constant (so a future format change is distinguishable from a
259
+ // hand edit).
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /** Bumped whenever this renderer's OUTPUT SHAPE changes, so a re-render
263
+ * under a new renderer version is distinguishable from drift under the same
264
+ * one. Version 2 (this plan, 260821-a86) escapes Markdown table cells via
265
+ * `escapeMarkdownCell()` -- WR-04. */
266
+ export const RENDERER_VERSION = "2";
267
+
268
+ /**
269
+ * Escapes `text` for safe interpolation into a Markdown table cell or list
270
+ * item: every `|` becomes `\|`, and every `\r\n`/`\n`/bare `\r` collapses to
271
+ * `<br>` (a single-line-safe line break inside a table cell). This control
272
+ * ESCAPES and never REJECTS -- unlike the label-name policy
273
+ * (`r2000-acme-ident.ts`'s `assertLegalAcmeIdentifier()`, T-11-NAME-INJECT's
274
+ * other leg), because comment `evidence` legitimately contains `|` and
275
+ * embedded newlines (`r2000_set_comment`'s own schema documents multi-line
276
+ * support) -- refusing here would refuse valid data, not an attack. Closes
277
+ * WR-04 / T-11-NAME-INJECT's render leg: an unescaped `|` or newline in
278
+ * store text used to be able to inject an extra table cell or split a row
279
+ * across lines in the generated Markdown. A plain string or an empty string
280
+ * is returned unchanged. */
281
+ export function escapeMarkdownCell(text: string): string {
282
+ return text.replace(/\|/g, "\\|").replace(/\r\n|\r|\n/g, "<br>");
283
+ }
284
+
285
+ function computeRenderDigest(
286
+ blocks: readonly R2000Block[],
287
+ symbols: readonly R2000Symbol[],
288
+ comments: readonly R2000Comment[],
289
+ sidecarBytes: string,
290
+ ): string {
291
+ const canonical = JSON.stringify({ blocks, symbols, comments }) + "" + sidecarBytes + "" + RENDERER_VERSION;
292
+ return createHash("sha256").update(canonical, "utf8").digest("hex");
293
+ }
294
+
295
+ function hex4(addr: number): string {
296
+ return `$${addr.toString(16).toUpperCase().padStart(4, "0")}`;
297
+ }
298
+
299
+ interface GradedComment {
300
+ address: number;
301
+ grade: ConfidenceGrade | null;
302
+ evidence: string;
303
+ }
304
+
305
+ // ---------------------------------------------------------------------------
306
+ // renderMemoryMap()
307
+ // ---------------------------------------------------------------------------
308
+
309
+ export interface RenderMemoryMapOptions {
310
+ projectPath: string;
311
+ provenancePath: string;
312
+ }
313
+
314
+ export interface RenderMemoryMapResult {
315
+ markdown: string;
316
+ renderDigest: string;
317
+ /** Number of Range/Contents/Confidence/Evidence rows emitted. */
318
+ rowCount: number;
319
+ /** Number of comments carrying the `[unknown]` grade -- the Open questions count. */
320
+ unknownCount: number;
321
+ }
322
+
323
+ /**
324
+ * Renders the memory map from the r2000 store plus a validated provenance
325
+ * sidecar. Queries `r2000_get_blocks`/`r2000_get_symbols`/`r2000_get_comments`
326
+ * through `r2000-tools.ts`'s curated, allow-listed `runR2000Tool()` -- never
327
+ * `r2000-mcp-client.ts` directly (mirrors every other consumer's discipline
328
+ * in this repo).
329
+ *
330
+ * A malformed confidence prefix inside a store comment (a typo that survived
331
+ * whatever wrote it) THROWS through `parseConfidencePrefix()` -- this
332
+ * renderer never silently drops or blanks a grade it cannot parse; the typo
333
+ * must be fixed in the store, not hidden in the rendered view.
334
+ */
335
+ export async function renderMemoryMap(opts: RenderMemoryMapOptions): Promise<RenderMemoryMapResult> {
336
+ const { projectPath, provenancePath } = opts;
337
+
338
+ let sidecarBytes: string;
339
+ try {
340
+ sidecarBytes = readFileSync(provenancePath, "utf8");
341
+ } catch (err) {
342
+ throw new Error(`renderMemoryMap: could not read provenance sidecar at "${provenancePath}": ${errMsg(err)}`);
343
+ }
344
+
345
+ let sidecarJson: unknown;
346
+ try {
347
+ sidecarJson = JSON.parse(sidecarBytes);
348
+ } catch (err) {
349
+ throw new Error(`renderMemoryMap: provenance sidecar at "${provenancePath}" is not valid JSON: ${errMsg(err)}`);
350
+ }
351
+ const provenance = parseProvenanceHeader(sidecarJson);
352
+
353
+ const blocks = await queryR2000Json<R2000Block[]>("r2000_get_blocks", { project: projectPath });
354
+ const symbols = await queryR2000Json<R2000Symbol[]>("r2000_get_symbols", { project: projectPath });
355
+ const comments = await queryR2000Json<R2000Comment[]>("r2000_get_comments", {
356
+ project: projectPath,
357
+ type: "line",
358
+ });
359
+
360
+ const sortedBlocks = [...blocks].sort((a, b) => a.start_address - b.start_address);
361
+ const sortedSymbols = [...symbols].sort((a, b) => a.address - b.address);
362
+ const sortedComments = [...comments].sort((a, b) => a.address - b.address);
363
+
364
+ const gradedComments: GradedComment[] = sortedComments.map((c) => {
365
+ const parsed = parseConfidencePrefix(c.comment);
366
+ return { address: c.address, grade: parsed.grade, evidence: parsed.rest };
367
+ });
368
+
369
+ function findGradeInRange(startAddr: number, endAddr: number): GradedComment | undefined {
370
+ return gradedComments.find((c) => c.address >= startAddr && c.address <= endAddr);
371
+ }
372
+
373
+ const renderDigest = computeRenderDigest(sortedBlocks, sortedSymbols, sortedComments, sidecarBytes);
374
+
375
+ const lines: string[] = [];
376
+
377
+ lines.push("<!--");
378
+ lines.push(" GENERATED by `vice-mcp r2000 render-memmap` -- do not hand-edit; re-run the generator.");
379
+ lines.push(` store: ${projectPath}`);
380
+ lines.push(` sidecar: ${provenancePath}`);
381
+ lines.push(` render_digest: ${renderDigest}`);
382
+ lines.push(
383
+ " The digest covers the sorted r2000_get_blocks/r2000_get_symbols/r2000_get_comments results, the",
384
+ );
385
+ lines.push(
386
+ " raw provenance sidecar bytes, and this renderer's version constant -- so either a hand edit or a",
387
+ );
388
+ lines.push(" store-side change (e.g. a comment's confidence grade) is detected by `render-memmap --check`.");
389
+ lines.push("-->");
390
+ lines.push("");
391
+ lines.push(`# Memory map — ${provenance.capturePath}`);
392
+ lines.push("");
393
+ lines.push(`Capture: \`${provenance.capturePath}\` · SHA-256 \`${provenance.captureSha256}\``);
394
+ lines.push(
395
+ `\`$01\` = \`${provenance.port01}\` · VIC bank \`${provenance.vicBank}\` (\`$DD00\` = \`${provenance.dd00}\`) · video standard \`${provenance.videoStandard}\``,
396
+ );
397
+ lines.push(`Live vector pair: \`${provenance.liveVectorPair}\` → \`${provenance.vectorHandler}\``);
398
+ lines.push("");
399
+ lines.push(
400
+ "Every row carries a confidence. Do not promote a row by editing its grade -- re-verify and restate",
401
+ );
402
+ lines.push("the evidence, so the record of when something stopped being a guess survives.");
403
+ lines.push("");
404
+ lines.push("| Range | Contents | Confidence | Evidence |");
405
+ lines.push("|---|---|---|---|");
406
+ for (const block of sortedBlocks) {
407
+ const match = findGradeInRange(block.start_address, block.end_address);
408
+ const range = `\`${hex4(block.start_address)}-${hex4(block.end_address)}\``;
409
+ const grade = match?.grade ? match.grade.phrase.toUpperCase() : "";
410
+ const evidence = match ? escapeMarkdownCell(match.evidence) : "";
411
+ lines.push(`| ${range} | ${block.type} | ${grade} | ${evidence} |`);
412
+ }
413
+ lines.push("");
414
+ lines.push("Confidence vocabulary — the project's HIGH / MEDIUM / LOW scale, applied to classification:");
415
+ lines.push("");
416
+ lines.push("| Grade | Means |");
417
+ lines.push("|---|---|");
418
+ for (const grade of CONFIDENCE_GRADES) {
419
+ lines.push(`| **${grade.phrase}** | ${grade.meaning} |`);
420
+ }
421
+ lines.push("");
422
+ lines.push("## Graphics chain");
423
+ lines.push("");
424
+ lines.push("| What | Address | Derived from |");
425
+ lines.push("|---|---|---|");
426
+ lines.push(`| VIC bank | ${provenance.vicBank} | \`$DD00\` bits 0-1, inverted |`);
427
+ lines.push(`| Screen RAM (VM) | ${provenance.screenRam} | \`$D018\` bits 4-7 |`);
428
+ lines.push(`| Charset / bitmap (CB) | ${provenance.charsetOrBitmap} | \`$D018\` bits 1-3 |`);
429
+ lines.push(`| Mode | ${provenance.mode} | \`$D011\` bits 5-6, \`$D016\` bit 4 |`);
430
+ lines.push("");
431
+ lines.push("## Interrupts");
432
+ lines.push("");
433
+ lines.push("| | Address | Notes |");
434
+ lines.push("|---|---|---|");
435
+ lines.push(`| Live IRQ handler | ${provenance.vectorHandler} | via ${provenance.liveVectorPair} |`);
436
+ if (provenance.rasterPositions && provenance.rasterPositions.length > 0) {
437
+ lines.push(
438
+ `| Raster positions | ${provenance.rasterPositions.join(", ")} | one per \`$D012\` write on the way out of a handler |`,
439
+ );
440
+ }
441
+ lines.push("");
442
+ lines.push("## Routines");
443
+ lines.push("");
444
+ lines.push("| Address | Provisional name | Confirmed by | Confidence |");
445
+ lines.push("|---|---|---|---|");
446
+ const codeBlocks = sortedBlocks.filter((b) => b.type === "Code");
447
+ for (const sym of sortedSymbols) {
448
+ const inCode = codeBlocks.some((b) => sym.address >= b.start_address && sym.address <= b.end_address);
449
+ if (!inCode) continue;
450
+ const match = gradedComments.find((c) => c.address === sym.address);
451
+ const grade = match?.grade ? match.grade.phrase.toUpperCase() : "";
452
+ const confirmedBy = match ? escapeMarkdownCell(match.evidence) : "";
453
+ lines.push(`| ${hex4(sym.address)} | ${escapeMarkdownCell(sym.name)} | ${confirmedBy} | ${grade} |`);
454
+ }
455
+ lines.push("");
456
+ lines.push("## Open questions");
457
+ lines.push("");
458
+ const unknowns = gradedComments.filter((c) => c.grade?.token === "unknown");
459
+ if (unknowns.length === 0) {
460
+ lines.push("- (none)");
461
+ } else {
462
+ for (const u of unknowns) {
463
+ lines.push(`- ${hex4(u.address)}: ${escapeMarkdownCell(u.evidence)}`);
464
+ }
465
+ }
466
+ lines.push("");
467
+
468
+ const markdown = lines.join("\n");
469
+ return { markdown, renderDigest, rowCount: sortedBlocks.length, unknownCount: unknowns.length };
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // checkRenderedMemoryMap()
474
+ // ---------------------------------------------------------------------------
475
+
476
+ export interface CheckRenderedMemoryMapOptions {
477
+ projectPath: string;
478
+ provenancePath: string;
479
+ renderedPath: string;
480
+ }
481
+
482
+ export type CheckRenderedMemoryMapResult =
483
+ | { status: "in-sync" }
484
+ | { status: "drifted"; line: number; expected: string; actual: string }
485
+ | { status: "missing"; path: string };
486
+
487
+ /**
488
+ * Re-renders the memory map from the CURRENT store and sidecar state and
489
+ * compares it against the file on disk at `renderedPath`, line by line.
490
+ * Never auto-fixes. Returns:
491
+ * - `{status:"missing"}` when `renderedPath` does not exist;
492
+ * - `{status:"in-sync"}` when the freshly rendered text is byte-identical
493
+ * to the file on disk;
494
+ * - `{status:"drifted", line, expected, actual}` naming the first
495
+ * differing line otherwise -- reached by EITHER a hand edit to the file
496
+ * OR a store-side change (a label, a comment, a block) since the file
497
+ * was last rendered, because both change what a fresh render produces.
498
+ */
499
+ export async function checkRenderedMemoryMap(
500
+ opts: CheckRenderedMemoryMapOptions,
501
+ ): Promise<CheckRenderedMemoryMapResult> {
502
+ const { projectPath, provenancePath, renderedPath } = opts;
503
+
504
+ if (!existsSync(renderedPath)) {
505
+ return { status: "missing", path: renderedPath };
506
+ }
507
+
508
+ const onDisk = readFileSync(renderedPath, "utf8");
509
+ const { markdown } = await renderMemoryMap({ projectPath, provenancePath });
510
+
511
+ if (onDisk === markdown) {
512
+ return { status: "in-sync" };
513
+ }
514
+
515
+ const diskLines = onDisk.split("\n");
516
+ const freshLines = markdown.split("\n");
517
+ const max = Math.max(diskLines.length, freshLines.length);
518
+ for (let i = 0; i < max; i++) {
519
+ if (diskLines[i] !== freshLines[i]) {
520
+ return {
521
+ status: "drifted",
522
+ line: i + 1,
523
+ expected: freshLines[i] ?? "(end of file)",
524
+ actual: diskLines[i] ?? "(end of file)",
525
+ };
526
+ }
527
+ }
528
+ // Unreachable in practice (the strings already compared unequal above),
529
+ // kept only as a defensive fallback.
530
+ return { status: "drifted", line: max + 1, expected: "(no further lines)", actual: "(no further lines)" };
531
+ }