@henols/vice-mcp 0.2.1 → 0.2.2

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 (57) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -24
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-cli.ts +1465 -0
  5. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  6. package/anno-coverage.ts +2465 -0
  7. package/{r2000-d64.ts → anno-d64.ts} +5 -5
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +533 -0
  11. package/anno-export-asm.ts +1310 -0
  12. package/anno-index.ts +150 -0
  13. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  14. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  15. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  16. package/anno-register.ts +240 -0
  17. package/anno-store.ts +3486 -0
  18. package/anno-symbols.ts +266 -0
  19. package/anno-tools.ts +2111 -0
  20. package/anno-types.ts +1636 -0
  21. package/block-class.ts +201 -0
  22. package/build.ts +1 -1
  23. package/capability-registry.ts +3 -1
  24. package/disasm-decoder.ts +14 -14
  25. package/disasm-opcodes.ts +4 -4
  26. package/disasm-renderer.ts +2 -2
  27. package/hostpath.ts +1 -1
  28. package/install-resources.ts +1 -1
  29. package/package.json +23 -17
  30. package/prg-image.ts +119 -0
  31. package/repo-root.ts +20 -5
  32. package/resources/broker-launch.mjs +8 -4
  33. package/resources/vice-launcher.sh +3 -3
  34. package/stock-address.ts +5 -5
  35. package/stock-cia.ts +2 -2
  36. package/stock-condition.ts +7 -7
  37. package/stock-connect.ts +1 -1
  38. package/stock-dispatch.ts +35 -5
  39. package/stock-execution.ts +5 -3
  40. package/stock-input.ts +9 -9
  41. package/stock-machine.ts +17 -6
  42. package/stock-protocol.ts +16 -11
  43. package/stock-registers.ts +54 -29
  44. package/stock-sprites.ts +3 -3
  45. package/stock-symbols.ts +9 -9
  46. package/stock-timing.ts +1 -1
  47. package/stock-vicii.ts +1 -1
  48. package/version.ts +1 -1
  49. package/vice-proxy.ts +68 -46
  50. package/r2000-cli.ts +0 -1103
  51. package/r2000-enum-gen.ts +0 -574
  52. package/r2000-launch.ts +0 -357
  53. package/r2000-mcp-client.ts +0 -596
  54. package/r2000-project.ts +0 -190
  55. package/r2000-symbols.ts +0 -388
  56. package/r2000-tools.ts +0 -914
  57. package/r2000-verify.ts +0 -184
@@ -4,19 +4,19 @@
4
4
  // requirement.
5
5
  //
6
6
  // WHY THIS FILE EXISTS HERE, AND NOT AS AN EXTENSION OF
7
- // `.claude/skills/c64-ram-capture/scripts/d64-parse.mjs`: the researcher's
7
+ // `src/skills/c64-ram-capture/scripts/d64-parse.mjs`: the researcher's
8
8
  // own recommendation (RESEARCH.md Open Question #2) was to extend
9
9
  // `d64-parse.mjs` in place, since it already walks the directory chain. That
10
10
  // is not reachable in practice: this MCP server ships as `@henols/vice-mcp`,
11
- // whose `files[]` in `package.json` lists only `.claude/mcp/vice/` contents,
12
- // while `.claude/skills/**` ships in the *other* package
11
+ // whose `files[]` in `package.json` lists only `src/mcp/vice/` contents,
12
+ // while `src/skills/**` ships in the *other* package
13
13
  // (`@henols/c64-re-tools`). An import from this seam into a skill script
14
14
  // cannot resolve on either npm-installer route (neither copies the sibling
15
15
  // package's source tree onto disk next to it), and
16
16
  // `scripts/check-npm-packages.mjs`'s transitive-closure walk over `files[]`
17
17
  // would fail the pack the moment a reachable module sat outside the listed
18
18
  // set. So this is a SECOND, independent copy of the sector-chain-walk
19
- // algorithm, container-side, scoped to exactly what the r2000 bootstrap
19
+ // algorithm, container-side, scoped to exactly what the anno bootstrap
20
20
  // needs -- not a shared library and not an import of the skill-side module.
21
21
  //
22
22
  // `d64-parse.mjs` REMAINS the skill-side owner of the algorithm and is left
@@ -215,7 +215,7 @@ export function listEntries(image: Uint8Array): D64Entry[] {
215
215
  * The returned bytes are the file's RAW content INCLUDING its leading 2-byte
216
216
  * PRG load address, unmodified -- that is deliberate, since the whole point
217
217
  * of this module is to hand bytes straight to `parsePrg()` in
218
- * `r2000-project.ts`, which expects that same 2-byte header.
218
+ * `prg-image.ts`, which expects that same 2-byte header.
219
219
  */
220
220
  export function extractEntry(image: Uint8Array, entryName: string): Uint8Array {
221
221
  const entries = listEntries(image);
package/anno-derive.ts ADDED
@@ -0,0 +1,590 @@
1
+ #!/usr/bin/env node
2
+ // anno-derive.ts
3
+ //
4
+ // WHAT THIS IS THE ONE AUTHORITATIVE PLACE FOR: answers DERIVED from the
5
+ // program bytes on every query -- the cross-reference set for an address, and
6
+ // the search over labels, comments and rendered instruction text. Every answer
7
+ // here is recomputed from the caller's own bytes and the store's own range
8
+ // table each time it is asked for. Nothing this module computes is retained
9
+ // between calls, in memory or anywhere else.
10
+ //
11
+ // ---------------------------------------------------------------------------
12
+ // WHY THIS FILE EXISTS
13
+ // ---------------------------------------------------------------------------
14
+ // A cached derivation would be a SECOND ON-DISK TRUTH that can disagree with
15
+ // the range table it came from, and the disagreement would be invisible because
16
+ // both answers look authoritative. That is not this module's opinion -- three
17
+ // places in this tree state the rule independently:
18
+ //
19
+ // * `anno-store.ts`'s `putXref` doc block, in as many words: "nothing
20
+ // derivable is ever written here. A cached derivation would be a SECOND
21
+ // ON-DISK TRUTH that can disagree with the range table it came from".
22
+ // * `anno-types.ts`'s `XrefRow` type doc: "Only NON-DERIVABLE references live
23
+ // here".
24
+ // * `COV-01`'s derived-from-bytes byte-coverage census, whose whole
25
+ // discipline is that coverage is computed from bytes rather than recorded
26
+ // beside them.
27
+ //
28
+ // Splitting the derivation into its own module is what makes that rule
29
+ // ENFORCEABLE rather than merely stated: `anno-derive.test.ts`'s structural
30
+ // control reads THIS FILE's comment-stripped source and asserts it carries no
31
+ // SQL write verb, no filesystem write call and no persistence binding. A rule
32
+ // living in a file whose whole subject is derivation is a rule a reader trips
33
+ // over before breaking.
34
+ //
35
+ // ---------------------------------------------------------------------------
36
+ // THE MATCHING RULE, stated as a decision rather than left to be inferred
37
+ // ---------------------------------------------------------------------------
38
+ // Search matching is a BYTE-EXACT SUBSTRING test over the corpus text, CASE
39
+ // SENSITIVE, applied identically to all three corpora. No Unicode
40
+ // normalisation is applied and none is assumed.
41
+ //
42
+ // Case sensitivity is not a default that fell out of `String.prototype`
43
+ // includes: `anno-store.ts`'s `setLabel` doc block records that the store's own
44
+ // name-versus-name comparison is "EXACT BYTE EQUALITY -- the SQL `=` on a text
45
+ // column with the default (binary) collation [...] No case folding, no Unicode
46
+ // normalisation, no trimming." A search that case-folded would report a hit on
47
+ // a label the store itself considers a DIFFERENT name, so the search and the
48
+ // store would disagree about identity. `anno_search_disassembly`'s
49
+ // case-insensitive default is the shape this deliberately does not copy, and
50
+ // the divergence is named in the tool description rather than left for a caller
51
+ // to discover from a missing hit.
52
+ //
53
+ // ---------------------------------------------------------------------------
54
+ // WHAT NOT TO DO
55
+ // ---------------------------------------------------------------------------
56
+ // - Never write anything, anywhere, on any path in this file. No cache table,
57
+ // no index file, no memoised store column, no snapshot. That means: no SQL
58
+ // write statement, no `node:fs` write call, and no naming of the
59
+ // persistence builtin `node:sqlite` -- the store is reached ONLY through
60
+ // `anno-store.ts`'s own read entry points (STORE-07).
61
+ // - Never import `hostpath.ts`, `containerpath.ts` or `container-guard.mts`.
62
+ // This module is proxy-local; a host/container-translated path would point
63
+ // the derivation at bytes on the wrong side of the container boundary
64
+ // (MCP-02, and `hostpath-consumers.test.ts` names this module as forbidden).
65
+ // - Never add a second address parser, a second range validator or a second
66
+ // data-type vocabulary. `parseStoreAddress`, `assertRangeShape` and
67
+ // `assertDataType` are imported from `anno-types.ts` for exactly that
68
+ // reason; a divergent second rule would accept an address the store refuses.
69
+ // - Never re-derive a split table's partner arithmetic. `splitEntryAddressPairs`
70
+ // is the ONE place that knows an entry's partner; a second copy could
71
+ // disagree about what an entry IS, silently, because both copies produce
72
+ // legal answers.
73
+ // - Never give `max_results` a default. An implicit ceiling makes a truncated
74
+ // answer indistinguishable from a complete one; the ceiling is the caller's
75
+ // and the true total comes back beside the truncated list.
76
+ // - Never return a plausible zero for something this surface cannot answer.
77
+ // An unsupported corpus comes back as `{available:false, reason}` in the
78
+ // register style of `stock-cia.ts`'s unavailability table -- what was asked
79
+ // for, why it cannot be answered, and where the nearest answerable thing
80
+ // lives.
81
+ import { listComments, listLabels, listRanges, listXrefs } from "./anno-store.ts";
82
+ import type { AnnoStoreHandle } from "./anno-store.ts";
83
+ import {
84
+ ADDRESS_MAX,
85
+ ADDRESS_MIN,
86
+ AnnoStoreError,
87
+ assertDataType,
88
+ assertRangeShape,
89
+ isSplitDataType,
90
+ parseStoreAddress,
91
+ producesXrefsFor,
92
+ resolveSplitTargets,
93
+ splitEntryAddressPairs,
94
+ } from "./anno-types.ts";
95
+ import type { AnnoStoreErrorOptions, DataType, RangeRow } from "./anno-types.ts";
96
+ import { decode } from "./disasm-decoder.ts";
97
+ import type { Instruction } from "./disasm-decoder.ts";
98
+ import { renderLine } from "./disasm-renderer.ts";
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Refusals. Both are `AnnoStoreError` subclasses and therefore `ViceError`s --
102
+ // never a bare `Error` -- so one `catch` takes the whole family and the caller
103
+ // can name which member fired.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export interface AnnoDeriveArgumentErrorOptions extends AnnoStoreErrorOptions {
107
+ argument?: string;
108
+ value?: unknown;
109
+ }
110
+
111
+ /** An argument this module cannot derive from: an absent or non-positive
112
+ * `max_results`, a query that is not a string, or an image larger than the
113
+ * address space it is supposed to describe. The MCP transport validates
114
+ * NOTHING (`vice-proxy.ts:3230`'s `validate: (value) => ({ value })`), so every
115
+ * such argument is re-checked at the boundary that actually runs. */
116
+ export class AnnoDeriveArgumentError extends AnnoStoreError {
117
+ argument?: string;
118
+ value?: unknown;
119
+
120
+ constructor(message: string, { argument, value, ...rest }: AnnoDeriveArgumentErrorOptions = {}) {
121
+ super(message, rest);
122
+ this.name = "AnnoDeriveArgumentError";
123
+ this.argument = argument;
124
+ this.value = value;
125
+ }
126
+ }
127
+
128
+ export interface AnnoDerivedTargetErrorOptions extends AnnoStoreErrorOptions {
129
+ fromAddress?: number;
130
+ target?: number;
131
+ }
132
+
133
+ /** A decoded edge whose computed target left the 6510's address space.
134
+ *
135
+ * UNREACHABLE TODAY, AND DELIBERATELY KEPT: `disasm-decoder.ts`'s rule 2 masks
136
+ * every address it produces with `& 0xffff`, and its relative branch resolves
137
+ * `(address + 2 + signed8(offset)) & 0xffff`, so no `Instruction` this module
138
+ * can be handed carries an out-of-space target. This is defence in depth on a
139
+ * currently unreachable path -- the same posture `disasm-decoder.ts` records for
140
+ * its own `startAddress` bound -- and it exists so that a future decoder change
141
+ * that stopped wrapping is REPORTED BY NAME rather than silently wrapped or
142
+ * truncated here, where the wrap would look like a legitimate address. */
143
+ export class AnnoDerivedTargetError extends AnnoStoreError {
144
+ fromAddress?: number;
145
+ target?: number;
146
+
147
+ constructor(message: string, { fromAddress, target, ...rest }: AnnoDerivedTargetErrorOptions = {}) {
148
+ super(message, rest);
149
+ this.name = "AnnoDerivedTargetError";
150
+ this.fromAddress = fromAddress;
151
+ this.target = target;
152
+ }
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Bounds (T-29-15). Both are caller-facing refusals rather than silent
157
+ // truncation: an answer quietly computed over less than it was asked about is
158
+ // the failure the whole `max_results` convention exists against.
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /** The largest `image` this module will look at: the 6510's whole address
162
+ * space. A longer buffer cannot be a C64 program image -- every range the store
163
+ * can hold is bounded to `$0000..$ffff` by `assertRangeShape` -- so it is a
164
+ * caller mistake (a whole `.d64`, an unsplit archive) and is refused by name
165
+ * rather than walked. */
166
+ export const ANNO_DERIVE_MAX_IMAGE_BYTES = ADDRESS_MAX - ADDRESS_MIN + 1;
167
+
168
+ /** The default ceiling on how many bytes `searchAnnotations` will decode to
169
+ * build its instruction corpus. Overridable through the environment variable of
170
+ * the same name, READ AT CALL TIME (never frozen at module load) -- the same
171
+ * read-at-call-time convention `anno-tools.ts`'s `ANNO_READ_REGION_MAX_BYTES`
172
+ * override uses, so one `node --test` process can point several different caps
173
+ * at this code within a single run. */
174
+ export const ANNO_SEARCH_MAX_CORPUS_BYTES = ADDRESS_MAX - ADDRESS_MIN + 1;
175
+
176
+ function currentCorpusByteCap(): number {
177
+ const raw = process.env.ANNO_SEARCH_MAX_CORPUS_BYTES;
178
+ if (raw === undefined) return ANNO_SEARCH_MAX_CORPUS_BYTES;
179
+ const n = Number(raw);
180
+ return Number.isFinite(n) && n > 0 ? n : ANNO_SEARCH_MAX_CORPUS_BYTES;
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Shared byte plumbing. Every slice below is bounded by a `RangeRow`'s OWN
185
+ // `start`/`endInclusive` and by the image's own length (T-29-17): a derived
186
+ // answer can never read a byte outside a range a human typed.
187
+ // ---------------------------------------------------------------------------
188
+
189
+ function assertImage(image: unknown): Uint8Array {
190
+ if (!(image instanceof Uint8Array)) {
191
+ throw new AnnoDeriveArgumentError(
192
+ 'the program "image" must be a Uint8Array of the bytes to derive from -- the store holds NO program image (D-07), so every ' +
193
+ "derived answer is computed from bytes the caller names.",
194
+ { argument: "image", value: typeof image },
195
+ );
196
+ }
197
+ if (image.length > ANNO_DERIVE_MAX_IMAGE_BYTES) {
198
+ throw new AnnoDeriveArgumentError(
199
+ `the program "image" is ${image.length} bytes, which exceeds ANNO_DERIVE_MAX_IMAGE_BYTES (${ANNO_DERIVE_MAX_IMAGE_BYTES}, the ` +
200
+ "6510's whole address space). Every range this store can hold is bounded to $0000..$ffff, so a longer buffer is not a program " +
201
+ "image -- refused by name rather than walked.",
202
+ { argument: "image", value: image.length },
203
+ );
204
+ }
205
+ return image;
206
+ }
207
+
208
+ function assertOrigin(origin: unknown): number {
209
+ return parseStoreAddress(origin, { what: "origin" });
210
+ }
211
+
212
+ /**
213
+ * The bytes of `range` as they sit in `image`, or `null` when the range is not
214
+ * reachable in this image at all.
215
+ *
216
+ * `range.start - origin` is computed and CHECKED before it is used: a negative
217
+ * offset handed to `subarray()` counts from the END of the buffer, which would
218
+ * silently return a plausible-looking slice of the wrong bytes. The tail is
219
+ * clamped to the image's own length so a range that runs past the last byte
220
+ * yields the bytes that exist (which `decode()` reports as `truncated`) rather
221
+ * than a fabricated remainder.
222
+ */
223
+ function sliceForRange(image: Uint8Array, origin: number, range: RangeRow): Uint8Array | null {
224
+ const begin = range.start - origin;
225
+ if (begin < 0 || begin >= image.length) return null;
226
+ const end = Math.min(range.endInclusive - origin + 1, image.length);
227
+ if (end <= begin) return null;
228
+ return image.subarray(begin, end);
229
+ }
230
+
231
+ /** Re-narrows a row's `data_type` text through the ONE vocabulary. `listRanges`
232
+ * casts the column (`row.data_type as DataType`) without validating it, so this
233
+ * is where a store written by something else stops being trusted. */
234
+ function rangeDataType(range: RangeRow): DataType {
235
+ return assertDataType(range.dataType);
236
+ }
237
+
238
+ /** Every range this store types `code`, with its bytes and its own shape
239
+ * re-validated. */
240
+ function codeRanges(handle: AnnoStoreHandle): RangeRow[] {
241
+ return listRanges(handle).filter((range) => rangeDataType(range) === "code");
242
+ }
243
+
244
+ /** Decodes one `code` range fresh. There is deliberately no memoisation here,
245
+ * not even within a single call: see this file's header. */
246
+ function decodeRange(image: Uint8Array, origin: number, range: RangeRow): Instruction[] {
247
+ const bytes = sliceForRange(image, origin, range);
248
+ if (bytes === null) return [];
249
+ assertRangeShape(range.start, range.endInclusive, rangeDataType(range));
250
+ return decode(bytes, range.start, { end: range.endInclusive });
251
+ }
252
+
253
+ /**
254
+ * The address an instruction REFERENCES, or `undefined` when it references
255
+ * none.
256
+ *
257
+ * * No operand at all (`rts`, `nop`) -- nothing to reference.
258
+ * * `immediate` -- the operand IS the value. `lda #$c0` encodes the byte
259
+ * `$c0`; reporting it as a reference to `$00c0` would invent an edge.
260
+ * * `indirect` -- `jmp ($0314)` transfers to whatever the two bytes AT
261
+ * `$0314` hold. That target is not derivable from this instruction, and
262
+ * attributing an edge to the vector's own address would name the wrong one.
263
+ * * Everything else (`absolute`, `zeropage`, `relative`) references an
264
+ * address: `resolvedTarget` when the decoder resolved one (a branch, a
265
+ * `jmp`/`jsr` absolute), `operand.value` otherwise.
266
+ */
267
+ function referencedAddress(instruction: Instruction): number | undefined {
268
+ const operand = instruction.operand;
269
+ if (operand === undefined) return undefined;
270
+ if (operand.role === "immediate" || operand.role === "indirect") return undefined;
271
+ const target = instruction.resolvedTarget ?? operand.value;
272
+ if (!Number.isInteger(target) || target < ADDRESS_MIN || target > ADDRESS_MAX) {
273
+ throw new AnnoDerivedTargetError(
274
+ `the instruction at $${instruction.address.toString(16).padStart(4, "0")} computed the target ${String(target)}, which is outside ` +
275
+ `${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff). Reported by name rather than wrapped or truncated: a wrapped target is a ` +
276
+ "plausible-looking address pointing at the wrong place, and nothing downstream could tell.",
277
+ { fromAddress: instruction.address, target: typeof target === "number" ? target : undefined },
278
+ );
279
+ }
280
+ return target;
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Derived cross-references (STORE-06)
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /** What `crossReferencesTo()` returns: the target, every address that reaches
288
+ * it (ascending, de-duplicated), and the count restated as a field so a caller
289
+ * can assert it without walking the array -- the same shape convention
290
+ * `SplitTargets` uses. */
291
+ export interface CrossReferencesResult {
292
+ to: number;
293
+ callers: readonly number[];
294
+ count: number;
295
+ }
296
+
297
+ /**
298
+ * Every address that references `to`, unioned from THREE sources and returned
299
+ * as one ascending, de-duplicated list.
300
+ *
301
+ * 1. THE DECODED CODE. Every range `listRanges()` types `code`, sliced out of
302
+ * `image` and decoded FRESH, keeping the instructions whose operand role
303
+ * references an address (see `referencedAddress`).
304
+ * 2. THE TYPED SPLIT TABLES. Every range whose type is a split layout AND for
305
+ * which `producesXrefsFor()` is true -- the `_address` forms produce
306
+ * cross-references and the `_word` forms do not, which is the schema's own
307
+ * distinction and not a judgement made here. Each resolved target is
308
+ * attributed to its entry's own first-half address, taken from
309
+ * `splitEntryAddressPairs()` rather than recomputed.
310
+ * 3. THE STORED ROWS. `listXrefs()` -- the ONLY half that lives on disk, and
311
+ * only because those references (a computed dispatch, a hand-asserted
312
+ * edge) cannot be recovered from the bytes at all.
313
+ *
314
+ * The union is a `Set`, so an address reached by two sources appears once; the
315
+ * sort makes the answer stable regardless of the order the three sources are
316
+ * walked. NOTHING IS WRITTEN.
317
+ */
318
+ export function crossReferencesTo(
319
+ handle: AnnoStoreHandle,
320
+ image: Uint8Array,
321
+ origin: number | string,
322
+ to: number | string,
323
+ ): CrossReferencesResult {
324
+ const bytes = assertImage(image);
325
+ const base = assertOrigin(origin);
326
+ const target = parseStoreAddress(to, { what: "to" });
327
+
328
+ const callers = new Set<number>();
329
+
330
+ // 1. Derived from the bytes: every code range, decoded fresh.
331
+ for (const range of codeRanges(handle)) {
332
+ for (const instruction of decodeRange(bytes, base, range)) {
333
+ if (referencedAddress(instruction) === target) callers.add(instruction.address);
334
+ }
335
+ }
336
+
337
+ // 2. Derived from typed split tables (also bytes, also never stored).
338
+ for (const range of listRanges(handle)) {
339
+ const dataType = rangeDataType(range);
340
+ if (!isSplitDataType(dataType) || !producesXrefsFor(dataType)) continue;
341
+ const tableBytes = sliceForRange(bytes, base, range);
342
+ // A PARTIAL split table is skipped rather than resolved. `anno-types.ts`'s
343
+ // `SplitTableReinterpretation` records why: an entry's partner is a
344
+ // function of the row's start AND its length, so a fragment re-pairs every
345
+ // entry and decodes to DIFFERENT 16-bit values than the ones a human
346
+ // recorded. Resolving a fragment would produce legal, plausible, wrong
347
+ // targets.
348
+ if (tableBytes === null || tableBytes.length !== range.endInclusive - range.start + 1) continue;
349
+ const { targets } = resolveSplitTargets(tableBytes, dataType);
350
+ const { pairs } = splitEntryAddressPairs(range.start, range.endInclusive, dataType);
351
+ targets.forEach((resolved, i) => {
352
+ if (resolved === target) callers.add(pairs[i]![0]);
353
+ });
354
+ }
355
+
356
+ // 3. The ONLY stored half: references that cannot be recovered from bytes.
357
+ for (const row of listXrefs(handle)) {
358
+ if (row.toAddress === target) callers.add(row.fromAddress);
359
+ }
360
+
361
+ const sorted = [...callers].sort((a, b) => a - b);
362
+ return { to: target, callers: sorted, count: sorted.length };
363
+ }
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // Search (STORE-06)
367
+ // ---------------------------------------------------------------------------
368
+
369
+ /** The three corpora this surface has. Frozen and derived from, never
370
+ * re-typed: `CorpusName` is `Extract`ed from it below. */
371
+ export const SEARCH_CORPORA = Object.freeze(["labels", "comments", "instructions"] as const);
372
+
373
+ /** One member of the frozen three. */
374
+ export type CorpusName = (typeof SEARCH_CORPORA)[number];
375
+
376
+ /** One search hit. `corpus` is carried on EVERY hit so a caller can tell a
377
+ * label match from an instruction match without re-deriving which corpus could
378
+ * have produced the text. */
379
+ export interface SearchHit {
380
+ corpus: CorpusName;
381
+ address: number;
382
+ text: string;
383
+ }
384
+
385
+ /** What a corpus this surface does not have reports. Structurally identical to
386
+ * `stock-cia.ts`'s unavailability entries, and for the same reason: an
387
+ * unanswerable question must never come back as a plausible zero. */
388
+ export interface UnavailableCorpus {
389
+ available: false;
390
+ reason: string;
391
+ }
392
+
393
+ export interface SearchRequest {
394
+ query: string;
395
+ max_results: number;
396
+ search_labels?: boolean;
397
+ search_comments?: boolean;
398
+ search_instructions?: boolean;
399
+ [key: string]: unknown;
400
+ }
401
+
402
+ export interface SearchResult {
403
+ query: string;
404
+ caseSensitive: true;
405
+ corpora: Record<CorpusName, { searched: boolean; entries: number }>;
406
+ unavailable: Record<string, UnavailableCorpus>;
407
+ results: SearchHit[];
408
+ returned: number;
409
+ total: number;
410
+ truncated: boolean;
411
+ }
412
+
413
+ /** Where each corpus's own data actually lives, named in the unavailability
414
+ * reason so a refusal points at the answerable thing instead of stopping at
415
+ * "no". */
416
+ const NEAREST_ANSWERABLE =
417
+ "labels (anno-store.ts's listLabels), comments (listComments) and the instruction text rendered from the ranges typed `code`";
418
+
419
+ function unavailableCorpus(name: string): UnavailableCorpus {
420
+ return {
421
+ available: false,
422
+ reason:
423
+ `search_${name} names the "${name}" corpus, which this surface does not search. The three corpora it does search are ` +
424
+ `${NEAREST_ANSWERABLE}. Every other annotation kind is read directly through its own anno-store.ts list entry point rather ` +
425
+ "than through this search, because a corpus with no rendered text has nothing for a substring rule to match.",
426
+ };
427
+ }
428
+
429
+ function assertQuery(query: unknown): string {
430
+ if (typeof query !== "string" || query === "") {
431
+ throw new AnnoDeriveArgumentError(
432
+ `"query" must be a non-empty string, got ${JSON.stringify(query)} -- an empty query would match every entry of every corpus, ` +
433
+ "which is a listing rather than a search and is what the list entry points are for.",
434
+ { argument: "query", value: query },
435
+ );
436
+ }
437
+ return query;
438
+ }
439
+
440
+ /** `max_results` is REQUIRED with no default, and `0` is refused by name. An
441
+ * implicit ceiling makes a truncated answer indistinguishable from a complete
442
+ * one; a ceiling of zero asks for an answer that cannot carry information. */
443
+ function assertMaxResults(request: SearchRequest | undefined): number {
444
+ const raw = request?.max_results;
445
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw <= 0) {
446
+ throw new AnnoDeriveArgumentError(
447
+ `"max_results" must be a positive integer, got ${JSON.stringify(raw)} -- it is REQUIRED and has no default on this surface. ` +
448
+ "Pass an explicit ceiling and compare the returned count against it to detect truncation; the true total comes back beside " +
449
+ "the truncated list.",
450
+ { argument: "max_results", value: raw },
451
+ );
452
+ }
453
+ return raw;
454
+ }
455
+
456
+ /** True when the request did not explicitly disable this corpus. Enabled is the
457
+ * default for all three, matching `anno_search_disassembly`'s advertised
458
+ * shape. */
459
+ function corpusEnabled(request: SearchRequest, corpus: CorpusName): boolean {
460
+ const flag = request[`search_${corpus}`];
461
+ return flag !== false;
462
+ }
463
+
464
+ /** Every `search_*` key naming something outside the frozen three. */
465
+ function unsupportedCorpora(request: SearchRequest): string[] {
466
+ const known = new Set<string>(SEARCH_CORPORA);
467
+ const found: string[] = [];
468
+ for (const key of Object.keys(request)) {
469
+ const match = /^search_(.+)$/.exec(key);
470
+ if (match === null) continue;
471
+ const name = match[1]!;
472
+ if (!known.has(name)) found.push(name);
473
+ }
474
+ return found.sort();
475
+ }
476
+
477
+ /** The instruction corpus: one rendered line per decoded instruction, carrying
478
+ * the address it was decoded at.
479
+ *
480
+ * `renderLine()` rather than `render()` over the whole range: a hit has to
481
+ * carry the address it was found at, and `render()`'s output is one blob with a
482
+ * `!cpu 6510` header and a symbol block. Both go through the same
483
+ * `renderInstructionLine()` inside `disasm-renderer.ts`, so the text a search
484
+ * matches is byte-identical to the text a listing shows.
485
+ *
486
+ * BOUNDED (T-29-15): the total number of bytes decoded for the corpus is capped
487
+ * and exceeding the cap is REFUSED BY NAME. A silently truncated corpus would
488
+ * report a genuine-looking zero for a term that is really there. */
489
+ function instructionCorpus(handle: AnnoStoreHandle, image: Uint8Array, origin: number): SearchHit[] {
490
+ const cap = currentCorpusByteCap();
491
+ const ranges = codeRanges(handle);
492
+ let budget = 0;
493
+ for (const range of ranges) {
494
+ const bytes = sliceForRange(image, origin, range);
495
+ if (bytes !== null) budget += bytes.length;
496
+ }
497
+ if (budget > cap) {
498
+ throw new AnnoDeriveArgumentError(
499
+ `the ranges typed \`code\` cover ${budget} byte(s) of this image, which exceeds the ANNO_SEARCH_MAX_CORPUS_BYTES cap of ${cap}. ` +
500
+ "Refused rather than silently truncated: a corpus cut short reports a genuine-looking zero for a term that is really there. " +
501
+ "Narrow the typed ranges, or raise ANNO_SEARCH_MAX_CORPUS_BYTES.",
502
+ { argument: "image", value: budget },
503
+ );
504
+ }
505
+
506
+ const hits: SearchHit[] = [];
507
+ for (const range of ranges) {
508
+ for (const instruction of decodeRange(image, origin, range)) {
509
+ hits.push({ corpus: "instructions", address: instruction.address, text: renderLine(instruction) });
510
+ }
511
+ }
512
+ return hits;
513
+ }
514
+
515
+ /**
516
+ * Searches the three corpora for `request.query`.
517
+ *
518
+ * MATCHING IS BYTE-EXACT AND CASE-SENSITIVE over the corpus text, applied
519
+ * identically to all three -- see the module header for why case folding would
520
+ * put this search and the store's own name comparison into disagreement. The
521
+ * rule is restated in the result body as `caseSensitive`, so a caller reading
522
+ * an empty answer is told which rule produced it.
523
+ *
524
+ * EVERY CORPUS IS NAMED IN THE RESULT with the number of entries it held, so an
525
+ * EMPTY RESULT over a real, non-empty corpus (a genuine zero) is distinguishable
526
+ * from a corpus this surface does not have (`unavailable`, carrying a reason).
527
+ * The two are separate fields; neither is inferable from the other.
528
+ */
529
+ export function searchAnnotations(
530
+ handle: AnnoStoreHandle,
531
+ image: Uint8Array,
532
+ origin: number | string,
533
+ request: SearchRequest,
534
+ ): SearchResult {
535
+ const bytes = assertImage(image);
536
+ const base = assertOrigin(origin);
537
+ const bag: SearchRequest = (typeof request === "object" && request !== null ? request : {}) as SearchRequest;
538
+ const query = assertQuery(bag.query);
539
+ const maxResults = assertMaxResults(bag);
540
+
541
+ const unavailable: Record<string, UnavailableCorpus> = {};
542
+ for (const name of unsupportedCorpora(bag)) unavailable[name] = unavailableCorpus(name);
543
+
544
+ const labelHits: SearchHit[] = listLabels(handle).map((row) => ({
545
+ corpus: "labels" as const,
546
+ address: row.address,
547
+ text: row.name,
548
+ }));
549
+ const commentHits: SearchHit[] = listComments(handle).map((row) => ({
550
+ corpus: "comments" as const,
551
+ address: row.address,
552
+ text: row.text,
553
+ }));
554
+
555
+ const enabled: Record<CorpusName, boolean> = {
556
+ labels: corpusEnabled(bag, "labels"),
557
+ comments: corpusEnabled(bag, "comments"),
558
+ instructions: corpusEnabled(bag, "instructions"),
559
+ };
560
+
561
+ // The instruction corpus is only BUILT when it is going to be searched:
562
+ // decoding every code range to answer a request that disabled the corpus
563
+ // would pay the whole cost for nothing, and would raise the byte-cap refusal
564
+ // for a corpus the caller did not ask about.
565
+ const instructionHits: SearchHit[] = enabled.instructions ? instructionCorpus(handle, bytes, base) : [];
566
+
567
+ const corpora: Record<CorpusName, { searched: boolean; entries: number }> = {
568
+ labels: { searched: enabled.labels, entries: labelHits.length },
569
+ comments: { searched: enabled.comments, entries: commentHits.length },
570
+ instructions: { searched: enabled.instructions, entries: instructionHits.length },
571
+ };
572
+
573
+ const matched: SearchHit[] = [];
574
+ for (const hit of [...labelHits, ...commentHits, ...instructionHits]) {
575
+ if (!enabled[hit.corpus]) continue;
576
+ if (hit.text.includes(query)) matched.push(hit);
577
+ }
578
+
579
+ const results = matched.slice(0, maxResults);
580
+ return {
581
+ query,
582
+ caseSensitive: true,
583
+ corpora,
584
+ unavailable,
585
+ results,
586
+ returned: results.length,
587
+ total: matched.length,
588
+ truncated: matched.length > results.length,
589
+ };
590
+ }