@henols/vice-mcp 0.2.0 → 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 (49) hide show
  1. package/README.md +2 -1
  2. package/THIRD-PARTY-NOTICES.md +1 -1
  3. package/anno-acme-ident.ts +97 -0
  4. package/anno-cli.ts +1465 -0
  5. package/anno-confidence.ts +233 -0
  6. package/anno-coverage.ts +2465 -0
  7. package/anno-d64.ts +310 -0
  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/anno-memmap-render.ts +672 -0
  14. package/anno-regbits-gen.ts +421 -0
  15. package/anno-regbits.json +1370 -0
  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 -3
  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 +33 -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 +168 -0
package/anno-types.ts ADDED
@@ -0,0 +1,1636 @@
1
+ #!/usr/bin/env node
2
+ // anno-types.ts
3
+ //
4
+ // The ONE place that writes down the annotation store's data-type vocabulary,
5
+ // its range row shape, and every validator the store runs before a caller's
6
+ // argument is allowed anywhere near SQL (STORE-01).
7
+ //
8
+ // ---------------------------------------------------------------------------
9
+ // WHY THIS FILE EXISTS
10
+ // ---------------------------------------------------------------------------
11
+ // The MCP proxy validates NOTHING. `vice-proxy.ts:3224` declares
12
+ // `rawJsonSchemaAsStandardSchema()`, and its validator at `:3230` is literally
13
+ // `validate: (value: unknown) => ({ value })` -- by design, and documented as
14
+ // such right there, because that is what keeps `tools/list`'s wire output
15
+ // byte-identical to the manifest's own raw schema. The consequence is that
16
+ // every argument reaches the store UNVALIDATED: an address of 65536, a
17
+ // misspelled data type, and a store path pointing outside the workspace all
18
+ // look identical to the transport.
19
+ //
20
+ // So validation lives here, at the store's own entry, and throws named
21
+ // `ViceError` subclasses whose messages embed the offending value AND the
22
+ // valid range or form -- `stock-address.ts:132-134`'s convention. It does NOT
23
+ // use `zod`: zod exists in this tree only as an undeclared transitive of
24
+ // `@mastra`, so a shipped module importing it would depend on a package this
25
+ // repo never declared and could lose without notice.
26
+ //
27
+ // The vocabulary itself is the one irreversible decision in this area.
28
+ // Split-table ORIENTATION cannot be recovered from a store that never
29
+ // recorded it -- there is no field to migrate, so the recovery cost is a hand
30
+ // re-annotation of every split table in every project file. That is why all
31
+ // four split layouts are first-class members rather than one `table` member
32
+ // plus an orientation flag, and why the twelve strings are frozen and pinned
33
+ // by a hand-written test (`anno-types.test.ts`) rather than by a derived one.
34
+ //
35
+ // ---------------------------------------------------------------------------
36
+ // WHAT NOT TO DO -- each entry names a specific, measured trap
37
+ // ---------------------------------------------------------------------------
38
+ // 1. NEVER re-spell, re-order, add to or remove from the twelve members of
39
+ // `DATA_TYPES`. They are the `anno_set_data_type` schema's own strings in
40
+ // the schema's own order (`anno-tools.ts:291-304`), and
41
+ // `src/skills/c64-memory-mapping/SKILL.md` already names all four split
42
+ // variants verbatim -- a re-spelling breaks a shipped playbook and buys
43
+ // nothing. Narrowing the vocabulary once a project file exists is not a
44
+ // migration; it is data loss.
45
+ // 2. NEVER write a second literal list of the split layouts.
46
+ // `SPLIT_DATA_TYPES` is DERIVED by filtering `DATA_TYPES`, the way
47
+ // `anno-confidence.ts:79` derives `VALID_BRACKETS` with a `.map()`. Two
48
+ // literal lists are two homes for one fact, and they drift silently.
49
+ // 3. NEVER hold module-level mutable state here. Every export below is a
50
+ // frozen constant, so two concurrent callers cannot observe each other
51
+ // and there is nothing to reset. That half of the rule is unchanged and
52
+ // unconditional.
53
+ // NARROWED 2026-08-28, and the reversal is the record rather than a
54
+ // deletion. This paragraph used to end "...or a pure function of its
55
+ // arguments", and that clause is now false for exactly ONE export:
56
+ // `storePathWithinWorkspace()` is a function of its arguments AND THE
57
+ // FILESYSTEM. It must be. Workspace confinement has to answer whether a
58
+ // path lands outside the root once symbolic links are followed, and that
59
+ // is a filesystem question that no string comparison can answer -- the
60
+ // earlier pure-string version accepted a symlinked subdirectory and let a
61
+ // store file be created outside the workspace root (`28-VERIFICATION.md`
62
+ // gap 3 / `28-REVIEW.md` CR-03, reproduced). Every OTHER export is still
63
+ // a pure function of its arguments and still unit-testable with no file
64
+ // on disk. The exception is named here, in `storePathWithinWorkspace`'s
65
+ // own doc comment, and in `anno-types.test.ts`'s mutable-state assertion
66
+ // message, so no reader can find a place that still claims total purity.
67
+ // 4. NEVER accept an unprefixed numeric string as an address.
68
+ // `parseStoreAddress()` takes an integer, a `$hex` string and a
69
+ // `0x`/`0X` string, and refuses `"1024"`. This is a REAL, user-visible
70
+ // divergence from `stock-address.ts:155-160`, which accepts the bare
71
+ // decimal form AS DECIMAL under its own decision `D-04` and says so at
72
+ // `:89-105`. The reason the store diverges: a mis-based address written
73
+ // into the store is PERSISTENT and silently wrong -- every later reader
74
+ // inherits it -- whereas a mis-based memory read is transient and the
75
+ // caller sees the wrong bytes immediately. An agent WILL hit this: the
76
+ // same string that reads memory at 1024 decimal is refused here.
77
+ // 5. NEVER reuse `stock-address.ts`'s `parseAddress()`. Trap 4 is the first
78
+ // reason; the second is independent of it -- that module carries
79
+ // module-level mutable resolver state at `:44-76`, so its parse result
80
+ // depends on whether a symbol table happens to be installed. A store
81
+ // write must not.
82
+ // 6. NEVER let a validator return a value instead of throwing. A refusal
83
+ // that returns a default writes the default into the store.
84
+ // 7. NEVER sanitise, substitute, trim or quote a label name. An illegal name
85
+ // is REFUSED outright. The hazard is concrete: a space-to-underscore
86
+ // substitution turns `init screen` and `init_screen` -- two names a human
87
+ // deliberately distinguished -- into ONE name, and the loss is silent and
88
+ // permanent because nothing records that a substitution happened. A legal
89
+ // name already bound to a different address is refused for the same
90
+ // reason rather than rebound. The schema states the rule itself
91
+ // (`anno-tools.ts:246-251`): "An illegal name is REJECTED, never
92
+ // sanitized or quoted."
93
+ // 8. NEVER restate the eleven auto-generated-name prefixes here. They live in
94
+ // exactly one place, `anno-coverage.ts`'s `AUTO_NAME_PREFIX_RE`, and
95
+ // `EXPORT-02` names the exact failure a short reimplementation causes: a
96
+ // five-prefix copy silently under-counts, which breaks the
97
+ // `routine-queue-walker` skill's backlog construction while every test
98
+ // keeps passing. The store separates the two namespaces with its label
99
+ // `kind` field, not with a name pattern.
100
+ // 9. NEVER build a mnemonic-to-access-kind classifier here. Nothing derivable
101
+ // is stored (see `anno-store.ts`'s `putXref`), `OpcodeEntry` carries no
102
+ // access/reads/writes field at all, and `REQUIREMENTS.md` records the
103
+ // analysis built on such a field as deferred with a named trigger. A
104
+ // classifier written now would have no caller and no way to be wrong
105
+ // observably.
106
+ // 10. NEVER measure comment length in code units. `String.length` counts UTF-16
107
+ // code units, so a multi-byte comment passes a code-unit check and then
108
+ // exceeds the byte bound on disk. `assertCommentText()` measures with a
109
+ // `TextEncoder`.
110
+ import { existsSync, lstatSync, readlinkSync, realpathSync } from "node:fs";
111
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
112
+
113
+ import { OPCODES } from "./disasm-opcodes.ts";
114
+ import { ViceError, type ViceErrorOptions } from "./vice.ts";
115
+
116
+ /**
117
+ * The on-disk schema version every store file carries in `anno_meta`. A
118
+ * store whose `schema_version` is not this exact value is REFUSED, never
119
+ * silently upgraded.
120
+ *
121
+ * VERSION 2, AND THE REVERSAL IS RECORDED RATHER THAN SILENTLY OVERWRITTEN,
122
+ * because a rationale that became false is evidence (`anno-store.ts`'s header
123
+ * discipline, 28-07 P3).
124
+ *
125
+ * The sentence still true: a store whose declared version is not this exact
126
+ * value is refused by name, never upgraded in place.
127
+ *
128
+ * The sentence that became false: this constant was `1`, and the DDL beside it
129
+ * claimed "`SCHEMA_VERSION` stays 1 and no later work alters an on-disk shape".
130
+ * Version 1 named a snapshot through a PERSISTED ABSOLUTE STRING in
131
+ * `anno_snapshot.path`, inside a ring directory whose name was the fixed
132
+ * `<dir>/snapshots`. Two consequences were reproduced against committed code:
133
+ *
134
+ * * TWO STORES IN ONE DIRECTORY SHARED ONE RING under the same
135
+ * `r<revision>.db` filenames, so `revertTo` on one store restored the
136
+ * OTHER store's whole database, silently and with no error (CR-01).
137
+ * * RENAMING THE CONTAINING DIRECTORY invalidated every persisted absolute
138
+ * path at once, after which `retainedRevisions()` reported none and the
139
+ * next accepted write's prune destroyed the entire revert history (CR-03).
140
+ *
141
+ * Version 2 drops `anno_snapshot.path` -- there is no persisted string left for
142
+ * a second namespace to disagree with -- and derives the location from the
143
+ * handle at every read and every delete via `snapshotDirFor()`.
144
+ *
145
+ * A VERSION-1 STORE IS REFUSED, NOT UPGRADED, and the reason is that the
146
+ * version-1 ring's OWNERSHIP is not recoverable: CR-01 means two stores may
147
+ * both have written into `<dir>/snapshots`, and nothing recorded which file
148
+ * belonged to which store. Any migration would have to guess, attributing one
149
+ * store's history to another -- CR-01 again with a new cause and no test
150
+ * watching. The legacy directory is therefore left on disk untouched: never
151
+ * adopted, never migrated, never deleted, so the bytes stay recoverable by
152
+ * hand.
153
+ *
154
+ * ---------------------------------------------------------------------------
155
+ * VERSION 3, 2026-08-29 (D-15) -- AND THE COST IS NAMED HERE RATHER THAN LEFT
156
+ * IN A PLANNING DIRECTORY, because a version number whose rationale lives
157
+ * somewhere else is a number the next reader has no way to weigh.
158
+ *
159
+ * WHAT THE BUMP BUYS: `anno_enum_usage`, the table that associates ONE address
160
+ * with ONE `anno_enum` row, which is what `anno_apply_enum_usage`'s route
161
+ * needs and what version 2 had nowhere to put. The association is by enum
162
+ * **id**, never by enum name, so `updateProjectEnum`'s rename can neither
163
+ * orphan a usage nor silently re-point it at a different enum.
164
+ *
165
+ * NO MIGRATION ARM WAS WRITTEN, AND THAT IS THE ACCEPTED COST: every
166
+ * version 2 store on disk is permanently unopenable. The refusal below stays a
167
+ * SINGLE-WITNESS refusal -- one comparison site in `openStore`, no upgrade
168
+ * path, no silent re-write of `anno_meta`. The basis measured on the day of the
169
+ * decision: no store file is tracked in this repository and none exists in its
170
+ * working tree, the store's own module landed 2026-08-27, the version 2 shape
171
+ * landed 2026-08-28, and the last release tag (`v0.5.0`, 2026-08-25) PREDATES
172
+ * the store entirely -- so no tagged release has ever shipped a store at all.
173
+ * Any store that would be stranded is in a user's own project and outside this
174
+ * repository's reach by construction; that is an assumption, flagged rather
175
+ * than asserted.
176
+ *
177
+ * THE VERSION 1 PARAGRAPH ABOVE IS THE PRECEDENT THIS IS MEASURED AGAINST, and
178
+ * the two refusals are NOT the same kind. Version 1 could not be migrated even
179
+ * in principle -- the ring's ownership was unrecoverable, so a migration would
180
+ * have had to guess. Version 3 COULD have been given a migration arm and
181
+ * deliberately was not, because one bought now protects stores that may not
182
+ * exist, and it would put new code into a module six hardening rounds went
183
+ * into. Stating the difference is the point: this one is a choice, not an
184
+ * impossibility.
185
+ */
186
+ export const SCHEMA_VERSION = 3;
187
+
188
+ /** The 6510's address space, inclusive at both ends. */
189
+ export const ADDRESS_MIN = 0x0000;
190
+ export const ADDRESS_MAX = 0xffff;
191
+
192
+ /** How many pre-mutation snapshots the store's own snapshot ring directory
193
+ * (`anno-store.ts`'s `snapshotDirFor()` -- a sibling named after the store
194
+ * FILE, not the fixed `<dir>/snapshots` version 1 used) may hold before the
195
+ * oldest is pruned. Declared here because the bound is a property of the
196
+ * store's format; the pruning that enforces it belongs to the revert surface
197
+ * (`STORE-04`). */
198
+ export const MAX_SNAPSHOT_REVISIONS = 32;
199
+
200
+ /**
201
+ * The twelve annotation data types, in the `anno_set_data_type` schema's own
202
+ * order and spelling (`anno-tools.ts:291-304`). This is the ONE place the
203
+ * vocabulary is written down -- see trap 1 in the module header.
204
+ *
205
+ * Both distinguishing axes are separately observable, which is what justifies
206
+ * four split members rather than two. ORIENTATION: the bytes
207
+ * `10 34 00 ff 08 12 c0 cf` resolve as `$0810 $1234 $c000 $cfff` under
208
+ * `lo_hi_address` and as `$1008 $3412 $00c0 $ffcf` under `hi_lo_address` -- a
209
+ * different resolved-target set. ADDRESS-VERSUS-WORD: the address forms
210
+ * produce cross-references and the word forms do not
211
+ * (`anno-tools.ts:305-313`).
212
+ */
213
+ export const DATA_TYPES = Object.freeze([
214
+ "code",
215
+ "byte",
216
+ "word",
217
+ "address",
218
+ "petscii",
219
+ "screencode",
220
+ "lo_hi_address",
221
+ "hi_lo_address",
222
+ "lo_hi_word",
223
+ "hi_lo_word",
224
+ "external_file",
225
+ "undefined",
226
+ ] as const);
227
+
228
+ /** One member of the frozen twelve. */
229
+ export type DataType = (typeof DATA_TYPES)[number];
230
+
231
+ /** The two split-table byte-order prefixes the schema's own spellings use.
232
+ * `SPLIT_DATA_TYPES` is derived through these -- never re-typed. */
233
+ const SPLIT_PREFIXES = Object.freeze(["lo_hi_", "hi_lo_"] as const);
234
+
235
+ /** A split-table layout: one of the four members whose spelling begins with a
236
+ * byte-order prefix. Derived from `DataType` by template-literal `Extract`, so
237
+ * this type cannot name a string the vocabulary does not contain. */
238
+ export type SplitDataType = Extract<DataType, `lo_hi_${string}` | `hi_lo_${string}`>;
239
+
240
+ /** True iff `value` is one of the four split-table layouts. The one predicate
241
+ * both `SPLIT_DATA_TYPES` and `assertRangeShape()`'s even-count rule use, so
242
+ * "counts as a split table" has exactly one definition. */
243
+ export function isSplitDataType(value: DataType): value is SplitDataType {
244
+ return SPLIT_PREFIXES.some((prefix) => value.startsWith(prefix));
245
+ }
246
+
247
+ /** The four split-table layouts, DERIVED from `DATA_TYPES` (trap 2). */
248
+ export const SPLIT_DATA_TYPES: readonly SplitDataType[] = Object.freeze(DATA_TYPES.filter(isSplitDataType));
249
+
250
+ /**
251
+ * One typed range as the store holds it. `endInclusive` is INCLUSIVE, matching
252
+ * the schema's own `end_address` sentence (`anno-tools.ts:288`), so a range's
253
+ * length is `endInclusive - start + 1` and a one-byte range has
254
+ * `start === endInclusive`. `bank` is reserved and interpreted by nothing:
255
+ * every row this store writes today has `bank` null.
256
+ */
257
+ export interface RangeRow {
258
+ id: number;
259
+ start: number;
260
+ endInclusive: number;
261
+ dataType: DataType;
262
+ bank: number | null;
263
+ }
264
+
265
+ /**
266
+ * The two comment placements, in the `anno_set_comment` schema's own order and
267
+ * spelling (`anno-tools.ts:270-274`). This is the ONE place this vocabulary is
268
+ * written down: `'line'` is a comment on its own line before the instruction,
269
+ * `'side'` is inline on the same line as the instruction.
270
+ */
271
+ export const COMMENT_TYPES = Object.freeze(["line", "side"] as const);
272
+
273
+ /** One of the two comment placements. */
274
+ export type CommentType = (typeof COMMENT_TYPES)[number];
275
+
276
+ /**
277
+ * The four label kinds. This is the ONE place this vocabulary is written down.
278
+ *
279
+ * THE CAPITALISATION IS A DECIDED ASYMMETRY, not an oversight. `DATA_TYPES` is
280
+ * lowercase because it is read off `anno_set_data_type`'s own schema and is
281
+ * named verbatim in `src/skills/c64-memory-mapping/SKILL.md`, so a re-spelling
282
+ * would break a shipped playbook. `LABEL_KINDS` is capitalised because its only
283
+ * mechanical consumer is the coverage census, which already spells it
284
+ * `"User"`/`"Auto"`/`"System"` at four sites (`anno-coverage.ts:206` for the
285
+ * doc form, `:1425-1435` for the acceptance, where `"Platform"` is taken as a
286
+ * synonym of `"System"`). Matching the consumer costs nothing; changing the
287
+ * consumer costs four edits inside a 2,292-line module and buys no criterion.
288
+ *
289
+ * `"Platform"` is a first-class member here rather than normalised away,
290
+ * because the census accepts both spellings and the store must be able to
291
+ * record which one a caller supplied.
292
+ */
293
+ export const LABEL_KINDS = Object.freeze(["User", "Auto", "System", "Platform"] as const);
294
+
295
+ /** One of the four label kinds. `"User"` and `"Auto"` are the two namespaces
296
+ * the store keeps apart -- with this field, never with a name pattern (trap 8). */
297
+ export type LabelKind = (typeof LABEL_KINDS)[number];
298
+
299
+ /**
300
+ * The four cross-reference access kinds. This is the ONE place this vocabulary
301
+ * is written down, and its provenance needs stating precisely so a later reader
302
+ * does not over-trust it: these four spellings are CITED from an external
303
+ * analyser's reference documentation, flowed through this project's own research
304
+ * notes, and fixed by `STORE-05`'s requirement text. They are NOT read from any
305
+ * code in this repository, and no comment may present them as verified project
306
+ * vocabulary.
307
+ *
308
+ * `COMPUTED_JUMP` is the member that motivates the whole table: a computed
309
+ * dispatch produces NO reference derivable from the bytes, so it is precisely
310
+ * the case that needs a hand-asserted row somewhere. See `anno-store.ts`'s
311
+ * `putXref`.
312
+ */
313
+ export const XREF_ACCESS_KINDS = Object.freeze(["READ", "WRITE", "READ_WRITE", "COMPUTED_JUMP"] as const);
314
+
315
+ /** One of the four cross-reference access kinds. */
316
+ export type XrefAccessKind = (typeof XREF_ACCESS_KINDS)[number];
317
+
318
+ /**
319
+ * The upper bound on one comment's text, in UTF-8 BYTES.
320
+ *
321
+ * Why a bound exists at all: the 64K address space caps a range naturally --
322
+ * there is no way to ask for a range longer than the machine -- but comment text
323
+ * caps nothing. Between an unvalidated caller argument (the transport validates
324
+ * nothing) and unbounded blob growth in the store file, this number is the only
325
+ * thing standing. 4096 bytes is roughly a screenful of prose per address, which
326
+ * is more than any annotation in the corpus this store was designed against.
327
+ */
328
+ export const MAX_COMMENT_BYTES = 4096;
329
+
330
+ /**
331
+ * Every 6502/6510 mnemonic, lowercase, DERIVED from the real 256-entry opcode
332
+ * table -- never hand-typed.
333
+ *
334
+ * Why derived matters here specifically: a hand-typed list is the documented
335
+ * 56-name legal set, and it MISSES every illegal-opcode mnemonic the decoder
336
+ * actually emits -- `jam`, `slo` and `lax` among them. A label named `slo`
337
+ * would then be accepted here and rejected by the assembler at export time,
338
+ * which is a failure a long way from its cause.
339
+ *
340
+ * A derivation function rather than an inline container, for two reasons. The
341
+ * module's own purity rule (trap 3) forbids a module-level mutable container,
342
+ * and `Object.freeze` on a `Set` is a NO-OP for its contents -- there is no
343
+ * "frozen Set" to declare. So the `ReadonlySet` type plus a single derivation
344
+ * site IS the contract: nothing in this module or the store writes to it.
345
+ */
346
+ function deriveMnemonicDenylist(): ReadonlySet<string> {
347
+ return new Set(OPCODES.map((entry) => entry.mnemonic.toLowerCase()));
348
+ }
349
+
350
+ export const MNEMONIC_DENYLIST: ReadonlySet<string> = deriveMnemonicDenylist();
351
+
352
+ /** One label as the store holds it. `bank` is reserved and interpreted by
353
+ * nothing; every row this store writes today has it null. */
354
+ export interface LabelRow {
355
+ id: number;
356
+ address: number;
357
+ name: string;
358
+ kind: LabelKind;
359
+ bank: number | null;
360
+ }
361
+
362
+ /** One comment as the store holds it. `text` never carries the `';'` prefix --
363
+ * the schema instructs callers to omit it and `assertCommentText()` refuses it. */
364
+ export interface CommentRow {
365
+ id: number;
366
+ address: number;
367
+ commentType: CommentType;
368
+ text: string;
369
+ bank: number | null;
370
+ }
371
+
372
+ /**
373
+ * One stored comment that a retype has just made FALSE (STORE-03).
374
+ *
375
+ * The caller needs all four facts to act on the report without a second query:
376
+ * WHERE the comment is, WHAT it says, WHICH grade fired, and WHICH data type
377
+ * contradicted it. A bare address list would send every recipient straight back
378
+ * to `listComments()`.
379
+ *
380
+ * `grade` is the bracket token verbatim, as `anno-confidence.ts` spells it --
381
+ * this store never writes a second spelling of one.
382
+ */
383
+ export interface ContradictedComment {
384
+ address: number;
385
+ commentType: CommentType;
386
+ text: string;
387
+ grade: string;
388
+ contradictedBy: DataType;
389
+ }
390
+
391
+ /**
392
+ * One entry-address pairing of a split table (STORE-03, CR-10).
393
+ *
394
+ * `pairs[i]` is the two ADDRESSES whose bytes form entry `i`, in table order:
395
+ * the first-half address and its second-half partner. `entryCount` is
396
+ * `pairs.length`, restated as a field so a caller can assert the count without
397
+ * reading the array -- the same shape convention `SplitTargets` uses.
398
+ */
399
+ export interface SplitEntryPairs {
400
+ entryCount: number;
401
+ pairs: readonly (readonly [number, number])[];
402
+ }
403
+
404
+ /**
405
+ * One surviving fragment of a split table that a partial overwrite left behind
406
+ * (CR-10). `entryPairs` is what that fragment reads NOW -- not what the addresses
407
+ * in it used to be paired with.
408
+ */
409
+ export interface SplitTableSurvivor {
410
+ start: number;
411
+ endInclusive: number;
412
+ entryCount: number;
413
+ entryPairs: readonly (readonly [number, number])[];
414
+ }
415
+
416
+ /**
417
+ * What one accepted partial overwrite of a split table COST, reported as data on
418
+ * a successful `setDataType()` result (STORE-03, CR-10). Documented in the same
419
+ * register as `ContradictedComment` above, and for the same reason: a caller
420
+ * needs every fact it would otherwise have to re-query for.
421
+ *
422
+ * WHY THIS RECORD EXISTS AT ALL. A split table's layout is
423
+ * first-half/second-half, so an entry's partner is a function of the row's START
424
+ * and its LENGTH (see `resolveSplitTargets()` below). Changing either end
425
+ * re-pairs EVERY entry: a surviving fragment of `m` entries pairs its own byte
426
+ * `j` with its own byte `m + j`, which matches an original pair only when
427
+ * `m == n` -- only when the fragment IS the whole row. **No proper fragment of a
428
+ * split table preserves a single entry pair, at any boundary, the midpoint
429
+ * included.** The surviving rows are still legal and still decode; they decode to
430
+ * DIFFERENT 16-bit values than the ones a human recorded. Preservation is
431
+ * therefore not recoverable by a cleverer boundary rule, and the only two honest
432
+ * answers are to refuse the edit or to disclose its cost. This record is the
433
+ * disclosure.
434
+ *
435
+ * BOTH OF THE NUMBERS THAT CONFLICTED are carried (28-08 P2): `entryPairsBefore`
436
+ * is what the table read before the write, each survivor's `entryPairs` is what
437
+ * that fragment reads after, and `preservedEntryPairs` is their intersection --
438
+ * COMPUTED by comparing the two sets, never assumed. It is empty today as a
439
+ * consequence of the layout; a computed field stays correct if a future layout
440
+ * changes that.
441
+ */
442
+ export interface SplitTableReinterpretation {
443
+ rowId: number;
444
+ rowStart: number;
445
+ rowEndInclusive: number;
446
+ dataType: SplitDataType;
447
+ entryCountBefore: number;
448
+ entryPairsBefore: readonly (readonly [number, number])[];
449
+ survivors: readonly SplitTableSurvivor[];
450
+ preservedEntryPairs: readonly (readonly [number, number])[];
451
+ summary: string;
452
+ }
453
+
454
+ /** One scope as the store holds it. Both ends are INCLUSIVE, matching the
455
+ * schema's own two sentences (`anno-tools.ts:322-331`). There is no name field
456
+ * and no nesting: the schema says nested scopes are unsupported, and the store
457
+ * must not invent a capability the surface it mirrors does not have. That last
458
+ * claim is ENFORCED rather than merely asserted -- `addScope()` in
459
+ * `anno-store.ts` refuses a nested or overlapping range with an
460
+ * `AnnoRangeShapeError` naming both scopes; see its doc comment for the rule. */
461
+ export interface ScopeRow {
462
+ id: number;
463
+ start: number;
464
+ endInclusive: number;
465
+ }
466
+
467
+ /** One project-local enum as the store holds it. `variants` is keyed by the
468
+ * numeric-string forms the schema names -- decimal, `0x`/`$` hex, `0b`/`%`
469
+ * binary -- and its values are variant names. */
470
+ export interface ProjectEnumRow {
471
+ id: number;
472
+ name: string;
473
+ variants: Readonly<Record<string, string>>;
474
+ description: string | null;
475
+ }
476
+
477
+ /**
478
+ * One enum usage as the store holds it: the association between ONE address
479
+ * and ONE project enum, added at `SCHEMA_VERSION` 3 (D-15).
480
+ *
481
+ * `enumId` IS WHAT THE STORE PERSISTS; `enumName` is resolved through the join
482
+ * at read time and is never a second on-disk copy of the name. A row that
483
+ * persisted the NAME would be re-pointed silently by `updateProjectEnum`'s
484
+ * rename -- the usage would follow whatever enum next took the old name -- and
485
+ * the disagreement would be invisible because both answers look authoritative.
486
+ * `bank` is the same reserved, uninterpreted column every other row type
487
+ * carries.
488
+ */
489
+ export interface EnumUsageRow {
490
+ id: number;
491
+ address: number;
492
+ enumId: number;
493
+ enumName: string;
494
+ bank: number | null;
495
+ }
496
+
497
+ /** One cross-reference as the store holds it. Only NON-DERIVABLE references
498
+ * live here -- see `anno-store.ts`'s `putXref` for why, and for the two
499
+ * requirement texts that look like they conflict and do not. */
500
+ export interface XrefRow {
501
+ id: number;
502
+ fromAddress: number;
503
+ toAddress: number;
504
+ accessKind: XrefAccessKind;
505
+ bank: number | null;
506
+ }
507
+
508
+ /** What `resolveSplitTargets()` returns: the entry count, the resolved 16-bit
509
+ * targets in table order, and whether this layout produces cross-references at
510
+ * all. Nothing here is ever written to disk. */
511
+ export interface SplitTargets {
512
+ entryCount: number;
513
+ targets: readonly number[];
514
+ producesXrefs: boolean;
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // The named error family. Follows `vice.ts`'s own constructor pattern: an
519
+ // `interface XErrorOptions`, plain public fields, `super(message, options)` for
520
+ // the field-free base and `super(message)` for the field-carrying subclasses
521
+ // (`vice.ts:245-292`, with `MachineRestartedError` as the field-carrying
522
+ // shape). Every subclass is an `AnnoStoreError` and therefore a `ViceError`, so
523
+ // one `catch` can take the whole family or any single member of it.
524
+ // ---------------------------------------------------------------------------
525
+
526
+ export interface AnnoStoreErrorOptions extends ViceErrorOptions {}
527
+
528
+ /** The family base: every refusal below is an instance of this. */
529
+ export class AnnoStoreError extends ViceError {
530
+ constructor(message: string, options: AnnoStoreErrorOptions = {}) {
531
+ super(message, options);
532
+ this.name = "AnnoStoreError";
533
+ }
534
+ }
535
+
536
+ export interface AnnoStoreCorruptErrorOptions {
537
+ path?: string;
538
+ }
539
+
540
+ /** The store file is not a store: no meta row, an unreadable meta row, a
541
+ * schema version this build does not speak, or a failed integrity check. Never
542
+ * thrown for a store that is merely EMPTY -- that distinction is the whole
543
+ * reason this error exists. */
544
+ export class AnnoStoreCorruptError extends AnnoStoreError {
545
+ path?: string;
546
+
547
+ constructor(message: string, { path }: AnnoStoreCorruptErrorOptions = {}) {
548
+ super(message);
549
+ this.name = "AnnoStoreCorruptError";
550
+ this.path = path;
551
+ }
552
+ }
553
+
554
+ export interface AnnoStoreStaleRevisionErrorOptions {
555
+ baseRevision?: number;
556
+ currentRevision?: number;
557
+ }
558
+
559
+ /** The write was refused because the on-disk revision is not the revision the
560
+ * caller based its edit on. Carries both numbers so the caller can say which
561
+ * two disagreed rather than "conflict". */
562
+ export class AnnoStoreStaleRevisionError extends AnnoStoreError {
563
+ baseRevision?: number;
564
+ currentRevision?: number;
565
+
566
+ constructor(message: string, { baseRevision, currentRevision }: AnnoStoreStaleRevisionErrorOptions = {}) {
567
+ super(message);
568
+ this.name = "AnnoStoreStaleRevisionError";
569
+ this.baseRevision = baseRevision;
570
+ this.currentRevision = currentRevision;
571
+ }
572
+ }
573
+
574
+ export interface AnnoRevisionArgumentErrorOptions {
575
+ /** The offending value, EXACTLY as it was supplied -- unconverted, so a
576
+ * caller can see that what it passed was a string. */
577
+ value?: unknown;
578
+ /** The parameter it was supplied for, so one class can serve more than one
579
+ * revision-shaped argument without the message having to say which. */
580
+ parameter?: string;
581
+ }
582
+
583
+ /**
584
+ * A REVISION-SHAPED ARGUMENT THAT IS NOT A REVISION: a numeric string, a
585
+ * negative number, a fraction, `NaN`. Thrown before any SQL runs and before any
586
+ * path is built, so nothing has been read and nothing has been written.
587
+ *
588
+ * WHY THIS IS NOT `AnnoStoreCorruptError`, WHICH IS THE WHOLE REASON THE CLASS
589
+ * EXISTS (WR-22). Until this class existed, `revertTo(handle, "0001")` matched
590
+ * revision 1's pointer row through SQLite's INTEGER affinity on a bound TEXT
591
+ * operand, while `snapshotPathFor` built `r0001.db` from the raw string -- so
592
+ * the two disagreed and the caller was told its snapshot was "not a readable
593
+ * annotation store". That is a CORRUPTION refusal produced by an ARGUMENT
594
+ * error, and `AnnoStoreCorruptError`'s own doc comment forbids exactly that
595
+ * confusion in as many words: "the annotations are gone" and "there are no
596
+ * annotations" must not read the same. Neither must "you passed the wrong
597
+ * thing".
598
+ *
599
+ * WHY IT IS NOT `AnnoStoreStaleRevisionError` EITHER. That class carries the
600
+ * TWO revisions that conflicted (28-08 P2's shape: never report a conflict
601
+ * without both of the numbers). An argument error has no second revision --
602
+ * nothing moved and nothing disagreed -- so reusing it would force the class to
603
+ * carry a number the caller never supplied and this code never had.
604
+ *
605
+ * A caller therefore tells the three apart BY CLASS, never by substring-matching
606
+ * a message.
607
+ */
608
+ export class AnnoRevisionArgumentError extends AnnoStoreError {
609
+ value?: unknown;
610
+ parameter?: string;
611
+
612
+ constructor(message: string, { value, parameter }: AnnoRevisionArgumentErrorOptions = {}) {
613
+ super(message);
614
+ this.name = "AnnoRevisionArgumentError";
615
+ this.value = value;
616
+ this.parameter = parameter;
617
+ }
618
+ }
619
+
620
+ export interface AnnoTypeErrorOptions {
621
+ dataType?: unknown;
622
+ validTypes?: readonly string[];
623
+ }
624
+
625
+ /** The `dataType` argument is not one of the frozen twelve. Carries the
626
+ * offending value AND the full valid list, so the caller never has to go
627
+ * looking for the vocabulary. */
628
+ export class AnnoTypeError extends AnnoStoreError {
629
+ dataType?: unknown;
630
+ validTypes?: readonly string[];
631
+
632
+ constructor(message: string, { dataType, validTypes }: AnnoTypeErrorOptions = {}) {
633
+ super(message);
634
+ this.name = "AnnoTypeError";
635
+ this.dataType = dataType;
636
+ this.validTypes = validTypes;
637
+ }
638
+ }
639
+
640
+ export interface AnnoRangeShapeErrorOptions {
641
+ start?: unknown;
642
+ endInclusive?: unknown;
643
+ }
644
+
645
+ /** The range's shape is impossible: an end outside the address space, an end
646
+ * below its start, or an odd byte count on a split-table layout. */
647
+ export class AnnoRangeShapeError extends AnnoStoreError {
648
+ start?: unknown;
649
+ endInclusive?: unknown;
650
+
651
+ constructor(message: string, { start, endInclusive }: AnnoRangeShapeErrorOptions = {}) {
652
+ super(message);
653
+ this.name = "AnnoRangeShapeError";
654
+ this.start = start;
655
+ this.endInclusive = endInclusive;
656
+ }
657
+ }
658
+
659
+ export interface AnnoSplitRemainderErrorOptions extends AnnoRangeShapeErrorOptions {
660
+ /** The overlapped row the split would fragment. */
661
+ rowId?: number;
662
+ rowStart?: number;
663
+ rowEndInclusive?: number;
664
+ /** The overlapped row's own type -- the type the remainder would inherit. */
665
+ dataType?: DataType;
666
+ /** The remainder the store refused to write. */
667
+ remainderStart?: number;
668
+ remainderEndInclusive?: number;
669
+ /** Which end of the overlapped row the illegal remainder is. */
670
+ side?: "head" | "tail";
671
+ }
672
+
673
+ /**
674
+ * A split-and-preserve remainder is not a legal shape for the type it would
675
+ * carry, so the whole retype is refused. Carries the overlapped row's identity
676
+ * AND the illegal remainder's, because a conflict reported with only one of the
677
+ * two numbers that conflicted is not a report.
678
+ *
679
+ * WHY IT EXTENDS `AnnoRangeShapeError` RATHER THAN `AnnoStoreError` DIRECTLY,
680
+ * and this is deliberate, not incidental: it really IS a shape refusal -- the
681
+ * SAME rule `assertRangeShape()` applies, asked about a range the store is about
682
+ * to write on its own initiative rather than one the caller supplied. Sitting it
683
+ * in the shape family means every existing `instanceof AnnoRangeShapeError`
684
+ * caller keeps working when the store starts refusing on this new path, and a
685
+ * caller that wants to distinguish the two asks for this class by name.
686
+ */
687
+ export class AnnoSplitRemainderError extends AnnoRangeShapeError {
688
+ rowId?: number;
689
+ rowStart?: number;
690
+ rowEndInclusive?: number;
691
+ dataType?: DataType;
692
+ remainderStart?: number;
693
+ remainderEndInclusive?: number;
694
+ side?: "head" | "tail";
695
+
696
+ constructor(message: string, options: AnnoSplitRemainderErrorOptions = {}) {
697
+ super(message, { start: options.start, endInclusive: options.endInclusive });
698
+ this.name = "AnnoSplitRemainderError";
699
+ this.rowId = options.rowId;
700
+ this.rowStart = options.rowStart;
701
+ this.rowEndInclusive = options.rowEndInclusive;
702
+ this.dataType = options.dataType;
703
+ this.remainderStart = options.remainderStart;
704
+ this.remainderEndInclusive = options.remainderEndInclusive;
705
+ this.side = options.side;
706
+ }
707
+ }
708
+
709
+ export interface AnnoAddressErrorOptions {
710
+ input?: unknown;
711
+ what?: string;
712
+ }
713
+
714
+ /** The value is not an address this store will accept. Carries the offending
715
+ * input and the name of the field it was supplied for. */
716
+ export class AnnoAddressError extends AnnoStoreError {
717
+ input?: unknown;
718
+ what?: string;
719
+
720
+ constructor(message: string, { input, what }: AnnoAddressErrorOptions = {}) {
721
+ super(message);
722
+ this.name = "AnnoAddressError";
723
+ this.input = input;
724
+ this.what = what;
725
+ }
726
+ }
727
+
728
+ export interface AnnoStorePathErrorOptions {
729
+ path?: string;
730
+ workspaceRoot?: string;
731
+ }
732
+
733
+ /** The store path resolves outside the workspace root it was confined to. */
734
+ export class AnnoStorePathError extends AnnoStoreError {
735
+ path?: string;
736
+ workspaceRoot?: string;
737
+
738
+ constructor(message: string, { path, workspaceRoot }: AnnoStorePathErrorOptions = {}) {
739
+ super(message);
740
+ this.name = "AnnoStorePathError";
741
+ this.path = path;
742
+ this.workspaceRoot = workspaceRoot;
743
+ }
744
+ }
745
+
746
+ export interface AnnoLabelErrorOptions {
747
+ /** The offending identifier, verbatim -- never a sanitised form of it.
748
+ *
749
+ * THE FIELD IS `identifier`, NOT `name`, and that is load-bearing rather than
750
+ * a naming preference: `name` is `Error.prototype.name`, which every
751
+ * constructor in this family assigns the class name to. A public `name` field
752
+ * would overwrite `"AnnoLabelError"` with the offending label, so a `catch`
753
+ * block asking which error it caught would be told the answer to a different
754
+ * question. */
755
+ identifier?: string;
756
+ /** Which rule fired, in words -- the illegal-character rule, the mnemonic
757
+ * denylist, or the collision. */
758
+ reason?: string;
759
+ /** For a collision: the address the name is already bound to. */
760
+ existingAddress?: number;
761
+ /** For a collision: the address the caller asked to bind it to. */
762
+ requestedAddress?: number;
763
+ }
764
+
765
+ /**
766
+ * An identifier -- a label name or a project-enum name -- is refused. Never
767
+ * sanitised, never substituted, never rebound: see trap 7 for the concrete
768
+ * silent-merge hazard that makes refusal the only safe answer.
769
+ */
770
+ export class AnnoLabelError extends AnnoStoreError {
771
+ identifier?: string;
772
+ reason?: string;
773
+ existingAddress?: number;
774
+ requestedAddress?: number;
775
+
776
+ constructor(message: string, { identifier, reason, existingAddress, requestedAddress }: AnnoLabelErrorOptions = {}) {
777
+ super(message);
778
+ this.name = "AnnoLabelError";
779
+ this.identifier = identifier;
780
+ this.reason = reason;
781
+ this.existingAddress = existingAddress;
782
+ this.requestedAddress = requestedAddress;
783
+ }
784
+ }
785
+
786
+ export interface AnnoCommentErrorOptions {
787
+ /** Which rule fired: not a string, an embedded newline, the semicolon
788
+ * prefix, or the byte bound. */
789
+ reason?: string;
790
+ /** The text's UTF-8 byte length, so a caller can see how far over it was
791
+ * rather than only that it was over. */
792
+ byteLength?: number;
793
+ }
794
+
795
+ /**
796
+ * Comment (or description) text is refused: not a string, carrying an embedded
797
+ * line break, over `MAX_COMMENT_BYTES` in UTF-8 bytes, or carrying the `';'`
798
+ * prefix the schema instructs callers to omit.
799
+ * A separate class from `AnnoLabelError` because neither of its fields fits --
800
+ * comment text is not an identifier and has no address to collide at.
801
+ */
802
+ export class AnnoCommentError extends AnnoStoreError {
803
+ reason?: string;
804
+ byteLength?: number;
805
+
806
+ constructor(message: string, { reason, byteLength }: AnnoCommentErrorOptions = {}) {
807
+ super(message);
808
+ this.name = "AnnoCommentError";
809
+ this.reason = reason;
810
+ this.byteLength = byteLength;
811
+ }
812
+ }
813
+
814
+ export interface AnnoCommentGradeErrorOptions {
815
+ /** The offending comment text, verbatim. */
816
+ comment?: string;
817
+ /** The original refusal this one wraps, kept so the diagnostic chain is not
818
+ * broken by the wrap. */
819
+ cause?: unknown;
820
+ }
821
+
822
+ /**
823
+ * A stored comment carries a leading bracket token that is not one of the five
824
+ * confidence grades, so the store cannot say whether a retype contradicts it.
825
+ *
826
+ * THIS CLASS EXISTS TO WRAP, AND THE WRAP IS THE DECISION. The parser that
827
+ * detects the malformed token throws a class extending `Error` DIRECTLY, not
828
+ * `ViceError` -- so a caller writing a single
829
+ * `catch (e) { if (e instanceof ViceError) ... }` at the store boundary would
830
+ * miss it, and a real refusal would escape as an unhandled rejection. The store
831
+ * catches it and rethrows this instead, so everything the store throws is a
832
+ * `ViceError`.
833
+ *
834
+ * THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED: the original class is no
835
+ * longer visible to `instanceof` at the store boundary. That is why the original
836
+ * message is preserved VERBATIM inside this one and the original error rides on
837
+ * `cause` -- nothing is lost from the diagnostic, only from the type. The
838
+ * alternative -- rethrow unchanged and document the asymmetry -- was rejected
839
+ * because it puts the burden on every future caller instead of on this one site.
840
+ *
841
+ * It is NEVER correct to swallow the original and treat the comment as ungraded:
842
+ * that would quietly exempt a malformed comment from contradiction reporting,
843
+ * which is the same silent un-documenting `STORE-03` exists to prevent.
844
+ */
845
+ export class AnnoCommentGradeError extends AnnoStoreError {
846
+ comment?: string;
847
+ cause?: unknown;
848
+
849
+ constructor(message: string, { comment, cause }: AnnoCommentGradeErrorOptions = {}) {
850
+ super(message);
851
+ this.name = "AnnoCommentGradeError";
852
+ this.comment = comment;
853
+ this.cause = cause;
854
+ }
855
+ }
856
+
857
+ // ---------------------------------------------------------------------------
858
+ // Validators. Each one THROWS on refusal (trap 6) and returns the narrowed
859
+ // value on acceptance.
860
+ // ---------------------------------------------------------------------------
861
+
862
+ /** Narrows an unvalidated argument to a `DataType`, or throws `AnnoTypeError`
863
+ * carrying the offending value and the full valid list. */
864
+ export function assertDataType(value: unknown): DataType {
865
+ if (typeof value === "string" && (DATA_TYPES as readonly string[]).includes(value)) {
866
+ return value as DataType;
867
+ }
868
+ throw new AnnoTypeError(
869
+ `data type ${JSON.stringify(value)} is not one of the ${DATA_TYPES.length} annotation data types -- expected one of: ${DATA_TYPES.join(", ")}`,
870
+ { dataType: value, validTypes: [...DATA_TYPES] },
871
+ );
872
+ }
873
+
874
+ /**
875
+ * Refuses an impossible range shape. Three separate refusals, each with its
876
+ * own message: an end outside `ADDRESS_MIN..ADDRESS_MAX`, an `endInclusive`
877
+ * below `start`, and an ODD byte count on a split-table layout -- the schema's
878
+ * own "even count required" rule (`anno-tools.ts:305-313`), which is a
879
+ * validation rule about the DATA rather than a property of the type, which is
880
+ * why it is checked here and not encoded in `DataType`.
881
+ */
882
+ export function assertRangeShape(start: number, endInclusive: number, dataType: DataType): void {
883
+ const ends: readonly (readonly [string, number])[] = [
884
+ ["start", start],
885
+ ["endInclusive", endInclusive],
886
+ ];
887
+ for (const [what, value] of ends) {
888
+ if (!Number.isInteger(value) || value < ADDRESS_MIN || value > ADDRESS_MAX) {
889
+ throw new AnnoRangeShapeError(
890
+ `${what} ${String(value)} is outside the address space -- expected an integer ${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff)`,
891
+ { start, endInclusive },
892
+ );
893
+ }
894
+ }
895
+ if (endInclusive < start) {
896
+ throw new AnnoRangeShapeError(
897
+ `endInclusive ${endInclusive} is below start ${start} -- both ends are INCLUSIVE, so a one-byte range has start === endInclusive`,
898
+ { start, endInclusive },
899
+ );
900
+ }
901
+ if (isSplitDataType(dataType) && (endInclusive - start + 1) % 2 !== 0) {
902
+ throw new AnnoRangeShapeError(
903
+ `a ${dataType} table needs an even byte count, but ${start}..${endInclusive} is ${endInclusive - start + 1} byte(s) -- the low half and the high half must be the same length`,
904
+ { start, endInclusive },
905
+ );
906
+ }
907
+ }
908
+
909
+ /**
910
+ * Parses `input` into a `ADDRESS_MIN..ADDRESS_MAX` address. Accepted forms: a
911
+ * JS integer in range, a `"$hex"` string, and a `"0x"`/`"0X"` string.
912
+ * Surrounding whitespace is trimmed.
913
+ *
914
+ * An UNPREFIXED numeric string such as `"1024"` is REFUSED -- see trap 4 in
915
+ * the module header for the divergence from `stock-address.ts` and the reason
916
+ * for it.
917
+ */
918
+ export function parseStoreAddress(input: unknown, opts: { what?: string } = {}): number {
919
+ const what = opts.what ?? "address";
920
+
921
+ if (typeof input === "number") {
922
+ if (!Number.isInteger(input) || input < ADDRESS_MIN || input > ADDRESS_MAX) {
923
+ throw new AnnoAddressError(
924
+ `${what}: ${String(input)} is out of range -- expected an integer ${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff)`,
925
+ { input, what },
926
+ );
927
+ }
928
+ return input;
929
+ }
930
+
931
+ if (typeof input === "string") {
932
+ const trimmed = input.trim();
933
+ const hexPart = trimmed.startsWith("$") ? trimmed.slice(1) : /^0[xX]/.test(trimmed) ? trimmed.slice(2) : null;
934
+ if (hexPart !== null) {
935
+ if (hexPart === "" || !/^[0-9a-fA-F]+$/.test(hexPart)) {
936
+ throw new AnnoAddressError(
937
+ `${what}: "${trimmed}" is not a valid hex address -- expected "$" or "0x" followed by hex digits, e.g. "$0810" or "0x0810"`,
938
+ { input, what },
939
+ );
940
+ }
941
+ const value = parseInt(hexPart, 16);
942
+ if (value < ADDRESS_MIN || value > ADDRESS_MAX) {
943
+ throw new AnnoAddressError(
944
+ `${what}: "${trimmed}" (${value}) is out of range -- expected ${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff)`,
945
+ { input, what },
946
+ );
947
+ }
948
+ return value;
949
+ }
950
+ }
951
+
952
+ throw new AnnoAddressError(
953
+ `${what}: ${JSON.stringify(input)} is not an address this store accepts -- expected an integer, a "$hex" string or a "0x" string. ` +
954
+ `An unprefixed numeric string is refused on purpose: a mis-based address written into the store is persistent and silently wrong, ` +
955
+ `so the base is required rather than assumed.`,
956
+ { input, what },
957
+ );
958
+ }
959
+
960
+ /**
961
+ * The maximum number of DANGLING-symlink hops `realpathOfNearestExisting` will
962
+ * take before refusing. 40 is not an arbitrary comfort number: it is Linux's own
963
+ * `MAXSYMLINKS`, so a chain this walk refuses is a chain the kernel would refuse
964
+ * too, and the two disagree about no input.
965
+ *
966
+ * The bound exists because a CYCLE (`a -> b`, `b -> a`) is otherwise an infinite
967
+ * loop inside a function whose input arrives UNVALIDATED from the transport (see
968
+ * this module's header). `realpathSync` gets `ELOOP` from the kernel for free;
969
+ * the manual hop below is ours, so the bound has to be ours too.
970
+ */
971
+ const MAX_SYMLINK_HOPS = 40;
972
+
973
+ /**
974
+ * Does the path ENTRY `p` exist -- that is, does this NAME exist in its
975
+ * directory?
976
+ *
977
+ * THIS IS THE WHOLE OF `CR-04`, in two sentences. `existsSync` answers a
978
+ * different question: "does this path RESOLVE to something?", which follows
979
+ * symbolic links and therefore reports `false` for a dangling one. `lstat`
980
+ * answers "does this NAME exist?", which does not follow the link. The two
981
+ * answers differ for exactly one input class -- a symlink whose target is absent
982
+ * -- and confinement has always needed the second question while asking the
983
+ * first.
984
+ *
985
+ * `throwIfNoEntry: false` makes the ABSENT case a value rather than an
986
+ * exception, so the caller has one branch instead of a `try` around a
987
+ * predicate. That option suppresses `ENOENT` AND NOTHING ELSE, which is the
988
+ * whole of `WR-12`.
989
+ *
990
+ * REVERSED 2026-08-28, and the reversal is the record rather than a deletion
991
+ * (this module's header discipline, 28-07 P3). The premise that was RIGHT and
992
+ * stays: the walk must stop at a path ENTRY, and only `lstat` can see one --
993
+ * `existsSync` follows links and cannot. The sentence that became FALSE: that
994
+ * swapping `existsSync` for `lstatSync` changed only which QUESTION was asked.
995
+ * It also changed what happens when the question cannot be answered.
996
+ * `existsSync` swallowed every error and returned `false`; `lstatSync` with
997
+ * `throwIfNoEntry: false` swallows `ENOENT` only. So three ORDINARY caller
998
+ * inputs regressed from a named `AnnoStorePathError` to a bare `Error`
999
+ * escaping the `ViceError` family entirely -- measured on Node 22.22, at the
1000
+ * predicate AND through `openStore`, both:
1001
+ *
1002
+ * * `ENOTDIR` -- an ancestor that is a regular file (`<ws>/notes.txt/p.annostore`),
1003
+ * which needs no symlink, no privilege and nothing pre-existing;
1004
+ * * `EACCES` -- an unreadable ancestor directory;
1005
+ * * `ELOOP` -- a symlink cycle in an ANCESTOR position, where the kernel
1006
+ * refuses at `lstat` before the manual hop counter below ever runs.
1007
+ *
1008
+ * `28-REVIEW.md` WR-12 has the before/after transcript. The `try` restores the
1009
+ * family WITHOUT restoring the old blindness: the absent case is still a value
1010
+ * and still one branch, and everything else is a decision naming both the entry
1011
+ * the walk stopped on and the path being confined.
1012
+ */
1013
+ function pathEntryExists(p: string, resolved: string): boolean {
1014
+ try {
1015
+ return lstatSync(p, { throwIfNoEntry: false }) !== undefined;
1016
+ } catch (e) {
1017
+ throw new AnnoStorePathError(
1018
+ `cannot stat ${JSON.stringify(p)} while confining ${JSON.stringify(resolved)} (${(e as Error).message})`,
1019
+ { path: resolved },
1020
+ );
1021
+ }
1022
+ }
1023
+
1024
+ /**
1025
+ * Returns the REAL absolute path of `p`, resolved through the deepest ancestor
1026
+ * whose path ENTRY exists on disk, with the non-existent tail re-joined after
1027
+ * it.
1028
+ *
1029
+ * WHY THE WALK. The common case is a store file that does NOT exist yet -- the
1030
+ * store is created on first open -- so a bare `realpathSync(p)` would throw
1031
+ * `ENOENT` on exactly the path this module most needs to check. The walk stops
1032
+ * at the first existing ancestor, resolves THAT, and re-joins the remaining
1033
+ * segments afterwards, so the answer is the path the filesystem will actually
1034
+ * use once the tail is created.
1035
+ *
1036
+ * WHY THE TAIL IS RE-JOINED AFTER the real ancestor rather than before: the
1037
+ * symlinks that matter are the ones already on disk, and they are all in the
1038
+ * existing prefix. Re-joining after resolution is what makes the returned value
1039
+ * the location a write lands at, which is the only thing confinement can
1040
+ * honestly compare.
1041
+ *
1042
+ * REVERSED 2026-08-28, and the reversal is the record rather than a deletion
1043
+ * (this module's header discipline, 28-07 P3). The premise that was RIGHT and
1044
+ * stays: the deepest EXISTING ancestor is the correct stopping point, and the
1045
+ * tail belongs after it. The sentence that became FALSE: this walk used to stop
1046
+ * at "the first path that `existsSync` reports present". `existsSync` FOLLOWS
1047
+ * links, so it reports `false` for a dangling one, and the walk stepped straight
1048
+ * PAST the link instead of stopping at it -- after which the confinement
1049
+ * compared a path the filesystem would later resolve somewhere else entirely.
1050
+ * Reproduced against committed code at `a8187d2`: a dangling leaf link written
1051
+ * `../outside/p.annostore` was ACCEPTED (`A) confinement ACCEPTED, returned:
1052
+ * /tmp/annosym-XXXX/ws/p.annostore`) and `openStore` created the store file
1053
+ * OUTSIDE the workspace root (`A) file created OUTSIDE workspace: true`);
1054
+ * separately, a dangling DIRECTORY link was accepted at the predicate
1055
+ * (`B dangling dir -> ACCEPTED`). `28-VERIFICATION.md` gap 2 / `28-REVIEW.md`
1056
+ * CR-04. The walk now stops on `pathEntryExists`, which is `lstat` and does not
1057
+ * follow the link.
1058
+ *
1059
+ * THE DANGLING STOPPING ENTRY IS RESOLVED BY HAND, because nothing else will:
1060
+ * `realpathSync` cannot resolve a chain whose end does not exist. The hop reads
1061
+ * the link and resolves its target AGAINST THE LINK'S OWN DIRECTORY, never
1062
+ * against the process cwd -- a relative target (`../outside/x`) is the common
1063
+ * form, and resolving it against the cwd is the one way a naive `readlinkSync`
1064
+ * fix gets this wrong. `tail` is deliberately NOT touched by a hop: the link's
1065
+ * own name is CONSUMED by the hop, and the segments below it still hang below
1066
+ * whatever the link resolves to. After the hop the loop re-enters the same walk,
1067
+ * so a CHAIN of dangling links is this one case repeated rather than a new one,
1068
+ * and a hop that lands on a LIVE entry falls through to `realpathSync`, which
1069
+ * resolves the rest of the chain itself. There is no third state.
1070
+ *
1071
+ * Nothing exists anywhere on the path (the walk reached the filesystem root):
1072
+ * there is nothing to resolve, so the answer is built from `current` and `tail`.
1073
+ * Those two are equal to the pre-walk `resolved` when no hop has happened, and
1074
+ * AFTER a hop `resolved` describes a path the walk is no longer on -- returning
1075
+ * it there would be a stale answer about the wrong location.
1076
+ *
1077
+ * Every `realpathSync`, `lstatSync` and `readlinkSync` failure is rethrown as
1078
+ * `AnnoStorePathError` naming the path, so a permission error resolving an
1079
+ * ancestor stays inside the `ViceError` family instead of escaping as a bare
1080
+ * `Error`.
1081
+ */
1082
+ function realpathOfNearestExisting(p: string): string {
1083
+ const resolved = resolve(p);
1084
+ const tail: string[] = [];
1085
+ let current = resolved;
1086
+ let hops = 0;
1087
+
1088
+ for (;;) {
1089
+ let reachedFilesystemRoot = false;
1090
+ while (!pathEntryExists(current, resolved)) {
1091
+ const parent = dirname(current);
1092
+ if (parent === current) {
1093
+ reachedFilesystemRoot = true;
1094
+ break;
1095
+ }
1096
+ tail.unshift(basename(current));
1097
+ current = parent;
1098
+ }
1099
+ if (reachedFilesystemRoot) {
1100
+ return tail.length === 0 ? current : join(current, ...tail);
1101
+ }
1102
+
1103
+ // The stopping ENTRY exists. Is it a symlink whose target does not? That is
1104
+ // the one class `existsSync` could not see, and the only one needing a hop.
1105
+ let stoppedAtDanglingLink: boolean;
1106
+ try {
1107
+ stoppedAtDanglingLink = lstatSync(current).isSymbolicLink() && !existsSync(current);
1108
+ } catch (e) {
1109
+ throw new AnnoStorePathError(
1110
+ `cannot stat ${JSON.stringify(current)} while confining ${JSON.stringify(resolved)} (${(e as Error).message})`,
1111
+ { path: resolved },
1112
+ );
1113
+ }
1114
+
1115
+ if (stoppedAtDanglingLink) {
1116
+ hops += 1;
1117
+ if (hops > MAX_SYMLINK_HOPS) {
1118
+ throw new AnnoStorePathError(
1119
+ `cannot resolve ${JSON.stringify(resolved)}: more than ${MAX_SYMLINK_HOPS} symbolic-link hops while resolving ` +
1120
+ `${JSON.stringify(current)} -- a symlink cycle or an over-long chain, refused rather than followed`,
1121
+ { path: resolved },
1122
+ );
1123
+ }
1124
+ let link: string;
1125
+ try {
1126
+ link = readlinkSync(current);
1127
+ } catch (e) {
1128
+ throw new AnnoStorePathError(
1129
+ `cannot read the symbolic link ${JSON.stringify(current)} while confining ${JSON.stringify(resolved)} (${(e as Error).message})`,
1130
+ { path: resolved },
1131
+ );
1132
+ }
1133
+ // Against the LINK'S directory, never the process cwd.
1134
+ current = resolve(dirname(current), link);
1135
+ continue;
1136
+ }
1137
+
1138
+ let real: string;
1139
+ try {
1140
+ real = realpathSync(current);
1141
+ } catch (e) {
1142
+ throw new AnnoStorePathError(
1143
+ `cannot resolve the real path of ${JSON.stringify(current)} while confining ${JSON.stringify(resolved)} (${(e as Error).message})`,
1144
+ { path: resolved },
1145
+ );
1146
+ }
1147
+ return tail.length === 0 ? real : join(real, ...tail);
1148
+ }
1149
+ }
1150
+
1151
+ /**
1152
+ * Resolves `path` and `workspaceRoot` to REAL paths and returns the resolved
1153
+ * absolute store path, or throws `AnnoStorePathError` when the store path is
1154
+ * not the workspace root itself or something beneath it. Boundary-safe: the
1155
+ * comparison appends the platform separator rather than testing a bare string
1156
+ * prefix, so a sibling directory whose name merely STARTS with the root's name
1157
+ * is refused.
1158
+ *
1159
+ * BOTH SIDES GO THROUGH `realpathOfNearestExisting`, and that is the
1160
+ * load-bearing detail rather than a symmetry preference:
1161
+ *
1162
+ * * The PATH must be a real path, because `resolve()` normalises `..` but
1163
+ * does NOT follow symbolic links. The pure-string version accepted a
1164
+ * symlinked subdirectory inside the workspace and the store file was
1165
+ * created outside the root (`28-REVIEW.md` CR-03, reproduced by the phase
1166
+ * verifier). A confinement check has to compare what the filesystem will
1167
+ * actually do.
1168
+ * * The ROOT must go through the SAME walk, for two independent reasons. A
1169
+ * workspace root that does not exist is a legitimate input -- the pinned
1170
+ * case in `anno-store.test.ts` passes `<dir>/nested`, which is never
1171
+ * created -- and a bare `realpathSync` on it throws a raw `ENOENT`,
1172
+ * replacing a clean named refusal with a non-family error. And resolving
1173
+ * only ONE side makes every path look foreign whenever the root itself is
1174
+ * reached through a symlink, which is the common case on hosts where the
1175
+ * temp directory is a link.
1176
+ *
1177
+ * BEHAVIOURAL CONSEQUENCE, intended and tested: because this returns the real
1178
+ * path, a store reached through a symlink that points INSIDE the workspace is
1179
+ * FOLLOWED, and the file lands at the link's real location rather than through
1180
+ * the link. The alternative -- refusing every symlink -- would refuse
1181
+ * legitimate layouts, and is the over-broad fix that
1182
+ * `anno-confinement.test.ts` discriminates against: a control that only ever
1183
+ * refuses is indistinguishable from one that works.
1184
+ *
1185
+ * This is the ONE export in this module that is a function of its arguments AND
1186
+ * the filesystem; see the narrowed trap 3 in the header.
1187
+ *
1188
+ * The path is deliberately NOT routed through either host/container
1189
+ * path-translation seam -- see trap 7 in `anno-store.ts`'s header for what a
1190
+ * translated store path would do. `node:fs` is a Node builtin, not a seam, and
1191
+ * `hostpath-consumers.test.ts`'s closed consumer set still excludes this
1192
+ * module.
1193
+ */
1194
+ export function storePathWithinWorkspace(path: string, workspaceRoot: string): string {
1195
+ const resolvedRoot = realpathOfNearestExisting(workspaceRoot);
1196
+ const resolvedPath = realpathOfNearestExisting(path);
1197
+ if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(resolvedRoot + sep)) {
1198
+ throw new AnnoStorePathError(
1199
+ `store path ${JSON.stringify(resolvedPath)} is outside the workspace root ${JSON.stringify(resolvedRoot)} -- refusing to open a store there`,
1200
+ { path: resolvedPath, workspaceRoot: resolvedRoot },
1201
+ );
1202
+ }
1203
+ return resolvedPath;
1204
+ }
1205
+
1206
+ /**
1207
+ * Returns the location of `path` RELATIVE to `workspaceRoot`, spelled with
1208
+ * POSIX `/` separators, or `"."` when the two resolve to the same directory.
1209
+ * Throws `AnnoStorePathError` -- the same class `storePathWithinWorkspace()`
1210
+ * throws -- when the spelling would leave the root.
1211
+ *
1212
+ * WHY THIS IS A SEAM RATHER THAN AN INLINE `relative()` AT ITS ONE CALL SITE.
1213
+ * The spelling this returns goes into a **compared** artifact: the memory
1214
+ * map's banner is re-rendered and diffed BYTE FOR BYTE by
1215
+ * `checkRenderedMemoryMap()`. The defect it closes (`CR-01`, gap 1 in
1216
+ * `.planning/phases/29-the-mcp-surface/29-VERIFICATION.md`) is that machine
1217
+ * identity leaked into that content comparison: the banner recorded the
1218
+ * absolute realpaths, so the same store, the same sidecar and the same
1219
+ * rendered file reported `drifted` as soon as the checkout sat at a different
1220
+ * absolute path -- while the artifact's own `render_digest`, which covers
1221
+ * content and not paths, printed IDENTICAL in both trees. The digest and the
1222
+ * verdict disagreed by construction. "Which spelling goes into the compared
1223
+ * bytes" therefore needs exactly one definition, and this is it; an inlined
1224
+ * `relative()` at the call site is how it grows a second one.
1225
+ *
1226
+ * BOTH SIDES GO THROUGH `realpathOfNearestExisting`, for the same reason
1227
+ * `storePathWithinWorkspace()` above does, and the two must not drift apart.
1228
+ * The store path arrives here having already been through that seam (the CLI
1229
+ * confines it), while the workspace root arrives from `repoRoot()` and is NOT
1230
+ * necessarily a realpath. Resolving only one side would make a legitimately
1231
+ * in-workspace store look foreign whenever the root is reached through a
1232
+ * symlink -- the common case on hosts where the temp directory is a link --
1233
+ * and would silently produce a `../…` spelling, re-introducing the very
1234
+ * machine dependence this function exists to remove, wearing a new spelling.
1235
+ *
1236
+ * THE ESCAPE CASE THROWS DELIBERATELY. Returning `"../../tmp/xyz/game.annostore"`
1237
+ * would be machine identity again: the number of `..` hops encodes where the
1238
+ * checkout sits. A banner cannot record a location outside the workspace root
1239
+ * at all, so this makes that unrepresentable rather than merely unlikely. A
1240
+ * caller that wants to record such a path has a confinement problem, not a
1241
+ * spelling problem.
1242
+ *
1243
+ * This is NOT a confinement check and must not be read as one. It computes a
1244
+ * spelling and refuses one it cannot spell; `storePathWithinWorkspace()` above
1245
+ * is the seam that decides whether a path may be opened at all.
1246
+ */
1247
+ export function workspaceRelativePath(path: string, workspaceRoot: string): string {
1248
+ const resolvedRoot = realpathOfNearestExisting(workspaceRoot);
1249
+ const resolvedPath = realpathOfNearestExisting(path);
1250
+ if (resolvedPath === resolvedRoot) return ".";
1251
+
1252
+ const spelling = relative(resolvedRoot, resolvedPath).split(sep).join("/");
1253
+ if (spelling === "" || isAbsolute(spelling) || spelling === ".." || spelling.startsWith("../")) {
1254
+ throw new AnnoStorePathError(
1255
+ `path ${JSON.stringify(resolvedPath)} is outside the workspace root ${JSON.stringify(resolvedRoot)} -- ` +
1256
+ "refusing to record a location outside the workspace root, because a relative spelling that escapes the root " +
1257
+ "encodes where the checkout sits and is the same machine-dependence under a different spelling",
1258
+ { path: resolvedPath, workspaceRoot: resolvedRoot },
1259
+ );
1260
+ }
1261
+ return spelling;
1262
+ }
1263
+
1264
+ /**
1265
+ * The ONE vocabulary-membership check. Every `assert*` over a frozen string
1266
+ * vocabulary routes through here, so "is a member" has one definition and the
1267
+ * refusal message has one shape: the offending value, then the full valid list.
1268
+ *
1269
+ * `AnnoTypeError`'s `dataType` field carries the offending value whatever the
1270
+ * vocabulary was -- the field was named for the first vocabulary that needed it
1271
+ * and is deliberately not renamed, because renaming it would be a breaking
1272
+ * change to an error field for a cosmetic gain.
1273
+ */
1274
+ function assertMember<T extends string>(value: unknown, members: readonly T[], what: string): T {
1275
+ if (typeof value === "string" && (members as readonly string[]).includes(value)) {
1276
+ return value as T;
1277
+ }
1278
+ throw new AnnoTypeError(
1279
+ `${what} ${JSON.stringify(value)} is not one of the ${members.length} valid values -- expected one of: ${members.join(", ")}`,
1280
+ { dataType: value, validTypes: [...members] },
1281
+ );
1282
+ }
1283
+
1284
+ /** Narrows an unvalidated argument to a `CommentType`, or throws
1285
+ * `AnnoTypeError` carrying the offending value and both valid members. */
1286
+ export function assertCommentType(value: unknown): CommentType {
1287
+ return assertMember(value, COMMENT_TYPES, "comment type");
1288
+ }
1289
+
1290
+ /** Narrows an unvalidated argument to a `LabelKind`, or throws `AnnoTypeError`
1291
+ * carrying the offending value and all four valid members. */
1292
+ export function assertLabelKind(value: unknown): LabelKind {
1293
+ return assertMember(value, LABEL_KINDS, "label kind");
1294
+ }
1295
+
1296
+ /** Narrows an unvalidated argument to an `XrefAccessKind`, or throws
1297
+ * `AnnoTypeError` carrying the offending value and all four valid members. A
1298
+ * fifth access kind is refused here rather than stored and puzzled over later. */
1299
+ export function assertAccessKind(value: unknown): XrefAccessKind {
1300
+ return assertMember(value, XREF_ACCESS_KINDS, "access kind");
1301
+ }
1302
+
1303
+ /** The legal-identifier shape, quoted from the schema's own sentence
1304
+ * (`anno-tools.ts:246-251`): "starts with a letter or underscore, followed by
1305
+ * letters/digits/underscores only". Used for label names and for project-enum
1306
+ * names, which the schema calls a "unique alphanumeric identifier" and whose own
1307
+ * documented example (`vic_registers`) carries an underscore. */
1308
+ const LEGAL_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1309
+
1310
+ /**
1311
+ * Refuses a label name that is not a legal ACME identifier, or that is a
1312
+ * 6502/6510 mnemonic. Returns the name UNCHANGED on acceptance.
1313
+ *
1314
+ * THREE REFUSALS AND NO NORMALISATION. There is no sanitisation, substitution,
1315
+ * trimming or quoting step in this function or anywhere on the store's write
1316
+ * path, and trap 7 states the reason as the concrete hazard: a
1317
+ * space-to-underscore substitution turns two names a human deliberately
1318
+ * distinguished into one, and the loss is silent and permanent.
1319
+ *
1320
+ * TWO DIFFERENT COMPARISONS, stated here so the store's collision check has one
1321
+ * definition to follow. The denylist comparison is CASE-INSENSITIVE -- `LDA`,
1322
+ * `lda` and `Lda` are all illegal, because all three assemble to the same
1323
+ * instruction. The comparison of one label name against another (the collision
1324
+ * check, which lives in `anno-store.ts` because it needs the rows) is EXACT BYTE
1325
+ * EQUALITY: no case folding, no Unicode normalisation, no whitespace trimming.
1326
+ * Any of those would be a normalisation, and a normalisation is what merges two
1327
+ * names.
1328
+ */
1329
+ export function assertLegalLabel(name: unknown): string {
1330
+ if (typeof name !== "string" || name.length === 0) {
1331
+ throw new AnnoLabelError(
1332
+ `label name ${JSON.stringify(name)} is not a legal identifier -- expected a non-empty string starting with a letter or underscore, ` +
1333
+ `then letters, digits and underscores only. An illegal name is REFUSED, never sanitised or quoted.`,
1334
+ { identifier: typeof name === "string" ? name : undefined, reason: "not a non-empty string" },
1335
+ );
1336
+ }
1337
+ if (!LEGAL_IDENTIFIER_RE.test(name)) {
1338
+ throw new AnnoLabelError(
1339
+ `label name ${JSON.stringify(name)} is not a legal identifier -- it must start with a letter or underscore and then contain ` +
1340
+ `letters, digits and underscores only. It is REFUSED rather than rewritten: substituting a character would merge this name with ` +
1341
+ `whatever name the substitution produces, and nothing would record that it happened.`,
1342
+ { identifier: name, reason: "illegal character" },
1343
+ );
1344
+ }
1345
+ if (MNEMONIC_DENYLIST.has(name.toLowerCase())) {
1346
+ throw new AnnoLabelError(
1347
+ `label name ${JSON.stringify(name)} is a 6502/6510 mnemonic and cannot be a label -- the denylist is derived from the full ` +
1348
+ `256-entry opcode table, so it covers the illegal-opcode mnemonics too, and it is compared case-insensitively.`,
1349
+ { identifier: name, reason: "6502/6510 mnemonic" },
1350
+ );
1351
+ }
1352
+ return name;
1353
+ }
1354
+
1355
+ /**
1356
+ * Refuses a project-enum name that is not the schema's "unique alphanumeric
1357
+ * identifier". Returns it unchanged.
1358
+ *
1359
+ * The mnemonic denylist is deliberately NOT applied: the schema's mnemonic rule
1360
+ * is scoped to LABEL names, which are the symbols an assembler sees, and
1361
+ * widening it here would refuse an enum name on a rule the surface this store
1362
+ * mirrors does not have.
1363
+ */
1364
+ export function assertEnumName(name: unknown): string {
1365
+ if (typeof name !== "string" || !LEGAL_IDENTIFIER_RE.test(name)) {
1366
+ throw new AnnoLabelError(
1367
+ `project enum name ${JSON.stringify(name)} is not a legal identifier -- expected a non-empty string starting with a letter or ` +
1368
+ `underscore, then letters, digits and underscores only`,
1369
+ { identifier: typeof name === "string" ? name : undefined, reason: "illegal enum name" },
1370
+ );
1371
+ }
1372
+ return name;
1373
+ }
1374
+
1375
+ /** The UTF-8 byte length of `text`. A `TextEncoder` and not `String.length`:
1376
+ * see trap 10 -- code units are not bytes, and the bound is a bound on the
1377
+ * bytes that land in the store file. */
1378
+ function utf8ByteLength(text: string): number {
1379
+ return new TextEncoder().encode(text).length;
1380
+ }
1381
+
1382
+ /**
1383
+ * Every line-break character a comment may not contain: LF, CR, and the two
1384
+ * Unicode line separators U+2028 / U+2029.
1385
+ *
1386
+ * LF and CR are the ones that genuinely end a line in generated assembler
1387
+ * source. U+2028 and U+2029 are refused alongside them because some editors
1388
+ * emit them where a human meant a line break, and a field documented as
1389
+ * single-line should not silently carry one on any of the four spellings -- a
1390
+ * check that catches three of four is a check somebody will trip over exactly
1391
+ * once, a long way from here.
1392
+ */
1393
+ const LINE_BREAK_RE = /[\n\r\u2028\u2029]/;
1394
+
1395
+ /**
1396
+ * Refuses comment (or description) text. FOUR checks, in the order they run:
1397
+ *
1398
+ * 1. Not a string at all.
1399
+ * 2. An embedded LINE BREAK -- `\n`, `\r`, U+2028 or U+2029, anywhere in the
1400
+ * text. Named before a length is, because "your comment has a line break
1401
+ * in it" is actionable and "your comment is too long" would not be.
1402
+ * 3. The `';'` prefix the schema tells callers to omit ("Do not include the
1403
+ * ';' prefix", `anno-tools.ts:269`) -- unless `allowLeadingSemicolon`.
1404
+ * 4. Over `MAX_COMMENT_BYTES` UTF-8 bytes.
1405
+ *
1406
+ * Returns the text UNCHANGED on acceptance. Every refusal is a REFUSAL and
1407
+ * never a repair: an over-long comment is not truncated, because a truncation
1408
+ * drops the end of a human's sentence and reports success, and a line break is
1409
+ * not stripped, because stripping MERGES two things somebody wrote on separate
1410
+ * lines into one text -- the same silent, permanent loss trap 7 records for
1411
+ * space-to-underscore label sanitisation.
1412
+ *
1413
+ * WHY A LINE BREAK IS A REFUSAL AND NOT A COSMETIC ISSUE: the store holds the
1414
+ * comment's WORDS and the exporter adds the `';'` prefix. A stored line break
1415
+ * therefore puts everything after it into the generated ACME source at column
1416
+ * zero -- as assembler INPUT, not as a comment.
1417
+ *
1418
+ * The line-break rule applies UNCONDITIONALLY, `allowLeadingSemicolon`
1419
+ * included. Both call sites are single-line text fields; that flag governs the
1420
+ * semicolon rule and nothing else.
1421
+ *
1422
+ * `allowLeadingSemicolon` exists for the one neighbouring text field with the
1423
+ * same byte bound and no semicolon rule: a project enum's free-text
1424
+ * description, which the schema does not describe as assembler comment text.
1425
+ */
1426
+ export function assertCommentText(text: unknown, opts: { what?: string; allowLeadingSemicolon?: boolean } = {}): string {
1427
+ const what = opts.what ?? "comment";
1428
+ if (typeof text !== "string") {
1429
+ throw new AnnoCommentError(`${what} text ${JSON.stringify(text)} is not a string`, { reason: "not a string" });
1430
+ }
1431
+ if (LINE_BREAK_RE.test(text)) {
1432
+ throw new AnnoCommentError(
1433
+ `${what} text contains an embedded line break -- the store holds the comment's words and the exporter adds the ';' prefix, so a stored ` +
1434
+ `newline would put everything after it into the generated ACME source at column zero, as assembler input rather than as a comment. ` +
1435
+ `It is REFUSED rather than stripped, because stripping merges two lines somebody wrote separately into one text and reports success`,
1436
+ { reason: "embedded newline" },
1437
+ );
1438
+ }
1439
+ if (opts.allowLeadingSemicolon !== true && /^\s*;/.test(text)) {
1440
+ throw new AnnoCommentError(
1441
+ `${what} text must not begin with the ';' prefix -- the store holds the comment's words and the exporter adds the prefix, ` +
1442
+ `so a stored ';' would be emitted twice`,
1443
+ { reason: "semicolon prefix" },
1444
+ );
1445
+ }
1446
+ const bytes = utf8ByteLength(text);
1447
+ if (bytes > MAX_COMMENT_BYTES) {
1448
+ throw new AnnoCommentError(
1449
+ `${what} text is ${bytes} UTF-8 bytes, over the ${MAX_COMMENT_BYTES}-byte bound -- it is REFUSED rather than truncated, ` +
1450
+ `because a truncation drops the end of a sentence somebody wrote and reports success`,
1451
+ { reason: "over MAX_COMMENT_BYTES", byteLength: bytes },
1452
+ );
1453
+ }
1454
+ return text;
1455
+ }
1456
+
1457
+ /** The largest value a project-enum variant key may name. Bounded so a variants
1458
+ * object cannot carry an arbitrary-length key, and NOT bounded to the address
1459
+ * space, because a variant key is a VALUE -- a register bitmask or a mode
1460
+ * number -- rather than an address. */
1461
+ export const MAX_VARIANT_KEY = 0xffffffff;
1462
+
1463
+ /**
1464
+ * Parses one project-enum variant key into its numeric value. Accepts exactly
1465
+ * the forms the schema names (`anno-tools.ts:474`): "keys are numeric strings
1466
+ * (decimal, hex 0x/$, bin 0b/%)".
1467
+ *
1468
+ * Decimal IS accepted here, and that is not an inconsistency with
1469
+ * `parseStoreAddress()`'s refusal of `"1024"` (trap 4). The two answer different
1470
+ * questions: an ADDRESS in the wrong base points at the wrong memory and the
1471
+ * error is silent and persistent, whereas a variant key's base is stated by the
1472
+ * schema itself, so an unprefixed key has one documented reading and no
1473
+ * ambiguity to resolve.
1474
+ */
1475
+ export function parseVariantKey(key: unknown): number {
1476
+ if (typeof key !== "string" || key.trim() === "") {
1477
+ throw new AnnoTypeError(
1478
+ `enum variant key ${JSON.stringify(key)} is not a numeric string -- expected decimal, "0x"/"$" hex or "0b"/"%" binary`,
1479
+ { dataType: key },
1480
+ );
1481
+ }
1482
+ const raw = key.trim();
1483
+ const forms: readonly (readonly [RegExp, number, number])[] = [
1484
+ [/^\$([0-9a-fA-F]+)$/, 16, 1],
1485
+ [/^0[xX]([0-9a-fA-F]+)$/, 16, 2],
1486
+ [/^%([01]+)$/, 2, 1],
1487
+ [/^0[bB]([01]+)$/, 2, 2],
1488
+ [/^([0-9]+)$/, 10, 0],
1489
+ ];
1490
+ for (const [pattern, radix, skip] of forms) {
1491
+ const match = pattern.exec(raw);
1492
+ if (match) {
1493
+ const value = parseInt(raw.slice(skip), radix);
1494
+ if (!Number.isInteger(value) || value < 0 || value > MAX_VARIANT_KEY) {
1495
+ throw new AnnoTypeError(
1496
+ `enum variant key ${JSON.stringify(raw)} is ${value}, outside 0..${MAX_VARIANT_KEY}`,
1497
+ { dataType: key },
1498
+ );
1499
+ }
1500
+ return value;
1501
+ }
1502
+ }
1503
+ throw new AnnoTypeError(
1504
+ `enum variant key ${JSON.stringify(raw)} is not one of the numeric-string forms the schema names -- expected decimal ("64"), ` +
1505
+ `hex ("$40" or "0x40") or binary ("%01000000" or "0b01000000")`,
1506
+ { dataType: key },
1507
+ );
1508
+ }
1509
+
1510
+ /** True for the two `_address` split layouts, false for the two `_word` ones.
1511
+ * The schema's own distinction (`anno-tools.ts:305-313`):
1512
+ * `address=16-bit LE pointers (creates X-Refs, ...)` versus
1513
+ * `word=16-bit LE values`. Exported separately from `resolveSplitTargets()` so
1514
+ * the store can ask the question without resolving anything. */
1515
+ export function producesXrefsFor(dataType: SplitDataType): boolean {
1516
+ return dataType.endsWith("_address");
1517
+ }
1518
+
1519
+ /**
1520
+ * Resolves a split table's bytes into its 16-bit targets. PURE: no I/O, no
1521
+ * state, and nothing it computes is ever written to disk.
1522
+ *
1523
+ * THE LAYOUT. `bytes` is the table's raw bytes; the first half is one byte of
1524
+ * each entry and the second half is the other. For the low-high orientation the
1525
+ * first half holds the LOW bytes, so target `i` is
1526
+ * `bytes[i] | (bytes[n + i] << 8)`; for the high-low orientation the first half
1527
+ * holds the HIGH bytes, so target `i` is `(bytes[i] << 8) | bytes[n + i]`.
1528
+ *
1529
+ * THE WORKED ARITHMETIC, verified during research and pinned by
1530
+ * `anno-types.test.ts`: the bytes `10 34 00 ff 08 12 c0 cf` resolve to
1531
+ * `$0810 $1234 $c000 $cfff` under `lo_hi_address` and to
1532
+ * `$1008 $3412 $00c0 $ffcf` under `hi_lo_address`. THAT DIFFERING TARGET SET IS
1533
+ * THE CONTROL for the decision to keep all four split layouts as first-class
1534
+ * members: it is the one observable consequence of recording orientation.
1535
+ *
1536
+ * WHY A BYTE-IDENTICAL REASSEMBLY ASSERTION CANNOT BE THAT CONTROL: a retype
1537
+ * changes no bytes. A reassembly comparison is therefore green under BOTH
1538
+ * orientations and can never go red on a collapsed vocabulary. It is worth
1539
+ * having separately -- it catches an exporter that mangles bytes -- but it is
1540
+ * not this claim's evidence and must not be presented as such.
1541
+ *
1542
+ * THIS FUNCTION DERIVES AND RETURNS. It never stores. See `anno-store.ts`'s
1543
+ * `putXref` for the reason a derived cross-reference must not reach the disk.
1544
+ */
1545
+ export function resolveSplitTargets(bytes: Uint8Array | readonly number[], dataType: unknown): SplitTargets {
1546
+ const layout = assertSplitLayout(dataType);
1547
+ const source = Array.from(bytes);
1548
+ const lowFirst = layout.startsWith("lo_hi_");
1549
+ const targets: number[] = [];
1550
+ // THE PARTNER RULE IS NOT RE-DERIVED HERE. The offset couples come from
1551
+ // `splitPartnerOffsets()`, which is the one place in the repo that knows an
1552
+ // entry's partner -- and which raises this function's own odd-byte-count
1553
+ // refusal, so the message and the rule stay together.
1554
+ for (const [firstOffset, secondOffset] of splitPartnerOffsets(source.length, layout)) {
1555
+ const first = source[firstOffset] & 0xff;
1556
+ const second = source[secondOffset] & 0xff;
1557
+ targets.push(lowFirst ? first | (second << 8) : (first << 8) | second);
1558
+ }
1559
+ return { entryCount: targets.length, targets, producesXrefs: producesXrefsFor(layout) };
1560
+ }
1561
+
1562
+ /**
1563
+ * THE ONE PLACE IN THIS REPO WHERE AN ENTRY'S PARTNER IS COMPUTED.
1564
+ *
1565
+ * Returns the ordered offset couples `[i, n + i]` for `i` in `0..n-1`, with
1566
+ * `n = byteCount / 2` -- the first-half/second-half layout, expressed once.
1567
+ *
1568
+ * ITS TWO CONSUMERS, NAMED because a third would defeat the point:
1569
+ * * `resolveSplitTargets()` above, which reads BYTES at those offsets;
1570
+ * * `splitEntryAddressPairs()` below, which reads ADDRESSES at them, and is
1571
+ * what `anno-store.ts`'s `retype()` gate consults before it fragments a
1572
+ * split row.
1573
+ *
1574
+ * A resolver and a writer that each kept their own copy of this arithmetic could
1575
+ * disagree about what an entry IS, and the disagreement would be silent: both
1576
+ * copies produce legal, decodable rows. That is CR-10's whole class, so the rule
1577
+ * has one home. `anno-types.test.ts`'s worked-arithmetic pin is the control --
1578
+ * changing the couples here to an interleaved `[2i, 2i + 1]` reddens it.
1579
+ *
1580
+ * The odd-count refusal lives here for the same reason: it is the SAME rule
1581
+ * stated as a precondition, and `assertRangeShape()` asks it of an address span
1582
+ * while this asks it of a byte count.
1583
+ */
1584
+ function splitPartnerOffsets(byteCount: number, layout: SplitDataType): readonly (readonly [number, number])[] {
1585
+ if (byteCount % 2 !== 0) {
1586
+ throw new AnnoRangeShapeError(
1587
+ `a ${layout} table needs an even byte count, but ${byteCount} byte(s) were supplied -- the low half and the high half must be ` +
1588
+ `the same length`,
1589
+ { start: 0, endInclusive: byteCount - 1 },
1590
+ );
1591
+ }
1592
+ const n = byteCount / 2;
1593
+ const offsets: (readonly [number, number])[] = [];
1594
+ for (let i = 0; i < n; i += 1) offsets.push([i, n + i] as const);
1595
+ return offsets;
1596
+ }
1597
+
1598
+ /** Refuses anything that is not one of the four split layouts, with the full
1599
+ * list. Extracted so `resolveSplitTargets()` and `splitEntryAddressPairs()`
1600
+ * refuse a non-split type in exactly one way rather than two. */
1601
+ function assertSplitLayout(dataType: unknown): SplitDataType {
1602
+ if (typeof dataType !== "string" || !(SPLIT_DATA_TYPES as readonly string[]).includes(dataType)) {
1603
+ throw new AnnoTypeError(
1604
+ `${JSON.stringify(dataType)} is not a split-table layout -- expected one of: ${SPLIT_DATA_TYPES.join(", ")}`,
1605
+ { dataType, validTypes: [...SPLIT_DATA_TYPES] },
1606
+ );
1607
+ }
1608
+ return dataType as SplitDataType;
1609
+ }
1610
+
1611
+ /**
1612
+ * The two ADDRESSES whose bytes form each entry of the split table occupying
1613
+ * `start..endInclusive`, in table order. PURE: no I/O, no state, no bytes.
1614
+ *
1615
+ * WHY THE STORE NEEDS ADDRESSES RATHER THAN RESOLVED VALUES. The store holds no
1616
+ * bytes -- it holds spans and types, and the emulator or the image holds what is
1617
+ * in them. So the preservation question the store can answer FOR ITSELF is not
1618
+ * "did this entry's 16-bit value survive" (it cannot know the value) but "does
1619
+ * this entry still read the same two addresses". That is the question
1620
+ * `retype()`'s gate asks before it fragments a split row, and it is answerable
1621
+ * from the row's span alone.
1622
+ *
1623
+ * ORDERING IS LOAD-BEARING, the same split `setDataType()` makes: the TYPE gate
1624
+ * runs first (is this a split layout at all), then the RANGE gate
1625
+ * (`assertRangeShape`, which owns the even-byte-count rule for an address span).
1626
+ * Collapsing them would make a caller unable to tell "that is not a split
1627
+ * layout" from "those two ends do not make a split table".
1628
+ */
1629
+ export function splitEntryAddressPairs(start: number, endInclusive: number, dataType: SplitDataType): SplitEntryPairs {
1630
+ const layout = assertSplitLayout(dataType);
1631
+ assertRangeShape(start, endInclusive, layout);
1632
+ const pairs = splitPartnerOffsets(endInclusive - start + 1, layout).map(
1633
+ ([first, second]) => [start + first, start + second] as const,
1634
+ );
1635
+ return { entryCount: pairs.length, pairs };
1636
+ }