@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,388 @@
1
+ #!/usr/bin/env node
2
+ // r2000-symbols.ts -- the ONE authoritative place in this repo for the
3
+ // symbol round trip between regenerator2000's annotation store and stock
4
+ // VICE's symbol table (R2000-14/R2000-15, ARCHITECTURE.md Rule A20).
5
+ //
6
+ // WHY THIS FILE EXISTS: static-analysis symbols going OUT to VICE
7
+ // (`exportLabels()`) and live-discovered symbols coming IN from VICE
8
+ // (`importLabels()`) must flow through explicit adapter code -- neither side
9
+ // may parse the other's internal representation (Rule A20). This module is
10
+ // that adapter. It reuses `stock-symbols.ts`'s existing `al C:xxxx .Name`
11
+ // parser (`parseViceLabelFile()`, exported there for exactly this reuse)
12
+ // rather than adding this repo's THIRD copy of that format --
13
+ // `stock-symbols.ts` and `acme-build/scripts/acme.mjs`'s `curateLabels()`
14
+ // are the two that already exist.
15
+ //
16
+ // MEASURED FACTS this module's behaviour depends on:
17
+ // - `--export_lbl` exports USER labels only. Measured (Phase 9, and
18
+ // re-confirmed by this plan's own round-trip test): an annotated
19
+ // project emits exactly the labels a caller set via
20
+ // `r2000_set_label_name`/`--import_lbl` -- the auto-generated `a_D011` /
21
+ // `a_D020` / `e_FFD2` externals are NOT exported. A test asserting an
22
+ // `a_`-prefixed name appears in an `exportLabels()` result is testing
23
+ // the wrong thing.
24
+ // - `--import_lbl` under plain `--headless` DISCARDS. `main.rs:800-806` is
25
+ // `if headless && !mcp_server { return Ok(()) }`: an argv of
26
+ // `--import_lbl <path> --headless <proj>` imports the labels into
27
+ // memory and then hits that early return WITHOUT ever calling save, so
28
+ // the import is silently discarded -- measured live: two names imported
29
+ // that way, and a subsequent `--export_lbl` read back from disk
30
+ // returned only the pre-existing label. `r2000-launch.ts`'s
31
+ // `buildImportLblArgs()` makes this combination unbuildable by always
32
+ // pairing `--mcp-server-stdio` (which sets both `headless` AND
33
+ // `mcp_server`, `main.rs:709-711`, skipping the early return) --
34
+ // `r2000-symbol-roundtrip.test.ts` pins the trap itself with a
35
+ // hand-built argv, so that builder's pairing stays provably load-bearing
36
+ // rather than merely assumed.
37
+ //
38
+ // WHAT NOT TO DO, named concretely:
39
+ // - Never add a second `al C:xxxx .Name` regex anywhere in this file.
40
+ // Every read of a label file's TEXT goes through
41
+ // `stock-symbols.ts`'s exported `parseViceLabelFile()`.
42
+ // - Never call `vice_symbols_load` (`stock-symbols.ts`'s handlers)
43
+ // incrementally. `regenerateAndReload()` below regenerates the WHOLE
44
+ // `.lbl` and returns its path for the CALLER to hand to
45
+ // `vice_symbols_load` exactly once -- `vice_symbols_load` is
46
+ // deliberately replace-not-merge (T-05-02-05), so a full regeneration is
47
+ // what keeps that semantics correct rather than a limitation. A merge
48
+ // mode on `vice_symbols_load` itself was rejected: it would reopen a
49
+ // v0.2.0 decision and make a tool advertised on both backends diverge in
50
+ // semantics.
51
+ // - Never build `--import_lbl`'s argv by hand in this file. It comes ONLY
52
+ // from `r2000-launch.ts`'s `buildImportLblArgs()` -- there is no literal
53
+ // `"--import_lbl"` string anywhere below.
54
+ // - Never report an import as persisted on the strength of a
55
+ // no-error response alone. `importLabels()` below proves persistence
56
+ // TWICE: once via `saveAndVerify()`'s content-hash check (inside the
57
+ // import session), and independently via a fresh `exportLabels()` from
58
+ // disk, in a BRAND NEW process, after the import session has fully
59
+ // closed. `ImportLabelsResult` is a discriminated union
60
+ // (`diskVerified: true | false`) specifically so a caller cannot
61
+ // mistake "the import call returned no error" for "the names are
62
+ // actually on disk".
63
+ // - Never let an illegal label name from a `.lbl` file reach a spawned
64
+ // child (T-11-NAME-INJECT, closed). `importLabels()` validates every
65
+ // name against `r2000-acme-ident.ts`'s `assertLegalAcmeIdentifier()`
66
+ // BEFORE `buildImportLblArgs()` is ever called -- REJECT, never
67
+ // sanitize, matching `r2000-tools.ts`'s `r2000_set_label_name` posture.
68
+ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
69
+ import { tmpdir } from "node:os";
70
+ import { join } from "node:path";
71
+
72
+ import { buildExportLblArgs, buildImportLblArgs, runR2000 } from "./r2000-launch.ts";
73
+ import { withR2000Session, saveAndVerify } from "./r2000-mcp-client.ts";
74
+ import { runR2000Tool } from "./r2000-tools.ts";
75
+ import { parseViceLabelFile, MAX_LABEL_FILE_BYTES } from "./stock-symbols.ts";
76
+ import { assertLegalAcmeIdentifier } from "./r2000-acme-ident.ts";
77
+
78
+ /** This module's own error class, following `r2000-tools.ts`'s
79
+ * `R2000StorePathError` / `r2000-launch.ts`'s `R2000ViceFlagError` minimal
80
+ * shape (message-only, `.name` set to the class name). Never thrown for a
81
+ * ceiling violation on a `.lbl` file's TEXT -- those come from
82
+ * `stock-symbols.ts`'s `StockSymbolsError`, surfaced verbatim, never
83
+ * re-wrapped as this class. Reserved for this module's OWN failure modes:
84
+ * a nonzero regenerator2000 exit, a missing output file despite a zero
85
+ * exit, an oversized file caught before `parseViceLabelFile()` is ever
86
+ * called, or (T-11-NAME-INJECT, closed) an illegal label name caught in
87
+ * `importLabels()` before any child is spawned -- naming the offending
88
+ * name, its 1-based line number, and that line's own text. */
89
+ export class R2000SymbolsError extends Error {
90
+ constructor(message: string) {
91
+ super(message);
92
+ this.name = "R2000SymbolsError";
93
+ }
94
+ }
95
+
96
+ export interface LabelEntry {
97
+ name: string;
98
+ address: number;
99
+ }
100
+
101
+ export interface ExportLabelsOptions {
102
+ projectPath: string;
103
+ outPath: string;
104
+ }
105
+
106
+ export interface ExportLabelsResult {
107
+ path: string;
108
+ symbolCount: number;
109
+ symbols: LabelEntry[];
110
+ skippedLines: number;
111
+ duplicateNames: number;
112
+ lineCount: number;
113
+ }
114
+
115
+ /**
116
+ * The export leg (R2000-14). Runs `buildExportLblArgs()` through
117
+ * `runR2000()`, then READS THE PRODUCED FILE BACK through
118
+ * `stock-symbols.ts`'s existing parser to validate it and return the parsed
119
+ * symbol list plus a count.
120
+ *
121
+ * The read-back is not optional, for two independent reasons: a
122
+ * regenerator2000 exit code has lied before (`r2000-verify.ts`'s founding
123
+ * incident, D-10 -- a zero exit alongside content that should have failed),
124
+ * and the parse is the only thing that proves the produced file is in the
125
+ * format `vice_symbols_load` actually accepts. Ceiling violations from
126
+ * `parseViceLabelFile()` (`StockSymbolsError`) are surfaced VERBATIM, never
127
+ * re-wrapped -- only this function's OWN failure modes (nonzero exit,
128
+ * missing file, oversized file) throw `R2000SymbolsError`.
129
+ */
130
+ export async function exportLabels({ projectPath, outPath }: ExportLabelsOptions): Promise<ExportLabelsResult> {
131
+ const argv = buildExportLblArgs({ projectPath, outPath });
132
+ const result = runR2000(argv);
133
+ if (result.status !== 0) {
134
+ throw new R2000SymbolsError(
135
+ `exportLabels: regenerator2000 exited ${result.status} for "${projectPath}" -- stderr: ${result.stderr || "(empty)"}`,
136
+ );
137
+ }
138
+ if (!existsSync(outPath)) {
139
+ throw new R2000SymbolsError(
140
+ `exportLabels: regenerator2000 exited 0 but did not produce "${outPath}" -- a lying zero exit code has ` +
141
+ "happened before (r2000-verify.ts's founding incident, D-10); refusing to trust the exit code alone.",
142
+ );
143
+ }
144
+
145
+ let size: number;
146
+ try {
147
+ size = statSync(outPath).size;
148
+ } catch (err) {
149
+ throw new R2000SymbolsError(
150
+ `exportLabels: could not stat "${outPath}" (${err instanceof Error ? err.message : String(err)})`,
151
+ );
152
+ }
153
+ if (size > MAX_LABEL_FILE_BYTES) {
154
+ throw new R2000SymbolsError(
155
+ `exportLabels: "${outPath}" is ${size} bytes, which exceeds the ${MAX_LABEL_FILE_BYTES}-byte ceiling`,
156
+ );
157
+ }
158
+
159
+ const text = readFileSync(outPath, "utf8");
160
+ // Reused verbatim -- stock-symbols.ts's ONE parser, never a second regex.
161
+ // A ceiling violation here (StockSymbolsError) propagates unmodified.
162
+ const parsed = parseViceLabelFile(text);
163
+
164
+ const symbols: LabelEntry[] = Array.from(parsed.table.byName.entries()).map(([name, address]) => ({ name, address }));
165
+
166
+ return {
167
+ path: outPath,
168
+ symbolCount: parsed.symbolCount,
169
+ symbols,
170
+ skippedLines: parsed.skippedLines,
171
+ duplicateNames: parsed.duplicateNames,
172
+ lineCount: parsed.lineCount,
173
+ };
174
+ }
175
+
176
+ export interface ImportLabelsOptions {
177
+ projectPath: string;
178
+ lblPath: string;
179
+ }
180
+
181
+ export interface ImportLabelsVerified {
182
+ diskVerified: true;
183
+ importedNames: string[];
184
+ /** The fresh, independent `exportLabels()` result used to prove
185
+ * persistence -- a caller can inspect it without re-exporting itself. */
186
+ exported: ExportLabelsResult;
187
+ }
188
+
189
+ export interface ImportLabelsUnverified {
190
+ diskVerified: false;
191
+ importedNames: string[];
192
+ /** Names present in the imported `.lbl` file that a fresh export from
193
+ * disk did NOT contain. Always non-empty when `diskVerified` is `false`. */
194
+ missingNames: string[];
195
+ reason: string;
196
+ }
197
+
198
+ /**
199
+ * A discriminated union, deliberately -- `diskVerified: true` and
200
+ * `diskVerified: false` are structurally distinct shapes, so a caller
201
+ * cannot read `result.importedNames` and mistake "the import call returned
202
+ * no error" for "the names are actually on disk" (the plan's own required
203
+ * distinction). Only the `true` variant carries `exported`; only the
204
+ * `false` variant carries `missingNames`/`reason`.
205
+ */
206
+ export type ImportLabelsResult = ImportLabelsVerified | ImportLabelsUnverified;
207
+
208
+ /**
209
+ * The import leg (R2000-15, the D-28 path). Runs `buildImportLblArgs()` --
210
+ * which ALWAYS carries `--mcp-server-stdio` -- and, over that SAME stdio
211
+ * session, calls `r2000_save_project` through `r2000-mcp-client.ts`'s
212
+ * `saveAndVerify()`. `--import_lbl` mutates only IN-MEMORY state
213
+ * (`main.rs:800-806` is why the save must be explicit); `saveAndVerify()`
214
+ * proves the save changed the project file's own content hash on disk.
215
+ *
216
+ * That alone is `r2000-mcp-client.ts`'s own T-11-FALSESUCCESS proof, not
217
+ * THIS module's. `importLabels()` proves persistence a SECOND, independent
218
+ * way: after the import session has fully closed, a BRAND NEW process
219
+ * (`exportLabels()`, a fresh `runR2000()` child) re-reads the project from
220
+ * disk and re-exports its labels. Only when every name in the caller's
221
+ * `.lbl` file appears in that fresh export does this function report
222
+ * `diskVerified: true`.
223
+ *
224
+ * The caller-supplied `.lbl` file itself is ceiling-checked BEFORE any
225
+ * child process is spawned (T-11-LBL-SIZE): a byte-size check against
226
+ * `stock-symbols.ts`'s own `MAX_LABEL_FILE_BYTES`, then a full
227
+ * `parseViceLabelFile()` pass, whose `StockSymbolsError` ceiling violations
228
+ * (`MAX_LABEL_FILE_LINES`/`MAX_SYMBOLS`) propagate verbatim -- an oversized
229
+ * or over-populated `.lbl` never reaches regenerator2000 at all.
230
+ *
231
+ * Every discovered name is then validated against `r2000-acme-ident.ts`'s
232
+ * `assertLegalAcmeIdentifier()`, also BEFORE any spawn (T-11-NAME-INJECT,
233
+ * closed): an illegal name throws `R2000SymbolsError` naming the offending
234
+ * name, its 1-based line number, and that line's own text -- REJECT, never
235
+ * sanitize, the same posture `r2000-tools.ts`'s `r2000_set_label_name`
236
+ * takes on the tool-surface entry route.
237
+ */
238
+ export async function importLabels({ projectPath, lblPath }: ImportLabelsOptions): Promise<ImportLabelsResult> {
239
+ let size: number;
240
+ try {
241
+ size = statSync(lblPath).size;
242
+ } catch (err) {
243
+ throw new R2000SymbolsError(
244
+ `importLabels: could not stat "${lblPath}" (${err instanceof Error ? err.message : String(err)})`,
245
+ );
246
+ }
247
+ if (size > MAX_LABEL_FILE_BYTES) {
248
+ throw new R2000SymbolsError(
249
+ `importLabels: "${lblPath}" is ${size} bytes, which exceeds the ${MAX_LABEL_FILE_BYTES}-byte ceiling`,
250
+ );
251
+ }
252
+
253
+ const inputText = readFileSync(lblPath, "utf8");
254
+ // Reused verbatim -- same parser as exportLabels(). A ceiling violation
255
+ // here (StockSymbolsError) propagates unmodified, before any spawn.
256
+ const inputParsed = parseViceLabelFile(inputText);
257
+ const importedNames = Array.from(inputParsed.table.byName.keys());
258
+
259
+ // T-11-NAME-INJECT (route B, closed): every discovered label name is
260
+ // validated against the one ACME identifier seam (r2000-acme-ident.ts)
261
+ // BEFORE any child is spawned -- REJECT, never sanitize, matching
262
+ // r2000-tools.ts's r2000_set_label_name posture. The offending line is
263
+ // located by a substring search over the already-read inputText, never a
264
+ // second `al C:` regex (this module's own header forbids a third parser
265
+ // for that format, T-11-LBL-PARSER-DUP).
266
+ for (const name of importedNames) {
267
+ try {
268
+ assertLegalAcmeIdentifier(name, "importLabels label name");
269
+ } catch (err) {
270
+ const reason = err instanceof Error ? err.message : String(err);
271
+ const inputLines = inputText.split(/\r?\n/);
272
+ const lineIndex = inputLines.findIndex((line) => line.includes(name));
273
+ const lineNumber = lineIndex === -1 ? 0 : lineIndex + 1;
274
+ const lineText = lineIndex === -1 ? "(line not found)" : inputLines[lineIndex];
275
+ throw new R2000SymbolsError(
276
+ `importLabels: "${lblPath}" line ${lineNumber} carries an illegal label name "${name}" (${reason}) -- ` +
277
+ `line text: ${JSON.stringify(lineText)}. REJECTED, never sanitized or quoted, before any child is spawned.`,
278
+ );
279
+ }
280
+ }
281
+
282
+ // buildImportLblArgs() is the ONLY producer of this argv -- there is no
283
+ // literal "--import_lbl" string anywhere in this file.
284
+ const argv = buildImportLblArgs({ projectPath, lblPath });
285
+ await withR2000Session(projectPath, (call) => saveAndVerify(projectPath, call), { argv });
286
+
287
+ // Independent proof #2: a brand-new process, after the import session has
288
+ // fully closed, re-reads the project from disk and re-exports its labels.
289
+ const verifyDir = mkdtempSync(join(tmpdir(), "r2000-symbols-verify-"));
290
+ try {
291
+ const reExportPath = join(verifyDir, "reexport.lbl");
292
+ const exported = await exportLabels({ projectPath, outPath: reExportPath });
293
+ const exportedNames = new Set(exported.symbols.map((s) => s.name));
294
+ const missingNames = importedNames.filter((n) => !exportedNames.has(n));
295
+
296
+ if (missingNames.length > 0) {
297
+ return {
298
+ diskVerified: false,
299
+ importedNames,
300
+ missingNames,
301
+ reason:
302
+ `importLabels: saveAndVerify() reported a changed content hash for "${projectPath}", but a fresh ` +
303
+ `exportLabels() from disk is missing ${missingNames.length} of ${importedNames.length} imported ` +
304
+ `name(s): ${missingNames.join(", ")}`,
305
+ };
306
+ }
307
+
308
+ return { diskVerified: true, importedNames, exported };
309
+ } finally {
310
+ rmSync(verifyDir, { recursive: true, force: true });
311
+ }
312
+ }
313
+
314
+ export interface RegenerateAndReloadOptions {
315
+ projectPath: string;
316
+ outPath: string;
317
+ address: number;
318
+ name: string;
319
+ }
320
+
321
+ export interface RegenerateAndReloadResult {
322
+ path: string;
323
+ symbolCount: number;
324
+ symbols: LabelEntry[];
325
+ }
326
+
327
+ /**
328
+ * D-29: the store is the merge point for a live-discovered name. Writes
329
+ * `name` at `address` into the store FIRST (via the curated
330
+ * `r2000_set_label_name`, `r2000-tools.ts`'s `runR2000Tool()` -- which
331
+ * auto-saves internally before its session exits), THEN regenerates the
332
+ * WHOLE `.lbl` with `exportLabels()`, and returns the path for the CALLER
333
+ * to hand to `vice_symbols_load` EXACTLY ONCE.
334
+ *
335
+ * This function deliberately does NOT call `vice_symbols_load` itself --
336
+ * see this module's header for why an incremental/repeated load would
337
+ * violate `vice_symbols_load`'s replace-not-merge semantics (T-05-02-05).
338
+ *
339
+ * Note (T-11-NAME-INJECT, closed): an illegal `name` now REJECTS this
340
+ * function's returned promise -- `r2000_set_label_name`'s own pre-spawn
341
+ * `assertLegalLabelArg()` gate (`r2000-tools.ts`) fires inside
342
+ * `runR2000Tool()`'s `assertCuratedTool()` call, before `runR2000Tool()`'s
343
+ * own `try` block, rather than surfacing as `setResult.isError` the way a
344
+ * regenerator2000-side failure would. This function had no production
345
+ * caller when that note was written, so no existing behaviour depended on
346
+ * the old `isError`-shaped outcome -- superseded in place by the formal
347
+ * status below rather than left as an aside.
348
+ *
349
+ * LIBRARY-ONLY (Phase 11 IN-02, D-11.1-06): as of this phase,
350
+ * `regenerateAndReload()` is a library-only export -- available for
351
+ * programmatic use, with NO PRODUCTION CALLER anywhere in this repo
352
+ * (`.claude/mcp/vice/`, `.claude/skills/`, `scripts/`). A future phase must
353
+ * not assume it is wired into any real workflow just because it is the
354
+ * named D-29 live-discovery merge point.
355
+ *
356
+ * The proven live path for the symbol round trip is NOT this function --
357
+ * it is `r2000 export-lbl` -> `vice_symbols_load` -> live discovery ->
358
+ * `r2000_set_label_name` -> `r2000 import-lbl`, documented as one closed
359
+ * loop in `c64-program-recon/SKILL.md`. `R2000-15` is satisfied through
360
+ * that sequence, not through this convenience wrapper. Giving this
361
+ * function a caller would mean inventing a new CLI verb or skill entry
362
+ * point it was never actually proven through -- out of this phase's scope
363
+ * fence (11.1-CONTEXT.md).
364
+ *
365
+ * ADOPTION CONDITION: if `regenerateAndReload()` ever acquires a real
366
+ * production caller, the `LIBRARY-ONLY` marker above must be deleted in
367
+ * the SAME commit that adds the caller. `r2000-symbol-roundtrip.test.ts`
368
+ * enforces this as a biconditional, in both directions: zero callers
369
+ * requires the marker present; one or more callers requires the marker
370
+ * absent. The two can never legally disagree.
371
+ */
372
+ export async function regenerateAndReload({
373
+ projectPath,
374
+ outPath,
375
+ address,
376
+ name,
377
+ }: RegenerateAndReloadOptions): Promise<RegenerateAndReloadResult> {
378
+ const setResult = await runR2000Tool("r2000_set_label_name", { project: projectPath, address, name });
379
+ if (setResult.isError) {
380
+ throw new R2000SymbolsError(
381
+ `regenerateAndReload: r2000_set_label_name failed for "${name}" at ${address}: ` +
382
+ `${setResult.content.map((c) => c.text).join(" ")}`,
383
+ );
384
+ }
385
+
386
+ const exported = await exportLabels({ projectPath, outPath });
387
+ return { path: exported.path, symbolCount: exported.symbolCount, symbols: exported.symbols };
388
+ }