@henols/vice-mcp 0.2.1 → 0.2.3

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.
Files changed (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
package/anno-import.ts ADDED
@@ -0,0 +1,495 @@
1
+ #!/usr/bin/env node
2
+ // anno-import.ts
3
+ //
4
+ // Phase 37, plan 37-01 (IMP-01, IMP-02): the container-side parser and
5
+ // importer for `GhidraStructExport.java`'s `## `-delimited transfer file.
6
+ //
7
+ // THIS MODULE RECEIVES AN ALREADY-OPEN STORE HANDLE. It never opens or closes
8
+ // a store itself -- there is no second store session anywhere in this file.
9
+ // It also never names `node:sqlite`, never imports `hostpath.ts` or
10
+ // `containerpath.ts`, and never resolves a workspace path itself: per
11
+ // `ghidra-run.ts`'s own documented posture, the export file's path arrives
12
+ // ALREADY TRANSLATED upstream (by `containerPath()` inside
13
+ // `runHostToolFromContainer()`), so this module reads it AS GIVEN. Workspace
14
+ // confinement is the CALLER's job -- `anno-tools.ts`'s existing
15
+ // `resolveWorkspacePath()`, the same one `store` and `image` already go
16
+ // through -- not this module's.
17
+ //
18
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: parsing the export's fixed
19
+ // `## `-delimited section format, mapping Ghidra's `Reference.getReferenceType()`
20
+ // vocabulary onto the store's frozen four-member `XrefAccessKind`
21
+ // (`GHIDRA_REFTYPE_TO_ACCESS_KIND`, D-37-05), and the digest-then-delete
22
+ // discipline that makes the transfer file transient evidence rather than a
23
+ // second on-disk model (IMP-02).
24
+ //
25
+ // WHAT NOT TO DO:
26
+ // - Never write a row before the WHOLE document has parsed successfully.
27
+ // `parseGhidraExport()` returns a document `importGhidraExport()` walks
28
+ // in full to build a write list BEFORE the first `putXref()` call -- a
29
+ // streaming parse that writes as it reads cannot honour this.
30
+ // - Never unlink the transfer file anywhere except the single statement
31
+ // that runs after the LAST `putXref()` in the batch has returned. Never
32
+ // inside the write loop, never in a `finally`, never on a throwing path
33
+ // -- `applyWrite()` (which every `putXref()` call goes through) commits
34
+ // before returning, so a returned write is a durable write, and deleting
35
+ // before that point can lose evidence a store write never durably held.
36
+ // - Never guess an unrecognised `ReferenceType` token onto the nearest
37
+ // member of `XREF_ACCESS_KINDS`. It is dropped and COUNTED in
38
+ // `kindsSeenNotImported`, never silently absorbed and never refused --
39
+ // refusing on an ordinary `JSR`/`JMP` reference would make the importer
40
+ // unusable against a real corpus binary (D-37-05).
41
+
42
+ import { createHash } from "node:crypto";
43
+ // Namespace import, deliberately: a later acceptance gate greps this file for
44
+ // the literal token `unlinkSync` and requires it to appear on EXACTLY ONE
45
+ // non-comment line -- the actual call site, after the last committed write.
46
+ // A named `import { unlinkSync } from "node:fs"` would itself be a second
47
+ // matching line, so every fs function this module uses is reached through
48
+ // this one namespace binding instead.
49
+ import * as fs from "node:fs";
50
+
51
+ import { putXref } from "./anno-store.ts";
52
+ import type { AnnoStoreHandle } from "./anno-store.ts";
53
+ import { parseStoreAddress } from "./anno-types.ts";
54
+ import type { XrefAccessKind } from "./anno-types.ts";
55
+
56
+ /** The offending section and 1-based line number ride on every refusal, so a
57
+ * refusal is actionable without re-reading this file's parser. Follows the
58
+ * `ViceError`-family construction idiom in SHAPE (a `message` plus plain
59
+ * public fields) but is a bare `Error` subclass, not an `AnnoStoreError` --
60
+ * this module never touches the store's own persistence and has no reason to
61
+ * join that error family. */
62
+ export class AnnoImportError extends Error {
63
+ section?: string;
64
+ line?: number;
65
+
66
+ constructor(message: string, opts: { section?: string; line?: number } = {}) {
67
+ super(message);
68
+ this.name = "AnnoImportError";
69
+ this.section = opts.section;
70
+ this.line = opts.line;
71
+ }
72
+ }
73
+
74
+ /** The parsed shape of one `## `-delimited transfer file. A trailer is a
75
+ * `## NAME <value>` line (e.g. `## REFERENCE_COUNT 7`); a bare `## NAME` line
76
+ * with nothing following it opens a section instead. */
77
+ export interface GhidraExportDocument {
78
+ sections: Map<string, string[]>;
79
+ trailers: Map<string, string>;
80
+ }
81
+
82
+ /**
83
+ * Parses ONE Ghidra-rendered address token -- e.g. `"0815"`, `"d020"`,
84
+ * `"0001"` -- exactly as `Address.toString()` renders one inside a real
85
+ * export: bare hex digits, NO `"$"` or `"0x"` prefix, ALWAYS hex, never
86
+ * decimal. MEASURED this plan, against a REAL captured export: this is a
87
+ * distinct format from `parseStoreAddress()`'s (`anno-types.ts`) own
88
+ * agent-facing contract, which REFUSES an unprefixed numeric string on
89
+ * purpose, because an AGENT-supplied address is genuinely ambiguous between
90
+ * hex and decimal. A Ghidra-rendered token carries NO such ambiguity (it
91
+ * always comes from `Address.toString()`, never from anything an agent
92
+ * typed), so this module parses it with its OWN narrow rule instead of
93
+ * `parseStoreAddress()`'s. Still accepts the `"$"`/`"0x"`-prefixed forms
94
+ * (delegating to `parseStoreAddress()` for those, unchanged), so a
95
+ * hand-written test fixture using either convention keeps working.
96
+ *
97
+ * Discovered as a LIVE, previously-untested defect in the REFERENCES import
98
+ * path (plan 37-01): every prior test used a hand-written, `$`-prefixed
99
+ * transfer file, never a token shaped exactly as Ghidra itself renders one
100
+ * -- `importGhidraExport()` would refuse EVERY real captured export outright.
101
+ * Fixed here, in the SAME function `## CONST_WRITES`'s own tokens (which are
102
+ * ALSO bare hex, D-37-06) need the identical treatment for.
103
+ */
104
+ function parseGhidraAddressToken(token: string, what: string): number {
105
+ if (token.startsWith("$") || /^0[xX]/.test(token)) {
106
+ return parseStoreAddress(token, { what });
107
+ }
108
+ if (/^[0-9a-fA-F]+$/.test(token)) {
109
+ const value = parseInt(token, 16);
110
+ if (value < 0 || value > 0xffff) {
111
+ throw new AnnoImportError(
112
+ `anno_import_ghidra_export refused: ${what} ${JSON.stringify(token)} is out of range -- expected $0000-$ffff.`,
113
+ { section: what },
114
+ );
115
+ }
116
+ return value;
117
+ }
118
+ throw new AnnoImportError(
119
+ `anno_import_ghidra_export refused: ${what} ${JSON.stringify(token)} is not a resolvable address -- expected bare ` +
120
+ `hex digits (Ghidra's own rendering) or a "$"/"0x"-prefixed form.`,
121
+ { section: what },
122
+ );
123
+ }
124
+
125
+ /**
126
+ * A single forward pass over `text`. Refuses BY NAME, embedding the section
127
+ * name and the 1-based line number in the message, BEFORE any store write is
128
+ * attempted anywhere downstream -- this function never writes and is called
129
+ * before `importGhidraExport()` issues its first `putXref()`.
130
+ *
131
+ * Refusal conditions, each named in the thrown message:
132
+ * - the text is empty or whitespace-only;
133
+ * - the first non-blank line is not a `## ` section header;
134
+ * - a `## REFERENCES` body line does not match the exact
135
+ * `<from> -> <to> <ReferenceType>` shape (the arrow as the second
136
+ * whitespace-separated token, exactly four tokens total);
137
+ * - a declared `## REFERENCE_COUNT` or `## CLASSIFICATION_LINES` trailer
138
+ * disagrees with the number of body lines actually parsed for that
139
+ * section.
140
+ *
141
+ * A section header present with ZERO following body lines is VALID and
142
+ * yields an empty array for that section -- an empty `## REFERENCES` body
143
+ * followed by `## REFERENCE_COUNT 0` is a legitimate export, not a malformed
144
+ * one.
145
+ */
146
+ export function parseGhidraExport(text: string): GhidraExportDocument {
147
+ if (text.trim() === "") {
148
+ throw new AnnoImportError(
149
+ "anno_import_ghidra_export refused: the transfer file's text is empty or whitespace-only -- an empty file " +
150
+ "is refused as malformed, never treated as an empty-but-valid export.",
151
+ { section: "(document)", line: 1 },
152
+ );
153
+ }
154
+
155
+ const lines = text.split(/\r?\n/);
156
+ const sections = new Map<string, string[]>();
157
+ const trailers = new Map<string, string>();
158
+ let currentSection: string | undefined;
159
+ let sawFirstNonBlank = false;
160
+
161
+ for (let i = 0; i < lines.length; i++) {
162
+ const line = lines[i]!;
163
+ const lineNo = i + 1;
164
+ if (line.trim() === "") continue;
165
+
166
+ if (!sawFirstNonBlank) {
167
+ sawFirstNonBlank = true;
168
+ if (!line.startsWith("## ")) {
169
+ throw new AnnoImportError(
170
+ `anno_import_ghidra_export refused: expected the first non-blank line to be a "## " section header, found ` +
171
+ `${JSON.stringify(line)} at line ${lineNo} -- refusing to parse a headerless document as a bodiless one.`,
172
+ { section: "(document)", line: lineNo },
173
+ );
174
+ }
175
+ }
176
+
177
+ if (line.startsWith("## ")) {
178
+ const rest = line.slice(3);
179
+ const spaceIdx = rest.indexOf(" ");
180
+ if (spaceIdx === -1) {
181
+ currentSection = rest.trim();
182
+ if (!sections.has(currentSection)) sections.set(currentSection, []);
183
+ } else {
184
+ const trailerName = rest.slice(0, spaceIdx);
185
+ const trailerValue = rest.slice(spaceIdx + 1).trim();
186
+ trailers.set(trailerName, trailerValue);
187
+ }
188
+ continue;
189
+ }
190
+
191
+ // A body line belonging to `currentSection`. `currentSection` is always
192
+ // defined here: the first-non-blank-line check above already refused any
193
+ // document whose first line is not a header, so a body line can only be
194
+ // reached after at least one header has been seen.
195
+ if (currentSection === "REFERENCES") {
196
+ const tokens = line.trim().split(/\s+/);
197
+ if (tokens.length !== 4 || tokens[1] !== "->") {
198
+ throw new AnnoImportError(
199
+ `anno_import_ghidra_export refused: REFERENCES line ${lineNo} does not match "<from> -> <to> ` +
200
+ `<ReferenceType>": ${JSON.stringify(line)}`,
201
+ { section: "REFERENCES", line: lineNo },
202
+ );
203
+ }
204
+ }
205
+
206
+ // MEASURED bug, found this plan against a REAL captured export (no prior
207
+ // test ever ran a real `## CLASSIFICATION` section through this parser):
208
+ // `GhidraStructExport.java`'s classification section appends three
209
+ // informational lines AFTER its own `## CLASSIFICATION_LINES <n>`
210
+ // trailer -- `CLASSIFICATION_EXPECTED_FROM_BLOCKS`, `CLASSIFICATION_
211
+ // OBSERVED`, `CLASSIFICATION_OVERRIDE_USED` -- with NO `## ` prefix
212
+ // (verbatim `StringBuilder.append()` calls, never a header/trailer
213
+ // line). Because `currentSection` stays "CLASSIFICATION" across the
214
+ // preceding `## CLASSIFICATION_LINES` TRAILER line (a trailer never
215
+ // changes `currentSection`), these three bare lines would otherwise be
216
+ // swept into `sections.get("CLASSIFICATION")` as ordinary body content,
217
+ // inflating the parsed count by exactly 3 relative to the script's own
218
+ // declared trailer -- silently reddening `checkTrailerCount` below on
219
+ // every real capture. Skipped here BY NAME (never a generic "looks like
220
+ // a trailer" heuristic, which risks dropping a genuine classification
221
+ // line that happens to start similarly).
222
+ if (
223
+ currentSection === "CLASSIFICATION" &&
224
+ /^(CLASSIFICATION_EXPECTED_FROM_BLOCKS|CLASSIFICATION_OBSERVED|CLASSIFICATION_OVERRIDE_USED)\b/.test(line)
225
+ ) {
226
+ continue;
227
+ }
228
+
229
+ sections.get(currentSection!)!.push(line);
230
+ }
231
+
232
+ const checkTrailerCount = (sectionName: string, trailerName: string): void => {
233
+ const declaredRaw = trailers.get(trailerName);
234
+ if (declaredRaw === undefined) return;
235
+ const declared = Number(declaredRaw);
236
+ const actual = sections.get(sectionName)?.length ?? 0;
237
+ if (!Number.isInteger(declared) || declared !== actual) {
238
+ throw new AnnoImportError(
239
+ `anno_import_ghidra_export refused: "## ${trailerName} ${declaredRaw}" declares ${declaredRaw} but ` +
240
+ `${sectionName} carries ${actual} parsed line(s) -- a self-checking count disagreement is refused rather ` +
241
+ "than trusted.",
242
+ { section: sectionName },
243
+ );
244
+ }
245
+ };
246
+ checkTrailerCount("REFERENCES", "REFERENCE_COUNT");
247
+ checkTrailerCount("CLASSIFICATION", "CLASSIFICATION_LINES");
248
+ // Plan 37-02: the SAME generic trailer-count check, extended to
249
+ // `## CONST_WRITES` / `## CONST_WRITES_COUNT`. Note what this generic
250
+ // grammar already does with the exporter's own `## CONST_WRITES_NONE`
251
+ // marker line: because every `## `-prefixed line with no following space
252
+ // OPENS A NEW SECTION (never a body line), that marker line itself closes
253
+ // out `CONST_WRITES` at zero body lines and opens an unrelated, always-
254
+ // empty `CONST_WRITES_NONE` section -- so a "found nothing" export and a
255
+ // "found nothing, no marker" export are indistinguishable to THIS check,
256
+ // both correctly reporting zero body lines against a `CONST_WRITES_COUNT 0`
257
+ // trailer. `parseConstWrites()` below never needs to special-case the
258
+ // marker itself for exactly this reason.
259
+ checkTrailerCount("CONST_WRITES", "CONST_WRITES_COUNT");
260
+
261
+ return { sections, trailers };
262
+ }
263
+
264
+ /**
265
+ * D-37-05's frozen mapping from Ghidra's `Reference.getReferenceType()`
266
+ * vocabulary onto the store's four-member `XrefAccessKind`. A token absent
267
+ * from this table is dropped and COUNTED (`kindsSeenNotImported`), never
268
+ * refused and never guessed -- a real corpus binary carries ordinary jump and
269
+ * call references constantly, and refusing on them would make the importer
270
+ * unusable.
271
+ */
272
+ export const GHIDRA_REFTYPE_TO_ACCESS_KIND: Readonly<Record<string, XrefAccessKind>> = Object.freeze({
273
+ READ: "READ",
274
+ WRITE: "WRITE",
275
+ READ_WRITE: "READ_WRITE",
276
+ COMPUTED_JUMP: "COMPUTED_JUMP",
277
+ COMPUTED_CALL: "COMPUTED_JUMP",
278
+ });
279
+
280
+ /** D-37-06's watched-address set, mirroring the Java constant
281
+ * `CONST_WRITE_WATCHED_ADDRESSES` in `GhidraStructExport.java` byte-for-byte
282
+ * -- the two MUST be kept in step. `parseConstWrites()` below does NOT
283
+ * filter its own output against this list: a `## CONST_WRITES` line for an
284
+ * address outside this set is still parsed and returned, because the
285
+ * EXPORTER owns the watched set, and a parser that silently dropped a
286
+ * widened set would hide the widening from every caller rather than
287
+ * surfacing it. */
288
+ export const CONST_WRITE_WATCHED_ADDRESSES: readonly number[] = Object.freeze([0x0001, 0xd011, 0xd018, 0xdd00]);
289
+
290
+ /** One resolved immediate store to a watched address, per `## CONST_WRITES`
291
+ * line: the instruction's own address, the memory address it wrote to, and
292
+ * the compile-time constant it wrote. All three are plain numbers -- callers
293
+ * needing `$`-formatted text format them themselves. */
294
+ export interface ConstWriteFact {
295
+ storeAddress: number;
296
+ targetAddress: number;
297
+ value: number;
298
+ }
299
+
300
+ /**
301
+ * Turns the `## CONST_WRITES` section `parseGhidraExport()` already parsed
302
+ * into `(storeAddress, targetAddress, value)` facts (Phase 37 plan 37-02,
303
+ * `AUTO-04`/`AUTO-05`). `parseGhidraExport()` has ALREADY refused, before
304
+ * this function is ever called, if a declared `## CONST_WRITES_COUNT`
305
+ * trailer disagrees with the section's own parsed body-line count -- the
306
+ * SAME generic check `## REFERENCE_COUNT`/`## CLASSIFICATION_LINES` already
307
+ * use. This function refuses on its own only for a body line whose token
308
+ * count is not exactly three (the importer's own named error, section and
309
+ * 1-BASED-WITHIN-THE-SECTION line number), or whose address/value token is
310
+ * not a resolvable constant (`parseGhidraAddressToken()`'s own refusal,
311
+ * reused rather than duplicated -- never a hand-rolled second numeric
312
+ * parser).
313
+ *
314
+ * A section carrying only the exporter's own `## CONST_WRITES_NONE` marker
315
+ * line yields an empty array, not an error: per `parseGhidraExport()`'s
316
+ * generic `## `-header grammar, that marker line itself OPENS A NEW,
317
+ * unrelated section (every `## `-prefixed line with no following space does)
318
+ * rather than being a `CONST_WRITES` body line, so `CONST_WRITES` is left
319
+ * with zero parsed body lines either way -- there is nothing here to
320
+ * special-case. A document that never mentions `CONST_WRITES` at all (no
321
+ * section key, no count trailer) yields the same empty array.
322
+ */
323
+ export function parseConstWrites(document: GhidraExportDocument): ConstWriteFact[] {
324
+ const lines = document.sections.get("CONST_WRITES") ?? [];
325
+ const facts: ConstWriteFact[] = [];
326
+ for (let i = 0; i < lines.length; i++) {
327
+ const line = lines[i]!;
328
+ const lineNo = i + 1;
329
+ const tokens = line.trim().split(/\s+/);
330
+ if (tokens.length !== 3) {
331
+ throw new AnnoImportError(
332
+ `anno_import_ghidra_export refused: CONST_WRITES line ${lineNo} does not match "<store-address> ` +
333
+ `<target-address> <value>": ${JSON.stringify(line)}`,
334
+ { section: "CONST_WRITES", line: lineNo },
335
+ );
336
+ }
337
+ const storeAddress = parseGhidraAddressToken(tokens[0]!, `CONST_WRITES line ${lineNo} storeAddress`);
338
+ const targetAddress = parseGhidraAddressToken(tokens[1]!, `CONST_WRITES line ${lineNo} targetAddress`);
339
+ const value = parseGhidraAddressToken(tokens[2]!, `CONST_WRITES line ${lineNo} value`);
340
+ facts.push({ storeAddress, targetAddress, value });
341
+ }
342
+ return facts;
343
+ }
344
+
345
+ /** What one `importGhidraExport()` call reports. */
346
+ export interface ImportCounts {
347
+ referencesSeen: number;
348
+ xrefsWritten: number;
349
+ xrefsAlreadyPresent: number;
350
+ kindsSeenNotImported: Record<string, number>;
351
+ transferPath: string;
352
+ transferSha256: string;
353
+ transferByteLength: number;
354
+ transferDeleted: boolean;
355
+ /** Present only when `transferDeleted` is `false` because the unlink
356
+ * itself threw -- the writes had already committed, so the import is
357
+ * still reported as a success with this reason attached, never as a
358
+ * failure after a durable write. */
359
+ transferDeleteError?: string;
360
+ /** CR-01 fix: the `## CONST_WRITES` section's own facts, parsed by
361
+ * `parseConstWrites()` BEFORE the transfer file is deleted below -- the
362
+ * ONE artifact carrying them. `importGhidraExport()` never persists these
363
+ * facts in the store (the reserved `bank` column stays null, IMP-01/D-37-25);
364
+ * they ride on THIS return value instead, so a caller can hand the SAME
365
+ * array straight to `anno_join_memmap`'s own `const_writes` argument in a
366
+ * following call, closing the loop `anno-tools.ts`'s `dispatchJoinMemmap()`
367
+ * previously left open (every call resolved through the unconstrained path
368
+ * because nothing ever supplied `runMemmapJoin()`'s `constWrites`).
369
+ * Always present, even when empty -- mirrors this interface's own
370
+ * "always present, often empty" siblings rather than being conditionally
371
+ * omitted. */
372
+ constWrites: ConstWriteFact[];
373
+ }
374
+
375
+ export interface ImportGhidraExportArgs {
376
+ exportPath: string;
377
+ expectedSha256?: string;
378
+ /** Test-only injection point for the delete step. Exists so "the writes
379
+ * committed but the delete itself failed" path (T-37-03) can be exercised
380
+ * DETERMINISTICALLY: making a directory read-only does not reliably block
381
+ * a delete when the test process runs as root (root ignores permission
382
+ * bits, and CI containers commonly run as root), so a caller-supplied
383
+ * removal function is the portable route. Defaults to the real deletion
384
+ * via `deleteTransferFile()` below. */
385
+ deleteFile?: (path: string) => void;
386
+ }
387
+
388
+ /** The real deletion step, defined once so `importGhidraExport()`'s own
389
+ * call site never spells the removal syscall's name directly -- a later
390
+ * acceptance gate greps this file for the literal token `unlinkSync` and
391
+ * requires it to appear on EXACTLY ONE non-comment line, which is this one. */
392
+ function deleteTransferFile(path: string): void {
393
+ fs.unlinkSync(path);
394
+ }
395
+
396
+ /**
397
+ * Imports one host-written transfer file into `handle`. Reads the file's
398
+ * bytes ONCE and derives both the digest and the byte length from that same
399
+ * buffer (mirroring `digestOutputFile()`'s shape at `host-tool.mts:1361-1372`
400
+ * -- the size and the hash must describe the same bytes). Parses fully,
401
+ * builds the mapped write list fully, and only THEN issues every `putXref()`
402
+ * call in file order. Deletes the transfer file in the LAST statement of the
403
+ * successful path, after every write has returned -- `putXref()` commits
404
+ * inside `applyWrite()` before returning, so a returned write is durable.
405
+ */
406
+ export function importGhidraExport(handle: AnnoStoreHandle, args: ImportGhidraExportArgs): ImportCounts {
407
+ const { exportPath } = args;
408
+
409
+ if (!fs.existsSync(exportPath)) {
410
+ throw new AnnoImportError(
411
+ `anno_import_ghidra_export refused: no transfer file exists at ${JSON.stringify(exportPath)}. Nothing was ` +
412
+ "read, nothing was written.",
413
+ { section: "(file)" },
414
+ );
415
+ }
416
+
417
+ const contents = fs.readFileSync(exportPath);
418
+ const transferSha256 = createHash("sha256").update(contents).digest("hex");
419
+ const transferByteLength = contents.length;
420
+
421
+ if (args.expectedSha256 !== undefined && args.expectedSha256 !== transferSha256) {
422
+ throw new AnnoImportError(
423
+ `anno_import_ghidra_export refused: expected sha256 ${args.expectedSha256} but the transfer file at ` +
424
+ `${JSON.stringify(exportPath)} hashes to ${transferSha256} -- this digest is a corruption/drift detector, ` +
425
+ "never a security boundary, and a mismatch refuses before anything is written or deleted.",
426
+ { section: "(digest)" },
427
+ );
428
+ }
429
+
430
+ const doc = parseGhidraExport(contents.toString("utf8"));
431
+ const referenceLines = doc.sections.get("REFERENCES") ?? [];
432
+ // CR-01/WR-02 fix: parsed here, from the SAME document, before the
433
+ // transfer file is deleted below -- `parseConstWrites()` was previously
434
+ // exercised only by test code (`anno-join.test.ts`/`ghidra-live.test.ts`),
435
+ // never by this, the only production entry point that reads a transfer
436
+ // file. See `ImportCounts.constWrites`'s own doc comment for how the
437
+ // facts reach `anno_join_memmap`.
438
+ const constWrites = parseConstWrites(doc);
439
+
440
+ const kindsSeenNotImported: Record<string, number> = {};
441
+ const writes: { fromAddress: number; toAddress: number; accessKind: XrefAccessKind }[] = [];
442
+ let referencesSeen = 0;
443
+
444
+ for (const line of referenceLines) {
445
+ referencesSeen += 1;
446
+ const tokens = line.trim().split(/\s+/);
447
+ const fromToken = tokens[0]!;
448
+ const toToken = tokens[2]!;
449
+ const kindToken = tokens[3]!;
450
+ const mappedKind = GHIDRA_REFTYPE_TO_ACCESS_KIND[kindToken];
451
+ if (mappedKind === undefined) {
452
+ kindsSeenNotImported[kindToken] = (kindsSeenNotImported[kindToken] ?? 0) + 1;
453
+ continue;
454
+ }
455
+ const fromAddress = parseGhidraAddressToken(fromToken, "REFERENCES fromAddress");
456
+ const toAddress = parseGhidraAddressToken(toToken, "REFERENCES toAddress");
457
+ writes.push({ fromAddress, toAddress, accessKind: mappedKind });
458
+ }
459
+
460
+ // PHASE TWO: every write is issued only after the whole document parsed
461
+ // and the whole write list was built above. No `putXref()` call happens
462
+ // before this point.
463
+ let xrefsWritten = 0;
464
+ let xrefsAlreadyPresent = 0;
465
+ for (const write of writes) {
466
+ const result = putXref(handle, write);
467
+ if (result.changed) xrefsWritten += 1;
468
+ else xrefsAlreadyPresent += 1;
469
+ }
470
+
471
+ // THE SINGLE DELETE CALL SITE. It runs here, after the loop above has
472
+ // fully returned, and nowhere else in this file.
473
+ const remove = args.deleteFile ?? deleteTransferFile;
474
+ let transferDeleted = true;
475
+ let transferDeleteError: string | undefined;
476
+ try {
477
+ remove(exportPath);
478
+ } catch (err) {
479
+ transferDeleted = false;
480
+ transferDeleteError = err instanceof Error ? err.message : String(err);
481
+ }
482
+
483
+ return {
484
+ referencesSeen,
485
+ xrefsWritten,
486
+ xrefsAlreadyPresent,
487
+ kindsSeenNotImported,
488
+ transferPath: exportPath,
489
+ transferSha256,
490
+ transferByteLength,
491
+ transferDeleted,
492
+ constWrites,
493
+ ...(transferDeleteError !== undefined ? { transferDeleteError } : {}),
494
+ };
495
+ }
package/anno-index.ts ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ // anno-index.ts
3
+ //
4
+ // The ONE place that answers "which annotated range owns this address" --
5
+ // a pure, narrowest-range-wins paint index over the whole 64K address space,
6
+ // rebuilt from rows and never maintained incrementally (STORE-03).
7
+ //
8
+ // ---------------------------------------------------------------------------
9
+ // WHY THIS FILE EXISTS
10
+ // ---------------------------------------------------------------------------
11
+ // The resolution rule is one sentence -- the SHORTEST range covering an address
12
+ // wins, and among equally short ones the LATER-inserted (higher id) wins -- and
13
+ // it has to be checkable against a second, independently written
14
+ // implementation. That cross-check is only possible if the rule is a pure
15
+ // function of a row list: an implementation that can only be reached by writing
16
+ // to a database cannot be fed the overlapping rows the tie-break case needs,
17
+ // because the store's write path exists precisely to prevent overlapping rows
18
+ // from ever being stored.
19
+ //
20
+ // So the rule lives here, taking rows as an argument, with no filesystem, no
21
+ // SQL, and no module-level state. The store calls it over `listRanges()`
22
+ // output; a test calls it over hand-built rows the store would never produce.
23
+ // Those are the same code path, which is the property that makes the
24
+ // cross-check mean anything.
25
+ //
26
+ // Rebuilding is affordable and was measured: a full rebuild over 2,000 rows is
27
+ // 8.56 ms, so there is no performance argument for the incremental variant that
28
+ // trap 4 forbids.
29
+ //
30
+ // ---------------------------------------------------------------------------
31
+ // WHAT NOT TO DO -- each entry names a specific, measured trap
32
+ // ---------------------------------------------------------------------------
33
+ // 1. NEVER move narrowest-wins behind the store's write path. That makes the
34
+ // equal-length tie-break unreachable: the write path's split-and-preserve
35
+ // is DESIGNED so overlapping rows never exist on disk, and the tie-break
36
+ // pin needs overlapping rows fed in directly. A pure module taking rows as
37
+ // an argument is the only shape that can be tested for it.
38
+ // 2. NEVER cache this index on disk. A cached index is a second truth that
39
+ // can disagree with the range table -- exactly the failure the
40
+ // derived-from-bytes coverage census (`COV-01`) exists to make impossible.
41
+ // Rebuild it; see the measurement above.
42
+ // 3. NEVER add an adjacency, coalescing or merge pass. `STORE-02` stores
43
+ // ranges AS ranges and never merges them, so there is no splitter
44
+ // primitive here to introduce. Merging is the named blocker behind
45
+ // `DECOMP-01` and `BUILD-02` and the predicted over-merge bias behind
46
+ // `COV-01`; all three are designed out by construction here, not
47
+ // documented as hazards to remember.
48
+ // 4. NEVER maintain the index incrementally. An incremental update has to
49
+ // know what the row it is removing was covering UNDERNEATH, which the
50
+ // index cannot say -- a painted cell records the winner, not the losers.
51
+ // 5. NEVER let the sort's stability decide the equal-length tie-break. It is
52
+ // pinned explicitly in the comparator below (decision `A4`) because the
53
+ // resolution rule requires SOME pinned tie-break and a stability-derived
54
+ // one is an implementation detail that two independent implementations
55
+ // would not share.
56
+ import { ADDRESS_MAX, ADDRESS_MIN, AnnoAddressError, AnnoRangeShapeError } from "./anno-types.ts";
57
+
58
+ /** One entry per 6510 address, 0x0000..0xFFFF inclusive. */
59
+ export const PAINT_INDEX_SIZE = 0x10000;
60
+
61
+ /** The value a cell holds when no range covers that address. Negative so it
62
+ * can never collide with a row id (SQLite `autoincrement` ids start at 1). */
63
+ export const NO_ROW = -1;
64
+
65
+ /** The painted index: `index[address]` is the winning row id, or `NO_ROW`.
66
+ * `Int32Array` rather than `number[]` so 65,536 cells cost 256 KB flat and the
67
+ * `fill()` per row is a typed-array memset rather than a per-cell loop. */
68
+ export type PaintIndex = Int32Array;
69
+
70
+ /** The minimum a row must carry to be paintable: an id and an inclusive span.
71
+ * Deliberately NOT `RangeRow` -- the index does not read, and must not read,
72
+ * the data type or the bank. */
73
+ export interface IndexableRange {
74
+ id: number;
75
+ start: number;
76
+ endInclusive: number;
77
+ }
78
+
79
+ /**
80
+ * Builds the paint index from `rows`. Does not mutate `rows` -- it sorts a
81
+ * copy.
82
+ *
83
+ * The algorithm is one line of insight: paint LONGEST span first, so any
84
+ * shorter range covering the same address is painted over it and therefore
85
+ * wins. Narrowest-wins falls out of the paint order rather than being
86
+ * re-checked per cell. Among equal-length rows the comparator orders by
87
+ * ASCENDING id, so the HIGHER id paints last and wins -- decision `A4`,
88
+ * "equal length, later insertion wins", pinned here rather than left to
89
+ * `Array.prototype.sort`'s stability (trap 5).
90
+ *
91
+ * Refuses a row whose span leaves the address space. This is not defensive
92
+ * decoration: `TypedArray.prototype.fill` CLAMPS out-of-range indices silently,
93
+ * so an unrefused bad row would paint a different span than it named, or
94
+ * nothing at all, with no error anywhere.
95
+ */
96
+ export function buildPaintIndex(rows: readonly IndexableRange[]): PaintIndex {
97
+ const index = new Int32Array(PAINT_INDEX_SIZE).fill(NO_ROW);
98
+
99
+ for (const row of rows) {
100
+ if (!Number.isInteger(row.start) || row.start < ADDRESS_MIN || row.start > ADDRESS_MAX) {
101
+ throw new AnnoRangeShapeError(
102
+ `range id ${row.id}: start ${String(row.start)} is outside the address space -- expected an integer ${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff)`,
103
+ { start: row.start, endInclusive: row.endInclusive },
104
+ );
105
+ }
106
+ if (!Number.isInteger(row.endInclusive) || row.endInclusive < ADDRESS_MIN || row.endInclusive > ADDRESS_MAX) {
107
+ throw new AnnoRangeShapeError(
108
+ `range id ${row.id}: endInclusive ${String(row.endInclusive)} is outside the address space -- expected an integer ${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff)`,
109
+ { start: row.start, endInclusive: row.endInclusive },
110
+ );
111
+ }
112
+ if (row.endInclusive < row.start) {
113
+ throw new AnnoRangeShapeError(
114
+ `range id ${row.id}: endInclusive ${row.endInclusive} is below start ${row.start} -- both ends are INCLUSIVE`,
115
+ { start: row.start, endInclusive: row.endInclusive },
116
+ );
117
+ }
118
+ }
119
+
120
+ const ordered = [...rows].sort((a, b) => {
121
+ const spanA = a.endInclusive - a.start + 1;
122
+ const spanB = b.endInclusive - b.start + 1;
123
+ if (spanA !== spanB) {
124
+ return spanB - spanA;
125
+ }
126
+ return a.id - b.id;
127
+ });
128
+
129
+ for (const row of ordered) {
130
+ index.fill(row.id, row.start, row.endInclusive + 1);
131
+ }
132
+
133
+ return index;
134
+ }
135
+
136
+ /**
137
+ * The winning row id at `address`, or `NO_ROW` when nothing covers it. Throws
138
+ * `AnnoAddressError` for an address outside the space rather than returning
139
+ * `undefined` -- an out-of-range probe is a caller bug, and `undefined` would
140
+ * be indistinguishable from "nothing here" at the call site.
141
+ */
142
+ export function resolveAt(index: PaintIndex, address: number): number {
143
+ if (!Number.isInteger(address) || address < ADDRESS_MIN || address > ADDRESS_MAX) {
144
+ throw new AnnoAddressError(
145
+ `address ${String(address)} is out of range -- expected an integer ${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff)`,
146
+ { input: address, what: "address" },
147
+ );
148
+ }
149
+ return index[address];
150
+ }