@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,455 @@
1
+ #!/usr/bin/env node
2
+ // stock-symbols.ts
3
+ //
4
+ // DERIV-04's store: `vice_symbols_load` and `vice_symbols_lookup`, the
5
+ // client-side symbol table this codebase carries no other implementation of.
6
+ // This is the ONLY module that may call stock-address.ts's
7
+ // setSymbolResolver() -- that file's ONE holder is the single seam, and a
8
+ // second holder or a family-local address->name map is that file's own
9
+ // named anti-pattern ("Never add a second resolver holder").
10
+ //
11
+ // Both tools are `needsSession: false` (D-04 of Phase 4): loading or
12
+ // looking up a symbol never opens a monitor connection and therefore never
13
+ // halts the user's running program -- a genuine ergonomic win over the
14
+ // fork, whose implementation lives inside the emulator process.
15
+ //
16
+ // WHY hostpath.ts IS NEVER IMPORTED HERE, spelled out: hostpath.ts
17
+ // translates a container path into a HOST path for a filename stock VICE
18
+ // ITSELF OPENS ACROSS THE WIRE. `vice_symbols_load` reads the file with
19
+ // Node's `fs` inside the MCP server's OWN process; there is no wire
20
+ // filename argument at all, so the translation does not apply and applying
21
+ // it would read the wrong file (or nothing). hostpath-consumers.test.ts's
22
+ // closed five-member production consumer set (containerpath.ts,
23
+ // install-resources.ts, stock-paths.ts, vice-proxy.ts, vice-sync.ts) must
24
+ // stay exactly five -- this module joining it would fail that test outright.
25
+ //
26
+ // The confirmed input format is a VICE label file, one `al C:xxxx .Name`
27
+ // line per symbol, verified against ACME's `--vicelabels` output via
28
+ // acme-build/scripts/acme.mjs's own parser (curateLabels(),
29
+ // `/^al\s+C:[0-9a-f]+\s+\.(\S+)/i`). VERIFIED (Phase 9, R2000-16(c)):
30
+ // regenerator2000 0.9.20's `--export_lbl` was run against the
31
+ // probe-illegal.prg-derived fixture and emitted `al C:0810 .init_screen`,
32
+ // which matches this module's own VICE_LABEL_LINE_RE
33
+ // (`/^al\s+C:([0-9a-fA-F]{1,4})\s+\.(\S+)/`) exactly. This claim is SCOPED to
34
+ // regenerator2000 0.9.20 and that fixture -- not to all inputs forever (the
35
+ // same scoping caveat ROADMAP.md applies to Phase 9's criterion 3(3) `pass`).
36
+ // The parser below still SKIPS unrecognised lines rather than refusing the
37
+ // whole file: a future regenerator2000 version, a hand-edited label file, or
38
+ // a different exporter entirely can still produce lines this format should
39
+ // tolerate rather than reject outright.
40
+ //
41
+ // WHAT NOT TO DO:
42
+ // - Never add a second resolver holder or call setSymbolResolver() from
43
+ // any other new Phase 5 module -- this file is the one seam.
44
+ // - Never import hostpath.ts, stock-paths.ts, containerpath.ts or
45
+ // vice-proxy.ts from this file (see above).
46
+ // - Never build a success-result object literal by hand (an "isError"
47
+ // field set to the negative literal) outside derivedAnswer() -- every
48
+ // success on this module's two handlers goes through it, exactly as
49
+ // stock-handler.ts's own header requires.
50
+ // - Never merge a newly-loaded table into the previous one -- a load is a
51
+ // REPLACE, matching the fork's own single-active-symbol-table framing
52
+ // (T-05-02-05).
53
+ import { readFileSync, realpathSync, statSync } from "node:fs";
54
+ import { resolve, sep } from "node:path";
55
+
56
+ import { ViceError, type ViceErrorOptions } from "./vice.ts";
57
+ import { repoRoot } from "./repo-root.ts";
58
+ import { parseAddress, setSymbolResolver, type SymbolResolver } from "./stock-address.ts";
59
+ import { derivedAnswer, isErrorText } from "./stock-handler.ts";
60
+ import type { DerivedPureHandler } from "./stock-derived.ts";
61
+
62
+ /** True iff `value` is a well-formed, generic JSON object -- not null, not
63
+ * an array. Matches this module tree's own isPlainObject() convention
64
+ * (stock-memory.ts, stock-disassemble.ts et al. each carry a private copy
65
+ * rather than a shared import). */
66
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Module constants.
72
+ // ---------------------------------------------------------------------------
73
+
74
+ /** The confirmed VICE label-file line shape: `al C:xxxx .Name`. Group 1 is
75
+ * the hex address (1-4 digits, either case); group 2 is the symbol name.
76
+ * Anchored at line start -- leading whitespace is trimmed off each line
77
+ * before matching. Deliberately case-sensitive on the literal `al`/`C:`
78
+ * text (unlike acme.mjs's own `/i` parser) since every producer this repo
79
+ * has verified emits exactly that casing; only the hex digits themselves
80
+ * accept either case. */
81
+ const VICE_LABEL_LINE_RE = /^al\s+C:([0-9a-fA-F]{1,4})\s+\.(\S+)/;
82
+
83
+ /** T-05-02-03: three independent resource ceilings, each refusing with both
84
+ * the observed value and the limit named. `MAX_LABEL_FILE_BYTES` is exported
85
+ * (11-08, Rule A20) so `r2000-symbols.ts`'s `exportLabels()`/`importLabels()`
86
+ * can apply the SAME byte ceiling to a regenerator2000-produced/-consumed
87
+ * `.lbl` file before ever calling `parseViceLabelFile()` below -- never a
88
+ * second hand-copied number. */
89
+ export const MAX_LABEL_FILE_BYTES = 2 * 1024 * 1024;
90
+ const MAX_LABEL_FILE_LINES = 50000;
91
+ const MAX_SYMBOLS = 20000;
92
+
93
+ /** D-05-02: `'auto'` does no format sniffing -- it parses the `al C:xxxx
94
+ * .Name` pattern only and reports the count actually loaded, whether that
95
+ * is `0` or not. `'kickasm'`/`'simple'` are refused by name (no skill or
96
+ * script in this repo produces either). */
97
+ const SUPPORTED_FORMATS = ["auto", "vice"];
98
+ const REFUSED_FORMATS = ["kickasm", "simple"];
99
+
100
+ /** The one address/byte-count error type this module ever throws -- never a
101
+ * bare Error, matching vice.ts's established ViceError hierarchy
102
+ * (stock-address.ts's StockAddressError, stock-paths.ts's StockPathError
103
+ * are the sibling precedents). */
104
+ export class StockSymbolsError extends ViceError {
105
+ constructor(message: string, options: ViceErrorOptions = {}) {
106
+ super(message, options);
107
+ this.name = "StockSymbolsError";
108
+ }
109
+ }
110
+
111
+ /** Exported alongside `parseViceLabelFile()` (11-08, Rule A20) purely so a
112
+ * cross-module caller can name this shape in its own type annotations --
113
+ * this module's own internal state (`loadedTable` below) still never leaves
114
+ * this file. */
115
+ export interface SymbolTable {
116
+ byName: Map<string, number>;
117
+ byAddress: Map<number, string>;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Path containment (T-05-02-01/02) -- resolve `path` against repoRoot() and
122
+ // refuse anything whose resolved absolute path is neither the root itself
123
+ // nor prefixed by `root + sep`, including via a symlink. Never calls
124
+ // hostpath.ts (see this file's header).
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function isContained(candidate: string, root: string): boolean {
128
+ return candidate === root || candidate.startsWith(root + sep);
129
+ }
130
+
131
+ /** Resolves `pathArg` against `repoRoot()`, refusing anything that escapes
132
+ * the workspace either directly or via a symlink, and returns the ONE
133
+ * canonical path that is checked, opened and reported. The rule (WR-08): the
134
+ * path that is containment-checked is the path that is opened and the path
135
+ * that is reported -- returning the pre-`realpathSync` string made the
136
+ * check advisory, because `statSync`/`readFileSync` re-traverse symlinks
137
+ * independently of this function's own check. */
138
+ function resolveLabelFilePath(pathArg: unknown): string {
139
+ if (typeof pathArg !== "string" || pathArg.trim() === "") {
140
+ throw new StockSymbolsError(`path must be a non-empty string, got ${typeof pathArg === "string" ? "an empty/whitespace-only string" : typeof pathArg}`);
141
+ }
142
+
143
+ const root = repoRoot();
144
+ const resolved = resolve(root, pathArg.trim());
145
+
146
+ if (!isContained(resolved, root)) {
147
+ throw new StockSymbolsError(`"${resolved}" is outside the workspace root (${root}) -- a symbol file must live inside the workspace`);
148
+ }
149
+
150
+ let real: string;
151
+ try {
152
+ real = realpathSync(resolved);
153
+ } catch (err) {
154
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
155
+ throw new StockSymbolsError(`"${resolved}" was not found`);
156
+ }
157
+ throw new StockSymbolsError(`could not resolve "${resolved}" (${err instanceof Error ? err.message : String(err)})`);
158
+ }
159
+
160
+ // WR-05 (2026-08-17): the second check must compare CANONICAL against
161
+ // CANONICAL. `repoRoot()` returns `resolve(...)`, never `realpathSync(...)`,
162
+ // so comparing the fully-canonicalised `real` against a possibly-symlinked
163
+ // `root` refused EVERY file in a workspace whose own path contains a
164
+ // symlinked component -- a bind-mounted or symlinked project directory,
165
+ // `/tmp` on macOS, a `~ -> /mnt/...` home. The file was inside the
166
+ // workspace and the refusal said it was not. Canonicalising the root is the
167
+ // fix; the check itself, and the `real` that is returned (WR-08), stay.
168
+ let realRoot: string;
169
+ try {
170
+ realRoot = realpathSync(root);
171
+ } catch {
172
+ // An unresolvable root cannot be canonicalised, so fall back to the
173
+ // resolved spelling rather than refusing every path -- the check below
174
+ // still runs, just against the less canonical of the two.
175
+ realRoot = root;
176
+ }
177
+
178
+ if (!isContained(real, realRoot)) {
179
+ throw new StockSymbolsError(
180
+ `"${resolved}" resolves (via symlink) to "${real}", which is outside the workspace root ` +
181
+ `(${realRoot === root ? realRoot : `${root}, canonically ${realRoot}`}) -- a symbol file must live inside the workspace`,
182
+ );
183
+ }
184
+
185
+ // WR-08: the path that is checked is the path that is opened and the path
186
+ // that is reported -- `real` (the fully-resolved, containment-checked
187
+ // path), never `resolved` (the pre-canonicalisation string). Returning
188
+ // `resolved` made the containment check advisory: statSync()/readFileSync()
189
+ // re-traverse any symlink in `resolved`, so a component swapped after the
190
+ // check on `real` but before those calls could read a file outside the
191
+ // workspace while the check above had passed on a different, already-gone
192
+ // resolution of the same string. Both checks above stay (the pre-realpath
193
+ // check on `resolved` gives the clearer error for an obviously out-of-tree
194
+ // argument); only the returned path changes.
195
+ return real;
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Parsing -- defensive per Pitfall 5: an unrecognised line is skipped and
200
+ // counted, never a whole-file refusal.
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Exported (11-08, Rule A20) so `r2000-symbols.ts` can validate a
205
+ * regenerator2000-produced `.lbl` file (or check a caller-supplied one
206
+ * BEFORE it is ever handed to a spawned regenerator2000 child) through THIS
207
+ * parser -- the ONE `al C:xxxx .Name` reader in this repo -- rather than
208
+ * adding a second copy of `VICE_LABEL_LINE_RE`. Ceiling violations
209
+ * (`MAX_LABEL_FILE_LINES`/`MAX_SYMBOLS`) throw `StockSymbolsError` exactly as
210
+ * they do for `handleSymbolsLoad` below; a caller across the module boundary
211
+ * is expected to surface that error verbatim, never re-wrap it.
212
+ */
213
+ export function parseViceLabelFile(text: string): {
214
+ table: SymbolTable;
215
+ symbolCount: number;
216
+ skippedLines: number;
217
+ duplicateNames: number;
218
+ lineCount: number;
219
+ } {
220
+ const lines = text.split("\n");
221
+ const lineCount = lines.length;
222
+ if (lineCount > MAX_LABEL_FILE_LINES) {
223
+ throw new StockSymbolsError(`the label file has ${lineCount} lines, which exceeds the ${MAX_LABEL_FILE_LINES}-line ceiling`);
224
+ }
225
+
226
+ const byName = new Map<string, number>();
227
+ const byAddress = new Map<number, string>();
228
+ let skippedLines = 0;
229
+ let duplicateNames = 0;
230
+
231
+ for (const rawLine of lines) {
232
+ const line = rawLine.trim();
233
+ if (line === "") {
234
+ skippedLines += 1;
235
+ continue;
236
+ }
237
+ const match = VICE_LABEL_LINE_RE.exec(line);
238
+ if (!match) {
239
+ skippedLines += 1;
240
+ continue;
241
+ }
242
+ const address = parseInt(match[1]!, 16);
243
+ // Defensively unreachable given the {1,4} hex bound above, but never
244
+ // trust a parsed value blindly -- an out-of-range address is counted as
245
+ // skipped rather than thrown.
246
+ if (!Number.isInteger(address) || address < 0 || address > 0xffff) {
247
+ skippedLines += 1;
248
+ continue;
249
+ }
250
+ const name = match[2]!;
251
+ if (byName.has(name)) {
252
+ duplicateNames += 1;
253
+ }
254
+ byName.set(name, address); // last definition wins
255
+ if (!byAddress.has(address)) {
256
+ byAddress.set(address, name); // first name for an address wins
257
+ }
258
+ }
259
+
260
+ if (byName.size > MAX_SYMBOLS) {
261
+ throw new StockSymbolsError(`the label file defines ${byName.size} distinct symbol names, which exceeds the ${MAX_SYMBOLS}-symbol ceiling`);
262
+ }
263
+
264
+ return { table: { byName, byAddress }, symbolCount: byName.size, skippedLines, duplicateNames, lineCount };
265
+ }
266
+
267
+ // ---------------------------------------------------------------------------
268
+ // Module state about THIS module's own load -- not a second resolver
269
+ // holder. loadedTable/loadedSymbolCount let handleSymbolsLookup answer
270
+ // without re-reading the file. (WR-11: a third field tracking the last-
271
+ // loaded path was write-only -- assigned on every load and cleared on
272
+ // reset, but read nowhere in the codebase. Deleted rather than replaced
273
+ // with an answer field: adding a key to either answer would require a
274
+ // tools-manifest.stock.json change this plan deliberately excludes.)
275
+ // ---------------------------------------------------------------------------
276
+
277
+ let loadedTable: SymbolTable | null = null;
278
+ let loadedSymbolCount = 0;
279
+
280
+ /** Builds one SymbolResolver implementing BOTH directions and installs it
281
+ * into stock-address.ts's existing holder via setSymbolResolver() -- a load
282
+ * is a REPLACE, never a merge: whatever was installed before is discarded. */
283
+ function installSymbolTable(table: SymbolTable): void {
284
+ const resolver: SymbolResolver = {
285
+ resolve: (name) => table.byName.get(name),
286
+ nameFor: (address) => table.byAddress.get(address),
287
+ };
288
+ setSymbolResolver(resolver);
289
+ }
290
+
291
+ /** Test-only reset, following stock-paths.ts's setIsInsideContainerForTest()
292
+ * / stock-runstate.ts's resetRunStateTrackersForTest() precedent: a
293
+ * module-level reset exported from the module that owns the state. Also
294
+ * clears stock-address.ts's holder so no test leaks a loaded table into
295
+ * another file's run. */
296
+ export function resetSymbolStoreForTest(): void {
297
+ loadedTable = null;
298
+ loadedSymbolCount = 0;
299
+ setSymbolResolver(null);
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // vice_symbols_load
304
+ // ---------------------------------------------------------------------------
305
+
306
+ export const handleSymbolsLoad: DerivedPureHandler = async (args, _deps) => {
307
+ if (!isPlainObject(args)) {
308
+ return isErrorText("vice_symbols_load: arguments must be an object");
309
+ }
310
+
311
+ let format = "auto";
312
+ if (args.format !== undefined) {
313
+ if (typeof args.format !== "string") {
314
+ return isErrorText(`vice_symbols_load: format must be a string, got ${typeof args.format}`);
315
+ }
316
+ if (REFUSED_FORMATS.includes(args.format)) {
317
+ return isErrorText(
318
+ `vice_symbols_load: format "${args.format}" is not supported on the stock backend -- only VICE-format label files ` +
319
+ `("al C:xxxx .Name" lines, as produced by ACME's --vicelabels) are supported. format must be "auto" or "vice".`,
320
+ );
321
+ }
322
+ if (!SUPPORTED_FORMATS.includes(args.format)) {
323
+ return isErrorText(
324
+ `vice_symbols_load: format "${args.format}" is not one of the fork's declared values (auto, kickasm, vice, simple) -- ` +
325
+ `only "auto" and "vice" are supported on the stock backend.`,
326
+ );
327
+ }
328
+ format = args.format;
329
+ }
330
+
331
+ let resolvedPath: string;
332
+ try {
333
+ resolvedPath = resolveLabelFilePath(args.path);
334
+ } catch (err) {
335
+ return isErrorText(`vice_symbols_load: ${err instanceof Error ? err.message : String(err)}`);
336
+ }
337
+
338
+ let size: number;
339
+ try {
340
+ size = statSync(resolvedPath).size;
341
+ } catch (err) {
342
+ return isErrorText(`vice_symbols_load: could not stat "${resolvedPath}" (${err instanceof Error ? err.message : String(err)})`);
343
+ }
344
+ if (size > MAX_LABEL_FILE_BYTES) {
345
+ return isErrorText(`vice_symbols_load: "${resolvedPath}" is ${size} bytes, which exceeds the ${MAX_LABEL_FILE_BYTES}-byte ceiling`);
346
+ }
347
+
348
+ let text: string;
349
+ try {
350
+ text = readFileSync(resolvedPath, "utf8");
351
+ } catch (err) {
352
+ const code = (err as NodeJS.ErrnoException).code;
353
+ if (code === "ENOENT") {
354
+ return isErrorText(`vice_symbols_load: "${resolvedPath}" was not found`);
355
+ }
356
+ if (code === "EACCES") {
357
+ return isErrorText(`vice_symbols_load: permission denied reading "${resolvedPath}"`);
358
+ }
359
+ if (code === "EISDIR") {
360
+ return isErrorText(`vice_symbols_load: "${resolvedPath}" is a directory, not a file`);
361
+ }
362
+ return isErrorText(`vice_symbols_load: could not read "${resolvedPath}" (${err instanceof Error ? err.message : String(err)})`);
363
+ }
364
+
365
+ let parsed: ReturnType<typeof parseViceLabelFile>;
366
+ try {
367
+ parsed = parseViceLabelFile(text);
368
+ } catch (err) {
369
+ return isErrorText(`vice_symbols_load: ${err instanceof Error ? err.message : String(err)}`);
370
+ }
371
+
372
+ const replaced = loadedTable !== null;
373
+ installSymbolTable(parsed.table);
374
+ loadedTable = parsed.table;
375
+ loadedSymbolCount = parsed.symbolCount;
376
+
377
+ const payload: Record<string, unknown> = {
378
+ path: args.path,
379
+ resolvedPath,
380
+ format: "vice",
381
+ symbolCount: parsed.symbolCount,
382
+ skippedLines: parsed.skippedLines,
383
+ duplicateNames: parsed.duplicateNames,
384
+ lineCount: parsed.lineCount,
385
+ replaced,
386
+ };
387
+ if (parsed.symbolCount === 0) {
388
+ payload.note =
389
+ `no "al C:xxxx .Name" lines were found in this file -- this is not an error. If the file was produced by ` +
390
+ `KickAssembler or another non-VICE format, it is not supported on the stock backend (format: "${format}").`;
391
+ }
392
+
393
+ return derivedAnswer(payload);
394
+ };
395
+
396
+ // ---------------------------------------------------------------------------
397
+ // vice_symbols_lookup
398
+ // ---------------------------------------------------------------------------
399
+
400
+ export const handleSymbolsLookup: DerivedPureHandler = async (args, _deps) => {
401
+ if (!isPlainObject(args)) {
402
+ return isErrorText("vice_symbols_lookup: arguments must be an object");
403
+ }
404
+
405
+ const hasName = args.name !== undefined;
406
+ const hasAddress = args.address !== undefined;
407
+
408
+ if (!hasName && !hasAddress) {
409
+ return isErrorText("vice_symbols_lookup: exactly one of name or address is required");
410
+ }
411
+ if (hasName && hasAddress) {
412
+ return isErrorText("vice_symbols_lookup: name and address are mutually exclusive -- supply exactly one");
413
+ }
414
+
415
+ const noTableNote = loadedTable === null ? "no symbol table is loaded -- call vice_symbols_load first" : undefined;
416
+
417
+ if (hasName) {
418
+ if (typeof args.name !== "string") {
419
+ return isErrorText(`vice_symbols_lookup: name must be a string, got ${typeof args.name}`);
420
+ }
421
+ const address = loadedTable?.byName.get(args.name);
422
+ const payload: Record<string, unknown> = { query: { name: args.name }, found: address !== undefined, symbolCount: loadedSymbolCount };
423
+ if (address !== undefined) {
424
+ payload.name = args.name;
425
+ payload.address = address;
426
+ }
427
+ if (noTableNote) {
428
+ payload.note = noTableNote;
429
+ }
430
+ return derivedAnswer(payload);
431
+ }
432
+
433
+ let address: number;
434
+ try {
435
+ address = parseAddress(args.address, { what: "address" });
436
+ } catch (err) {
437
+ return isErrorText(`vice_symbols_lookup: ${err instanceof Error ? err.message : String(err)}`);
438
+ }
439
+ const name = loadedTable?.byAddress.get(address);
440
+ // `query` echoes the value the lookup was PERFORMED AGAINST -- the parsed
441
+ // `address` local -- never the caller's raw `args.address`. parseAddress()
442
+ // accepts "$d020"/"0xd020" strings as well as numbers, but this tool's
443
+ // declared outputSchema pins `query.address` to `type: "number"`; echoing
444
+ // the raw argument would make the answer's own shape depend on the
445
+ // caller's formatting choice and violate that schema (WR-01, D-05-18).
446
+ const payload: Record<string, unknown> = { query: { address }, found: name !== undefined, symbolCount: loadedSymbolCount };
447
+ if (name !== undefined) {
448
+ payload.name = name;
449
+ payload.address = address;
450
+ }
451
+ if (noTableNote) {
452
+ payload.note = noTableNote;
453
+ }
454
+ return derivedAnswer(payload);
455
+ };