@henols/vice-mcp 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +4 -3
  2. package/THIRD-PARTY-NOTICES.md +423 -25
  3. package/{r2000-acme-ident.ts → anno-acme-ident.ts} +13 -13
  4. package/anno-bank.ts +171 -0
  5. package/anno-cli.ts +3040 -0
  6. package/{r2000-confidence.ts → anno-confidence.ts} +22 -22
  7. package/anno-coverage.ts +2465 -0
  8. package/anno-derive.ts +590 -0
  9. package/anno-details.ts +169 -0
  10. package/anno-enum-gen.ts +919 -0
  11. package/anno-export-asm.ts +2396 -0
  12. package/anno-graphics.ts +338 -0
  13. package/anno-hazard-report.ts +1367 -0
  14. package/anno-import.ts +495 -0
  15. package/anno-index.ts +150 -0
  16. package/anno-join.ts +480 -0
  17. package/{r2000-memmap-render.ts → anno-memmap-render.ts} +236 -95
  18. package/anno-provenance-ledger.ts +472 -0
  19. package/{r2000-regbits-gen.ts → anno-regbits-gen.ts} +20 -15
  20. package/{r2000-regbits.json → anno-regbits.json} +2 -2
  21. package/anno-register.ts +399 -0
  22. package/anno-store-export.ts +661 -0
  23. package/anno-store.ts +4002 -0
  24. package/anno-symbols.ts +266 -0
  25. package/anno-tools.ts +3264 -0
  26. package/anno-types.ts +1909 -0
  27. package/backend-detect.mts +124 -312
  28. package/block-class.ts +201 -0
  29. package/build.ts +4 -2
  30. package/capture-predicate.ts +597 -0
  31. package/channel-lock.ts +349 -0
  32. package/disasm-decoder.ts +14 -14
  33. package/disasm-opcodes.ts +4 -4
  34. package/disasm-renderer.ts +2 -2
  35. package/evid-ingest.ts +217 -0
  36. package/evid-reconcile.ts +316 -0
  37. package/host-tool-client.ts +430 -0
  38. package/hostpath.ts +1 -1
  39. package/incident-record.ts +23 -12
  40. package/install-resources.ts +30 -14
  41. package/memmap-lookup.ts +285 -0
  42. package/package.json +48 -23
  43. package/prg-image.ts +118 -0
  44. package/repo-root.ts +107 -8
  45. package/resources/backend-detect.mjs +98 -236
  46. package/resources/broker-control.mjs +189 -16
  47. package/resources/broker-epoch.mjs +1 -1
  48. package/resources/broker-kill.mjs +8 -2
  49. package/resources/broker-launch.mjs +373 -214
  50. package/resources/broker-state.mjs +64 -18
  51. package/resources/container-guard.mjs +1 -1
  52. package/resources/ghidra-project.mjs +790 -0
  53. package/resources/host-tool.mjs +2561 -0
  54. package/resources/vice-broker.mjs +330 -184
  55. package/resources/vice-launcher.sh +130 -12
  56. package/stock-address.ts +6 -6
  57. package/stock-cia.ts +2 -2
  58. package/stock-condition.ts +8 -8
  59. package/stock-connect.ts +10 -6
  60. package/stock-derived.ts +29 -37
  61. package/stock-diagnose.ts +200 -36
  62. package/stock-dispatch.ts +200 -68
  63. package/stock-execution.ts +5 -3
  64. package/stock-handler.ts +1 -1
  65. package/stock-input.ts +9 -9
  66. package/stock-machine.ts +17 -6
  67. package/stock-paths.ts +18 -14
  68. package/stock-petscii.ts +1 -1
  69. package/stock-protocol.ts +17 -12
  70. package/stock-recycle.ts +83 -2
  71. package/stock-registers.ts +54 -29
  72. package/stock-reproducible-run.ts +811 -0
  73. package/stock-run-until.ts +100 -1
  74. package/stock-sprites.ts +3 -3
  75. package/stock-symbols.ts +13 -13
  76. package/stock-timing.ts +2 -2
  77. package/stock-vicii.ts +1 -1
  78. package/stop-oracle.ts +167 -0
  79. package/text-capability-probe.ts +660 -0
  80. package/text-connect.ts +157 -0
  81. package/text-protocol.ts +810 -0
  82. package/text-tools.ts +778 -0
  83. package/textmon-backtrace.ts +385 -0
  84. package/textmon-cpuhistory.ts +335 -0
  85. package/textmon-memmap.ts +494 -0
  86. package/textmon-profile.ts +458 -0
  87. package/textmon-registers.ts +748 -0
  88. package/tools-manifest.stock.json +864 -3
  89. package/version.ts +1 -1
  90. package/vice-broker-client.ts +189 -42
  91. package/vice-errors.ts +268 -0
  92. package/vice-proxy.ts +392 -2175
  93. package/vsf-slice.ts +640 -0
  94. package/capability-registry.ts +0 -388
  95. package/r2000-cli.ts +0 -1103
  96. package/r2000-d64.ts +0 -310
  97. package/r2000-enum-gen.ts +0 -574
  98. package/r2000-launch.ts +0 -357
  99. package/r2000-mcp-client.ts +0 -596
  100. package/r2000-project.ts +0 -190
  101. package/r2000-symbols.ts +0 -388
  102. package/r2000-tools.ts +0 -914
  103. package/r2000-verify.ts +0 -184
  104. package/refresh-manifest.ts +0 -124
  105. package/tools-manifest.json +0 -1223
  106. package/vice-probe.ts +0 -278
  107. package/vice-sync.ts +0 -336
  108. package/vice.ts +0 -772
package/anno-types.ts ADDED
@@ -0,0 +1,1909 @@
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-errors.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
+ * ---------------------------------------------------------------------------
187
+ * VERSION 4, 2026-09-10 (EVID-02) -- THE DECISION IS `reaffirm-refusal`, AND
188
+ * THE FACTUAL BASIS IS TRANSCRIBED HERE RATHER THAN LEFT IN A PLANNING
189
+ * DIRECTORY, for the same reason VERSION 3's paragraph gives.
190
+ *
191
+ * WHAT THE BUMP BUYS: `anno_evid_exec`, the durable, run-identity-keyed table
192
+ * that records ONE fact per row -- an address was OBSERVED EXECUTING during a
193
+ * specific run -- so a later session can query accumulated runtime evidence
194
+ * instead of re-running the program. See `anno-store.ts`'s `DDL` and its
195
+ * `insertExecObservations` / `listExecObservations` / `listObservedRuns` /
196
+ * `deleteExecObservationsForRun`.
197
+ *
198
+ * THE OPTION SELECTED, BY NAME: `reaffirm-refusal`. The strict-equality
199
+ * refusal inside `openStore` stays exactly as VERSION 3 left it -- a
200
+ * version-3 store does not open under this `SCHEMA_VERSION`, no migration arm
201
+ * is written, and the single-witness property (one comparison site, no
202
+ * second write of `anno_meta.schema_version`) is unchanged.
203
+ *
204
+ * THE FACTUAL CHECK, RUN 2026-09-10, SCOPE ONE DEVELOPMENT MACHINE:
205
+ * 1. `find / -xdev -name '*.annostore'`, with `.git` directories excluded --
206
+ * empty. The broader `find "$HOME" -name '*.annostore'` (closing the `-xdev`
207
+ * mount-boundary gap) found five hits, all inside a dated (2026-08-27)
208
+ * scratch probe cache (`~/.cache/gsd-probe/c4/...`), with
209
+ * fixture-pattern names (`t-half`, `t-zero`, `t-tail-100`, `p`, `proj`)
210
+ * and one file zero bytes -- synthetic test fixtures, not a real
211
+ * project's store.
212
+ * 2. `git log --oneline --diff-filter=A -- '*.annostore'` -- empty. No
213
+ * `.annostore` has ever been added to this repository's history.
214
+ * 3. Release tags versus when the store landed: `anno-store.ts` was added
215
+ * by `4c9cea3c` (2026-08-27). `v0.7.0` (2026-09-01) and `v0.8.0`
216
+ * (2026-09-06) BOTH POSTDATE that landing and the VERSION 3 bump
217
+ * itself. **THIS CORRECTS THE VERSION 3 PARAGRAPH ABOVE**: its stated
218
+ * justification -- "the last release tag (`v0.5.0`, 2026-08-25)
219
+ * PREDATES the store entirely -- so no tagged release has ever shipped
220
+ * a store at all" -- was true when written (2026-08-29) and is STALE
221
+ * now. Two tagged releases since then could, in principle, have shipped
222
+ * a working store to a real user.
223
+ * 4. Nothing outside that dated scratch/probe cache directory, and nothing
224
+ * tracked in git, was found on this machine.
225
+ *
226
+ * THE RE-AFFIRMATION IS MADE DESPITE THAT CORRECTION, on the strength of the
227
+ * observed NULL RESULT (zero real stores found), not on the strength of the
228
+ * now-stale "no release ever shipped one" framing. What changed since VERSION
229
+ * 3 is the *possibility* of a field store, not an observation of one, and the
230
+ * two directions' costs are asymmetric in reversibility: re-affirming refusal
231
+ * STAYS REVERSIBLE -- a migration arm can be added later, at version 5,
232
+ * against real evidence -- while shipping a mutating open path now adds a
233
+ * half-reasoned migration to a module that also has a corruption-refusal
234
+ * path, exactly where silent corruption lives, inside a plan whose actual job
235
+ * is an evidence table rather than a schema-migration redesign. House style
236
+ * settles the shape too: this project's standing pattern for a mismatched or
237
+ * missing external state is DETECT, THEN REFUSE BY NAME WITH THE REMEDY IN
238
+ * THE MESSAGE (`CLAUDE.md`'s "external tools are never auto-installed"
239
+ * constraint states the same discipline for a different kind of missing
240
+ * state) -- `openStore`'s refusal message is extended to name that remedy
241
+ * explicitly and to say the refused file is left untouched, so a user who
242
+ * hits it is not left inferring either fact.
243
+ *
244
+ * THE FACTUAL BASIS IS ONE MACHINE ONLY, and the limit travels with the
245
+ * decision: this is not a global guarantee that no field store exists --
246
+ * exactly the scope VERSION 3's own paragraph already accepted once.
247
+ *
248
+ * THE VERSION 1 PARAGRAPH ABOVE IS STILL THE PRECEDENT THIS IS MEASURED
249
+ * AGAINST, and this refusal is, again, a CHOICE and not an impossibility:
250
+ * version 4 COULD be given a migration arm and deliberately is not, for the
251
+ * reasons above.
252
+ *
253
+ * THE REVERSAL CONDITION, RECORDED SO THIS DOES NOT QUIETLY HARDEN INTO
254
+ * PRECEDENT: a migration arm at version 5 is justified if a real, non-scratch
255
+ * `.annostore` file is found in the field -- a user's own project, a bug
256
+ * report attaching one, or a support request -- or if a documented
257
+ * distribution channel is shown to have shipped a version-3 or version-4
258
+ * store to users who have not yet upgraded past it. Absent that evidence, the
259
+ * refusal stays the default.
260
+ *
261
+ * ---------------------------------------------------------------------------
262
+ * VERSION 5, 2026-09-11 (BUILD-07) -- THE DECISION IS `reaffirm-refusal`, THE
263
+ * SAME OPTION VERSION 4 SELECTED, AND THE FACTUAL BASIS IS TRANSCRIBED HERE
264
+ * RATHER THAN LEFT IN A PLANNING DIRECTORY, for the same reason every prior
265
+ * version's paragraph gives.
266
+ *
267
+ * WHAT THE BUMP BUYS: `anno_excluded_range`, the durable record of a
268
+ * user-requested exclusion -- its extent AND the reason the user gave -- so
269
+ * the export can emit the range's real bytes tagged with a visible marker
270
+ * comment instead of a hole, which is `BUILD-07`'s whole invariant: an
271
+ * exclusion is a RECORDED, VISIBLE fact, never a silent drop. See
272
+ * `anno-store.ts`'s `DDL` and its three verbs, `addExcludedRange` /
273
+ * `listExcludedRanges` / `removeExcludedRange`.
274
+ *
275
+ * THE OPTION SELECTED, BY NAME: `reaffirm-refusal`. The strict-equality
276
+ * refusal inside `openStore` stays EXACTLY as VERSION 4 left it -- this bump
277
+ * does not touch that comparison site at all: a version-4 store does not open
278
+ * under this `SCHEMA_VERSION`, no migration arm is written, and the
279
+ * single-witness property (one comparison site, no second write of
280
+ * `anno_meta.schema_version`) is unchanged.
281
+ *
282
+ * THE FACTUAL CHECK, RUN 2026-09-11, SCOPE ONE DEVELOPMENT MACHINE -- the
283
+ * same three checks VERSION 4 ran, re-run rather than inherited, with the
284
+ * REAL results transcribed as of today:
285
+ * 1. `find "$HOME" -name '*.annostore'`, with `.git` directories excluded --
286
+ * SIX hits, not VERSION 4's five. The same five fixture-pattern files
287
+ * inside the dated (2026-08-27) scratch probe cache (`t-half`, `t-zero`,
288
+ * `t-tail-100`, `p`, `proj`) are still present, UNCHANGED. The SIXTH is
289
+ * NEW since VERSION 4's check: `.c64-re-tools/phase45-scratch/
290
+ * tracer.annostore`, inside THIS repository's own gitignored scratch
291
+ * directory, timestamped 2026-09-10 -- its path and name identify it as
292
+ * Phase 45's own tracer/scratch fixture, not a real consuming project's
293
+ * store. Six real files, zero real stores.
294
+ * 2. `git log --oneline --diff-filter=A -- '*.annostore'` -- still empty.
295
+ * No `.annostore` has ever been added to this repository's tracked
296
+ * history.
297
+ * 3. Release tags versus when the store landed: `anno-store.ts` was added
298
+ * by `4c9cea3c` (2026-08-27). Since VERSION 4's check, ONE MORE tagged
299
+ * release has been cut -- `v0.9.0` (2026-09-10) -- joining `v0.7.0`
300
+ * (2026-09-01) and `v0.8.0` (2026-09-06). All THREE now postdate the
301
+ * store's landing and VERSION 3's original bump, so three tagged
302
+ * releases, not two, could in principle have shipped a working store to
303
+ * a real user. No result differs in KIND from VERSION 4's; the count of
304
+ * possible-exposure releases has grown by one, consistent with time
305
+ * having passed rather than with any new evidence of a real store.
306
+ *
307
+ * THE RE-AFFIRMATION IS MADE ON THE SAME BASIS VERSION 4 USED: the observed
308
+ * NULL RESULT (zero real, non-scratch stores found across six candidate
309
+ * files), not on a claim that no release could have shipped one. The new
310
+ * sixth file changes nothing about that null result -- it is this project's
311
+ * OWN scratch output, in a directory this project's own tooling writes to and
312
+ * that ships nothing.
313
+ *
314
+ * WHY A NEW TABLE WAS CHOSEN OVER A NULLABLE `excluded`/`exclusion_reason`
315
+ * COLUMN PAIR ON `anno_range`, recorded because it is the one part of this
316
+ * decision that is structural rather than stylistic: an exclusion's extent is
317
+ * chosen by the USER and has no reason to coincide with a typed range's
318
+ * boundaries, so a column on `anno_range` would force `retype()`'s carve to
319
+ * split, duplicate, or lose an exclusion's reason every time a range boundary
320
+ * moved underneath it -- `retype()` knows nothing about exclusions today and
321
+ * a column would force it to. A separate table keeps the user's own words
322
+ * independent of the store's typing churn entirely. This also happens to
323
+ * match the project's existing preference (`anno_evid_exec` at VERSION 4 over
324
+ * widening `anno_range`), but that precedent is the WEAKER of the two
325
+ * arguments -- the structural one above is the one that would still hold even
326
+ * if this project had no such precedent.
327
+ *
328
+ * THE REVERSAL CONDITION, RECORDED SO THIS DOES NOT QUIETLY HARDEN INTO
329
+ * PRECEDENT: a migration arm at version 6 is justified if a real, non-scratch
330
+ * `.annostore` is found in the field -- a user's own project, a bug report
331
+ * attaching one, or a support request -- or if a documented distribution
332
+ * channel is shown to have shipped a version-4 or version-5 store to users
333
+ * who have not yet upgraded past it. Absent that evidence the refusal stays
334
+ * the default.
335
+ */
336
+ export const SCHEMA_VERSION = 5;
337
+
338
+ /** The 6510's address space, inclusive at both ends. */
339
+ export const ADDRESS_MIN = 0x0000;
340
+ export const ADDRESS_MAX = 0xffff;
341
+
342
+ /** How many pre-mutation snapshots the store's own snapshot ring directory
343
+ * (`anno-store.ts`'s `snapshotDirFor()` -- a sibling named after the store
344
+ * FILE, not the fixed `<dir>/snapshots` version 1 used) may hold before the
345
+ * oldest is pruned. Declared here because the bound is a property of the
346
+ * store's format; the pruning that enforces it belongs to the revert surface
347
+ * (`STORE-04`). */
348
+ export const MAX_SNAPSHOT_REVISIONS = 32;
349
+
350
+ /**
351
+ * The twelve annotation data types, in the `anno_set_data_type` schema's own
352
+ * order and spelling (`anno-tools.ts:291-304`). This is the ONE place the
353
+ * vocabulary is written down -- see trap 1 in the module header.
354
+ *
355
+ * Both distinguishing axes are separately observable, which is what justifies
356
+ * four split members rather than two. ORIENTATION: the bytes
357
+ * `10 34 00 ff 08 12 c0 cf` resolve as `$0810 $1234 $c000 $cfff` under
358
+ * `lo_hi_address` and as `$1008 $3412 $00c0 $ffcf` under `hi_lo_address` -- a
359
+ * different resolved-target set. ADDRESS-VERSUS-WORD: the address forms
360
+ * produce cross-references and the word forms do not
361
+ * (`anno-tools.ts:305-313`).
362
+ */
363
+ export const DATA_TYPES = Object.freeze([
364
+ "code",
365
+ "byte",
366
+ "word",
367
+ "address",
368
+ "petscii",
369
+ "screencode",
370
+ "lo_hi_address",
371
+ "hi_lo_address",
372
+ "lo_hi_word",
373
+ "hi_lo_word",
374
+ "external_file",
375
+ "undefined",
376
+ ] as const);
377
+
378
+ /** One member of the frozen twelve. */
379
+ export type DataType = (typeof DATA_TYPES)[number];
380
+
381
+ /** The two split-table byte-order prefixes the schema's own spellings use.
382
+ * `SPLIT_DATA_TYPES` is derived through these -- never re-typed. */
383
+ const SPLIT_PREFIXES = Object.freeze(["lo_hi_", "hi_lo_"] as const);
384
+
385
+ /** A split-table layout: one of the four members whose spelling begins with a
386
+ * byte-order prefix. Derived from `DataType` by template-literal `Extract`, so
387
+ * this type cannot name a string the vocabulary does not contain. */
388
+ export type SplitDataType = Extract<DataType, `lo_hi_${string}` | `hi_lo_${string}`>;
389
+
390
+ /** True iff `value` is one of the four split-table layouts. The one predicate
391
+ * both `SPLIT_DATA_TYPES` and `assertRangeShape()`'s even-count rule use, so
392
+ * "counts as a split table" has exactly one definition. */
393
+ export function isSplitDataType(value: DataType): value is SplitDataType {
394
+ return SPLIT_PREFIXES.some((prefix) => value.startsWith(prefix));
395
+ }
396
+
397
+ /** The four split-table layouts, DERIVED from `DATA_TYPES` (trap 2). */
398
+ export const SPLIT_DATA_TYPES: readonly SplitDataType[] = Object.freeze(DATA_TYPES.filter(isSplitDataType));
399
+
400
+ /**
401
+ * One typed range as the store holds it. `endInclusive` is INCLUSIVE, matching
402
+ * the schema's own `end_address` sentence (`anno-tools.ts:288`), so a range's
403
+ * length is `endInclusive - start + 1` and a one-byte range has
404
+ * `start === endInclusive`. `bank` is reserved and interpreted by nothing:
405
+ * every row this store writes today has `bank` null.
406
+ */
407
+ export interface RangeRow {
408
+ id: number;
409
+ start: number;
410
+ endInclusive: number;
411
+ dataType: DataType;
412
+ bank: number | null;
413
+ }
414
+
415
+ /**
416
+ * The two comment placements, in the `anno_set_comment` schema's own order and
417
+ * spelling (`anno-tools.ts:270-274`). This is the ONE place this vocabulary is
418
+ * written down: `'line'` is a comment on its own line before the instruction,
419
+ * `'side'` is inline on the same line as the instruction.
420
+ */
421
+ export const COMMENT_TYPES = Object.freeze(["line", "side"] as const);
422
+
423
+ /** One of the two comment placements. */
424
+ export type CommentType = (typeof COMMENT_TYPES)[number];
425
+
426
+ /**
427
+ * The four label kinds. This is the ONE place this vocabulary is written down.
428
+ *
429
+ * THE CAPITALISATION IS A DECIDED ASYMMETRY, not an oversight. `DATA_TYPES` is
430
+ * lowercase because it is read off `anno_set_data_type`'s own schema and is
431
+ * named verbatim in `src/skills/c64-memory-mapping/SKILL.md`, so a re-spelling
432
+ * would break a shipped playbook. `LABEL_KINDS` is capitalised because its only
433
+ * mechanical consumer is the coverage census, which already spells it
434
+ * `"User"`/`"Auto"`/`"System"` at four sites (`anno-coverage.ts:206` for the
435
+ * doc form, `:1425-1435` for the acceptance, where `"Platform"` is taken as a
436
+ * synonym of `"System"`). Matching the consumer costs nothing; changing the
437
+ * consumer costs four edits inside a 2,292-line module and buys no criterion.
438
+ *
439
+ * `"Platform"` is a first-class member here rather than normalised away,
440
+ * because the census accepts both spellings and the store must be able to
441
+ * record which one a caller supplied.
442
+ */
443
+ export const LABEL_KINDS = Object.freeze(["User", "Auto", "System", "Platform"] as const);
444
+
445
+ /** One of the four label kinds. `"User"` and `"Auto"` are the two namespaces
446
+ * the store keeps apart -- with this field, never with a name pattern (trap 8). */
447
+ export type LabelKind = (typeof LABEL_KINDS)[number];
448
+
449
+ /**
450
+ * The four cross-reference access kinds. This is the ONE place this vocabulary
451
+ * is written down, and its provenance needs stating precisely so a later reader
452
+ * does not over-trust it: these four spellings are CITED from an external
453
+ * analyser's reference documentation, flowed through this project's own research
454
+ * notes, and fixed by `STORE-05`'s requirement text. They are NOT read from any
455
+ * code in this repository, and no comment may present them as verified project
456
+ * vocabulary.
457
+ *
458
+ * `COMPUTED_JUMP` is the member that motivates the whole table: a computed
459
+ * dispatch produces NO reference derivable from the bytes, so it is precisely
460
+ * the case that needs a hand-asserted row somewhere. See `anno-store.ts`'s
461
+ * `putXref`.
462
+ */
463
+ export const XREF_ACCESS_KINDS = Object.freeze(["READ", "WRITE", "READ_WRITE", "COMPUTED_JUMP"] as const);
464
+
465
+ /** One of the four cross-reference access kinds. */
466
+ export type XrefAccessKind = (typeof XREF_ACCESS_KINDS)[number];
467
+
468
+ /**
469
+ * The upper bound on one comment's text, in UTF-8 BYTES.
470
+ *
471
+ * Why a bound exists at all: the 64K address space caps a range naturally --
472
+ * there is no way to ask for a range longer than the machine -- but comment text
473
+ * caps nothing. Between an unvalidated caller argument (the transport validates
474
+ * nothing) and unbounded blob growth in the store file, this number is the only
475
+ * thing standing. 4096 bytes is roughly a screenful of prose per address, which
476
+ * is more than any annotation in the corpus this store was designed against.
477
+ */
478
+ export const MAX_COMMENT_BYTES = 4096;
479
+
480
+ /**
481
+ * Every 6502/6510 mnemonic, lowercase, DERIVED from the real 256-entry opcode
482
+ * table -- never hand-typed.
483
+ *
484
+ * Why derived matters here specifically: a hand-typed list is the documented
485
+ * 56-name legal set, and it MISSES every illegal-opcode mnemonic the decoder
486
+ * actually emits -- `jam`, `slo` and `lax` among them. A label named `slo`
487
+ * would then be accepted here and rejected by the assembler at export time,
488
+ * which is a failure a long way from its cause.
489
+ *
490
+ * A derivation function rather than an inline container, for two reasons. The
491
+ * module's own purity rule (trap 3) forbids a module-level mutable container,
492
+ * and `Object.freeze` on a `Set` is a NO-OP for its contents -- there is no
493
+ * "frozen Set" to declare. So the `ReadonlySet` type plus a single derivation
494
+ * site IS the contract: nothing in this module or the store writes to it.
495
+ */
496
+ function deriveMnemonicDenylist(): ReadonlySet<string> {
497
+ return new Set(OPCODES.map((entry) => entry.mnemonic.toLowerCase()));
498
+ }
499
+
500
+ export const MNEMONIC_DENYLIST: ReadonlySet<string> = deriveMnemonicDenylist();
501
+
502
+ /** One label as the store holds it. `bank` is reserved and interpreted by
503
+ * nothing; every row this store writes today has it null. */
504
+ export interface LabelRow {
505
+ id: number;
506
+ address: number;
507
+ name: string;
508
+ kind: LabelKind;
509
+ bank: number | null;
510
+ }
511
+
512
+ /** One comment as the store holds it. `text` never carries the `';'` prefix --
513
+ * the schema instructs callers to omit it and `assertCommentText()` refuses it. */
514
+ export interface CommentRow {
515
+ id: number;
516
+ address: number;
517
+ commentType: CommentType;
518
+ text: string;
519
+ bank: number | null;
520
+ }
521
+
522
+ /**
523
+ * One stored comment that a retype has just made FALSE (STORE-03).
524
+ *
525
+ * The caller needs all four facts to act on the report without a second query:
526
+ * WHERE the comment is, WHAT it says, WHICH grade fired, and WHICH data type
527
+ * contradicted it. A bare address list would send every recipient straight back
528
+ * to `listComments()`.
529
+ *
530
+ * `grade` is the bracket token verbatim, as `anno-confidence.ts` spells it --
531
+ * this store never writes a second spelling of one.
532
+ */
533
+ export interface ContradictedComment {
534
+ address: number;
535
+ commentType: CommentType;
536
+ text: string;
537
+ grade: string;
538
+ contradictedBy: DataType;
539
+ }
540
+
541
+ /**
542
+ * One entry-address pairing of a split table (STORE-03, CR-10).
543
+ *
544
+ * `pairs[i]` is the two ADDRESSES whose bytes form entry `i`, in table order:
545
+ * the first-half address and its second-half partner. `entryCount` is
546
+ * `pairs.length`, restated as a field so a caller can assert the count without
547
+ * reading the array -- the same shape convention `SplitTargets` uses.
548
+ */
549
+ export interface SplitEntryPairs {
550
+ entryCount: number;
551
+ pairs: readonly (readonly [number, number])[];
552
+ }
553
+
554
+ /**
555
+ * One surviving fragment of a split table that a partial overwrite left behind
556
+ * (CR-10). `entryPairs` is what that fragment reads NOW -- not what the addresses
557
+ * in it used to be paired with.
558
+ */
559
+ export interface SplitTableSurvivor {
560
+ start: number;
561
+ endInclusive: number;
562
+ entryCount: number;
563
+ entryPairs: readonly (readonly [number, number])[];
564
+ }
565
+
566
+ /**
567
+ * What one accepted partial overwrite of a split table COST, reported as data on
568
+ * a successful `setDataType()` result (STORE-03, CR-10). Documented in the same
569
+ * register as `ContradictedComment` above, and for the same reason: a caller
570
+ * needs every fact it would otherwise have to re-query for.
571
+ *
572
+ * WHY THIS RECORD EXISTS AT ALL. A split table's layout is
573
+ * first-half/second-half, so an entry's partner is a function of the row's START
574
+ * and its LENGTH (see `resolveSplitTargets()` below). Changing either end
575
+ * re-pairs EVERY entry: a surviving fragment of `m` entries pairs its own byte
576
+ * `j` with its own byte `m + j`, which matches an original pair only when
577
+ * `m == n` -- only when the fragment IS the whole row. **No proper fragment of a
578
+ * split table preserves a single entry pair, at any boundary, the midpoint
579
+ * included.** The surviving rows are still legal and still decode; they decode to
580
+ * DIFFERENT 16-bit values than the ones a human recorded. Preservation is
581
+ * therefore not recoverable by a cleverer boundary rule, and the only two honest
582
+ * answers are to refuse the edit or to disclose its cost. This record is the
583
+ * disclosure.
584
+ *
585
+ * BOTH OF THE NUMBERS THAT CONFLICTED are carried (28-08 P2): `entryPairsBefore`
586
+ * is what the table read before the write, each survivor's `entryPairs` is what
587
+ * that fragment reads after, and `preservedEntryPairs` is their intersection --
588
+ * COMPUTED by comparing the two sets, never assumed. It is empty today as a
589
+ * consequence of the layout; a computed field stays correct if a future layout
590
+ * changes that.
591
+ */
592
+ export interface SplitTableReinterpretation {
593
+ rowId: number;
594
+ rowStart: number;
595
+ rowEndInclusive: number;
596
+ dataType: SplitDataType;
597
+ entryCountBefore: number;
598
+ entryPairsBefore: readonly (readonly [number, number])[];
599
+ survivors: readonly SplitTableSurvivor[];
600
+ preservedEntryPairs: readonly (readonly [number, number])[];
601
+ summary: string;
602
+ }
603
+
604
+ /** One scope as the store holds it. Both ends are INCLUSIVE, matching the
605
+ * schema's own two sentences (`anno-tools.ts:322-331`). There is no name field
606
+ * and no nesting: the schema says nested scopes are unsupported, and the store
607
+ * must not invent a capability the surface it mirrors does not have. That last
608
+ * claim is ENFORCED rather than merely asserted -- `addScope()` in
609
+ * `anno-store.ts` refuses a nested or overlapping range with an
610
+ * `AnnoRangeShapeError` naming both scopes; see its doc comment for the rule. */
611
+ export interface ScopeRow {
612
+ id: number;
613
+ start: number;
614
+ endInclusive: number;
615
+ }
616
+
617
+ /** One user-requested exclusion as the store holds it, added at
618
+ * `SCHEMA_VERSION` 5 (`BUILD-07`). Both ends are INCLUSIVE, matching every
619
+ * other range-shaped row this store persists. There is no `bank` column,
620
+ * because an exclusion is a statement about a span of the SUBJECT PROGRAM the
621
+ * user asked to leave out of the rebuild -- not a memory view -- and there is
622
+ * no confidence, verdict, grade or severity column, because this row holds
623
+ * WHAT THE USER ASKED FOR and nothing the tool concluded about it. A
624
+ * judgement column here would be the exact "tool is the decider" shape
625
+ * `BUILD-05`'s 2026-09-10 rewording removed: the store answers "what did the
626
+ * user record", never "should this range be excluded". */
627
+ export interface ExcludedRangeRow {
628
+ id: number;
629
+ start: number;
630
+ endInclusive: number;
631
+ reason: string;
632
+ }
633
+
634
+ /** One project-local enum as the store holds it. `variants` is keyed by the
635
+ * numeric-string forms the schema names -- decimal, `0x`/`$` hex, `0b`/`%`
636
+ * binary -- and its values are variant names. */
637
+ export interface ProjectEnumRow {
638
+ id: number;
639
+ name: string;
640
+ variants: Readonly<Record<string, string>>;
641
+ description: string | null;
642
+ }
643
+
644
+ /**
645
+ * One enum usage as the store holds it: the association between ONE address
646
+ * and ONE project enum, added at `SCHEMA_VERSION` 3 (D-15).
647
+ *
648
+ * `enumId` IS WHAT THE STORE PERSISTS; `enumName` is resolved through the join
649
+ * at read time and is never a second on-disk copy of the name. A row that
650
+ * persisted the NAME would be re-pointed silently by `updateProjectEnum`'s
651
+ * rename -- the usage would follow whatever enum next took the old name -- and
652
+ * the disagreement would be invisible because both answers look authoritative.
653
+ * `bank` is the same reserved, uninterpreted column every other row type
654
+ * carries.
655
+ */
656
+ export interface EnumUsageRow {
657
+ id: number;
658
+ address: number;
659
+ enumId: number;
660
+ enumName: string;
661
+ bank: number | null;
662
+ }
663
+
664
+ /** One cross-reference as the store holds it. Only NON-DERIVABLE references
665
+ * live here -- see `anno-store.ts`'s `putXref` for why, and for the two
666
+ * requirement texts that look like they conflict and do not. */
667
+ export interface XrefRow {
668
+ id: number;
669
+ fromAddress: number;
670
+ toAddress: number;
671
+ accessKind: XrefAccessKind;
672
+ bank: number | null;
673
+ }
674
+
675
+ /**
676
+ * The three memory regions `memmapshow` reports an execute observation
677
+ * against, `SCHEMA_VERSION` 4 (EVID-01). This is the ONE place this
678
+ * vocabulary is written down.
679
+ *
680
+ * ALL THREE ARE INCLUDED DELIBERATELY, not for symmetry: `AccessFlags`
681
+ * (`textmon-memmap.ts`) carries `read`/`write`/`execute` as three
682
+ * INDEPENDENT bits per region, and dropping any one of the three regions
683
+ * here would discard a genuine positive observation the wire already
684
+ * reported -- `io.execute` is exactly as real a fact as `ram.execute`.
685
+ */
686
+ export const EVID_SOURCE_BANKS = Object.freeze(["ram", "rom", "io"] as const);
687
+
688
+ /** One of the three evidence source banks. */
689
+ export type EvidSourceBank = (typeof EVID_SOURCE_BANKS)[number];
690
+
691
+ /**
692
+ * The runtime evidence layer's own classification of an address, `SCHEMA_VERSION`
693
+ * 4 (EVID-04). This is a TYPE-LEVEL control, not a runtime check: the union has
694
+ * exactly two members and NO `"data"` member exists for a caller to return,
695
+ * mistakenly or otherwise.
696
+ *
697
+ * THIS IS THE SAME DISCIPLINE `textmon-memmap.ts` STATES FOR ITSELF, carried
698
+ * forward rather than re-derived: that module's own header forbids adding
699
+ * "a field, key, label or enum member anywhere in this module or its answer
700
+ * types that classifies an address as DATA on the strength of never having
701
+ * been observed" (`textmon-memmap.ts:32-38`). `BlockClass`'s three-valued
702
+ * union (`"code" | "data" | "undefined"`, `block-class.ts`) is the byte-derived
703
+ * classifier's own vocabulary and is DELIBERATELY NOT REUSED here: that union
704
+ * exists to name a byte-derived guess, and this one exists to name only what
705
+ * was actually witnessed executing. `"unobserved"` means exactly "no
706
+ * execution was ever recorded at this address" -- never "this address is
707
+ * data" -- and no code path in this layer may treat the two as
708
+ * interchangeable (see the ban on deriving `data` from absence, Pitfall 3 in
709
+ * this phase's research).
710
+ */
711
+ export type RuntimeExecClass = "code" | "unobserved";
712
+
713
+ /**
714
+ * One runtime-execution observation as the store holds it (`anno_evid_exec`,
715
+ * `SCHEMA_VERSION` 4). Keyed by the bare run-identity triple
716
+ * `(imageSha256, argvDigest, seed)` plus `address` plus `sourceBank` -- the
717
+ * `no-change` assumption-delta decision plan 43-01's live A/B selected
718
+ * (`docs/phase43-instrumentation-perturbation-ab.md`): there is no
719
+ * `run_class` discriminator column, because instrumentation was measured
720
+ * `no-perturbation` at anchor hit depths 10 and 50.
721
+ *
722
+ * A row here asserts exactly ONE fact: this address was observed executing,
723
+ * in this source bank, during this run. There is no `bank`-reserved column
724
+ * (unlike the annotation tables) and no nullable column -- every field is a
725
+ * fact this table is licensed to assert, or the row does not exist.
726
+ */
727
+ export interface EvidExecRow {
728
+ id: number;
729
+ imageSha256: string;
730
+ argvDigest: string;
731
+ seed: string;
732
+ address: number;
733
+ sourceBank: EvidSourceBank;
734
+ }
735
+
736
+ /**
737
+ * One distinct run identity's accumulated observation count, as
738
+ * `listObservedRuns()` reports it. `observationCount` is a COUNT, never a
739
+ * percentage or rate -- see `listObservedRuns`'s own doc comment in
740
+ * `anno-store.ts` for the denominator this count is a fraction of (EVID-04:
741
+ * a count with no denominator invites the reading "the rest is data").
742
+ */
743
+ export interface ObservedRunRow {
744
+ imageSha256: string;
745
+ argvDigest: string;
746
+ seed: string;
747
+ observationCount: number;
748
+ }
749
+
750
+ /** What `resolveSplitTargets()` returns: the entry count, the resolved 16-bit
751
+ * targets in table order, and whether this layout produces cross-references at
752
+ * all. Nothing here is ever written to disk. */
753
+ export interface SplitTargets {
754
+ entryCount: number;
755
+ targets: readonly number[];
756
+ producesXrefs: boolean;
757
+ }
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // The named error family. Follows `vice.ts`'s own constructor pattern: an
761
+ // `interface XErrorOptions`, plain public fields, `super(message, options)` for
762
+ // the field-free base and `super(message)` for the field-carrying subclasses
763
+ // (`vice.ts:245-292`, with `MachineRestartedError` as the field-carrying
764
+ // shape). Every subclass is an `AnnoStoreError` and therefore a `ViceError`, so
765
+ // one `catch` can take the whole family or any single member of it.
766
+ // ---------------------------------------------------------------------------
767
+
768
+ export interface AnnoStoreErrorOptions extends ViceErrorOptions {}
769
+
770
+ /** The family base: every refusal below is an instance of this. */
771
+ export class AnnoStoreError extends ViceError {
772
+ constructor(message: string, options: AnnoStoreErrorOptions = {}) {
773
+ super(message, options);
774
+ this.name = "AnnoStoreError";
775
+ }
776
+ }
777
+
778
+ export interface AnnoStoreCorruptErrorOptions {
779
+ path?: string;
780
+ }
781
+
782
+ /** The store file is not a store: no meta row, an unreadable meta row, a
783
+ * schema version this build does not speak, or a failed integrity check. Never
784
+ * thrown for a store that is merely EMPTY -- that distinction is the whole
785
+ * reason this error exists. */
786
+ export class AnnoStoreCorruptError extends AnnoStoreError {
787
+ path?: string;
788
+
789
+ constructor(message: string, { path }: AnnoStoreCorruptErrorOptions = {}) {
790
+ super(message);
791
+ this.name = "AnnoStoreCorruptError";
792
+ this.path = path;
793
+ }
794
+ }
795
+
796
+ export interface AnnoStoreStaleRevisionErrorOptions {
797
+ baseRevision?: number;
798
+ currentRevision?: number;
799
+ }
800
+
801
+ /** The write was refused because the on-disk revision is not the revision the
802
+ * caller based its edit on. Carries both numbers so the caller can say which
803
+ * two disagreed rather than "conflict". */
804
+ export class AnnoStoreStaleRevisionError extends AnnoStoreError {
805
+ baseRevision?: number;
806
+ currentRevision?: number;
807
+
808
+ constructor(message: string, { baseRevision, currentRevision }: AnnoStoreStaleRevisionErrorOptions = {}) {
809
+ super(message);
810
+ this.name = "AnnoStoreStaleRevisionError";
811
+ this.baseRevision = baseRevision;
812
+ this.currentRevision = currentRevision;
813
+ }
814
+ }
815
+
816
+ export interface AnnoRevisionArgumentErrorOptions {
817
+ /** The offending value, EXACTLY as it was supplied -- unconverted, so a
818
+ * caller can see that what it passed was a string. */
819
+ value?: unknown;
820
+ /** The parameter it was supplied for, so one class can serve more than one
821
+ * revision-shaped argument without the message having to say which. */
822
+ parameter?: string;
823
+ }
824
+
825
+ /**
826
+ * A REVISION-SHAPED ARGUMENT THAT IS NOT A REVISION: a numeric string, a
827
+ * negative number, a fraction, `NaN`. Thrown before any SQL runs and before any
828
+ * path is built, so nothing has been read and nothing has been written.
829
+ *
830
+ * WHY THIS IS NOT `AnnoStoreCorruptError`, WHICH IS THE WHOLE REASON THE CLASS
831
+ * EXISTS (WR-22). Until this class existed, `revertTo(handle, "0001")` matched
832
+ * revision 1's pointer row through SQLite's INTEGER affinity on a bound TEXT
833
+ * operand, while `snapshotPathFor` built `r0001.db` from the raw string -- so
834
+ * the two disagreed and the caller was told its snapshot was "not a readable
835
+ * annotation store". That is a CORRUPTION refusal produced by an ARGUMENT
836
+ * error, and `AnnoStoreCorruptError`'s own doc comment forbids exactly that
837
+ * confusion in as many words: "the annotations are gone" and "there are no
838
+ * annotations" must not read the same. Neither must "you passed the wrong
839
+ * thing".
840
+ *
841
+ * WHY IT IS NOT `AnnoStoreStaleRevisionError` EITHER. That class carries the
842
+ * TWO revisions that conflicted (28-08 P2's shape: never report a conflict
843
+ * without both of the numbers). An argument error has no second revision --
844
+ * nothing moved and nothing disagreed -- so reusing it would force the class to
845
+ * carry a number the caller never supplied and this code never had.
846
+ *
847
+ * A caller therefore tells the three apart BY CLASS, never by substring-matching
848
+ * a message.
849
+ */
850
+ export class AnnoRevisionArgumentError extends AnnoStoreError {
851
+ value?: unknown;
852
+ parameter?: string;
853
+
854
+ constructor(message: string, { value, parameter }: AnnoRevisionArgumentErrorOptions = {}) {
855
+ super(message);
856
+ this.name = "AnnoRevisionArgumentError";
857
+ this.value = value;
858
+ this.parameter = parameter;
859
+ }
860
+ }
861
+
862
+ export interface AnnoTypeErrorOptions {
863
+ dataType?: unknown;
864
+ validTypes?: readonly string[];
865
+ }
866
+
867
+ /** The `dataType` argument is not one of the frozen twelve. Carries the
868
+ * offending value AND the full valid list, so the caller never has to go
869
+ * looking for the vocabulary. */
870
+ export class AnnoTypeError extends AnnoStoreError {
871
+ dataType?: unknown;
872
+ validTypes?: readonly string[];
873
+
874
+ constructor(message: string, { dataType, validTypes }: AnnoTypeErrorOptions = {}) {
875
+ super(message);
876
+ this.name = "AnnoTypeError";
877
+ this.dataType = dataType;
878
+ this.validTypes = validTypes;
879
+ }
880
+ }
881
+
882
+ export interface AnnoRangeShapeErrorOptions {
883
+ start?: unknown;
884
+ endInclusive?: unknown;
885
+ }
886
+
887
+ /** The range's shape is impossible: an end outside the address space, an end
888
+ * below its start, or an odd byte count on a split-table layout. */
889
+ export class AnnoRangeShapeError extends AnnoStoreError {
890
+ start?: unknown;
891
+ endInclusive?: unknown;
892
+
893
+ constructor(message: string, { start, endInclusive }: AnnoRangeShapeErrorOptions = {}) {
894
+ super(message);
895
+ this.name = "AnnoRangeShapeError";
896
+ this.start = start;
897
+ this.endInclusive = endInclusive;
898
+ }
899
+ }
900
+
901
+ export interface AnnoSplitRemainderErrorOptions extends AnnoRangeShapeErrorOptions {
902
+ /** The overlapped row the split would fragment. */
903
+ rowId?: number;
904
+ rowStart?: number;
905
+ rowEndInclusive?: number;
906
+ /** The overlapped row's own type -- the type the remainder would inherit. */
907
+ dataType?: DataType;
908
+ /** The remainder the store refused to write. */
909
+ remainderStart?: number;
910
+ remainderEndInclusive?: number;
911
+ /** Which end of the overlapped row the illegal remainder is. */
912
+ side?: "head" | "tail";
913
+ }
914
+
915
+ /**
916
+ * A split-and-preserve remainder is not a legal shape for the type it would
917
+ * carry, so the whole retype is refused. Carries the overlapped row's identity
918
+ * AND the illegal remainder's, because a conflict reported with only one of the
919
+ * two numbers that conflicted is not a report.
920
+ *
921
+ * WHY IT EXTENDS `AnnoRangeShapeError` RATHER THAN `AnnoStoreError` DIRECTLY,
922
+ * and this is deliberate, not incidental: it really IS a shape refusal -- the
923
+ * SAME rule `assertRangeShape()` applies, asked about a range the store is about
924
+ * to write on its own initiative rather than one the caller supplied. Sitting it
925
+ * in the shape family means every existing `instanceof AnnoRangeShapeError`
926
+ * caller keeps working when the store starts refusing on this new path, and a
927
+ * caller that wants to distinguish the two asks for this class by name.
928
+ */
929
+ export class AnnoSplitRemainderError extends AnnoRangeShapeError {
930
+ rowId?: number;
931
+ rowStart?: number;
932
+ rowEndInclusive?: number;
933
+ dataType?: DataType;
934
+ remainderStart?: number;
935
+ remainderEndInclusive?: number;
936
+ side?: "head" | "tail";
937
+
938
+ constructor(message: string, options: AnnoSplitRemainderErrorOptions = {}) {
939
+ super(message, { start: options.start, endInclusive: options.endInclusive });
940
+ this.name = "AnnoSplitRemainderError";
941
+ this.rowId = options.rowId;
942
+ this.rowStart = options.rowStart;
943
+ this.rowEndInclusive = options.rowEndInclusive;
944
+ this.dataType = options.dataType;
945
+ this.remainderStart = options.remainderStart;
946
+ this.remainderEndInclusive = options.remainderEndInclusive;
947
+ this.side = options.side;
948
+ }
949
+ }
950
+
951
+ export interface AnnoAddressErrorOptions {
952
+ input?: unknown;
953
+ what?: string;
954
+ }
955
+
956
+ /** The value is not an address this store will accept. Carries the offending
957
+ * input and the name of the field it was supplied for. */
958
+ export class AnnoAddressError extends AnnoStoreError {
959
+ input?: unknown;
960
+ what?: string;
961
+
962
+ constructor(message: string, { input, what }: AnnoAddressErrorOptions = {}) {
963
+ super(message);
964
+ this.name = "AnnoAddressError";
965
+ this.input = input;
966
+ this.what = what;
967
+ }
968
+ }
969
+
970
+ export interface AnnoStorePathErrorOptions {
971
+ path?: string;
972
+ workspaceRoot?: string;
973
+ }
974
+
975
+ /** The store path resolves outside the workspace root it was confined to. */
976
+ export class AnnoStorePathError extends AnnoStoreError {
977
+ path?: string;
978
+ workspaceRoot?: string;
979
+
980
+ constructor(message: string, { path, workspaceRoot }: AnnoStorePathErrorOptions = {}) {
981
+ super(message);
982
+ this.name = "AnnoStorePathError";
983
+ this.path = path;
984
+ this.workspaceRoot = workspaceRoot;
985
+ }
986
+ }
987
+
988
+ export interface AnnoLabelErrorOptions {
989
+ /** The offending identifier, verbatim -- never a sanitised form of it.
990
+ *
991
+ * THE FIELD IS `identifier`, NOT `name`, and that is load-bearing rather than
992
+ * a naming preference: `name` is `Error.prototype.name`, which every
993
+ * constructor in this family assigns the class name to. A public `name` field
994
+ * would overwrite `"AnnoLabelError"` with the offending label, so a `catch`
995
+ * block asking which error it caught would be told the answer to a different
996
+ * question. */
997
+ identifier?: string;
998
+ /** Which rule fired, in words -- the illegal-character rule, the mnemonic
999
+ * denylist, or the collision. */
1000
+ reason?: string;
1001
+ /** For a collision: the address the name is already bound to. */
1002
+ existingAddress?: number;
1003
+ /** For a collision: the address the caller asked to bind it to. */
1004
+ requestedAddress?: number;
1005
+ }
1006
+
1007
+ /**
1008
+ * An identifier -- a label name or a project-enum name -- is refused. Never
1009
+ * sanitised, never substituted, never rebound: see trap 7 for the concrete
1010
+ * silent-merge hazard that makes refusal the only safe answer.
1011
+ */
1012
+ export class AnnoLabelError extends AnnoStoreError {
1013
+ identifier?: string;
1014
+ reason?: string;
1015
+ existingAddress?: number;
1016
+ requestedAddress?: number;
1017
+
1018
+ constructor(message: string, { identifier, reason, existingAddress, requestedAddress }: AnnoLabelErrorOptions = {}) {
1019
+ super(message);
1020
+ this.name = "AnnoLabelError";
1021
+ this.identifier = identifier;
1022
+ this.reason = reason;
1023
+ this.existingAddress = existingAddress;
1024
+ this.requestedAddress = requestedAddress;
1025
+ }
1026
+ }
1027
+
1028
+ export interface AnnoCommentErrorOptions {
1029
+ /** Which rule fired: not a string, an embedded newline, the semicolon
1030
+ * prefix, or the byte bound. */
1031
+ reason?: string;
1032
+ /** The text's UTF-8 byte length, so a caller can see how far over it was
1033
+ * rather than only that it was over. */
1034
+ byteLength?: number;
1035
+ }
1036
+
1037
+ /**
1038
+ * Comment (or description) text is refused: not a string, carrying an embedded
1039
+ * line break, over `MAX_COMMENT_BYTES` in UTF-8 bytes, or carrying the `';'`
1040
+ * prefix the schema instructs callers to omit.
1041
+ * A separate class from `AnnoLabelError` because neither of its fields fits --
1042
+ * comment text is not an identifier and has no address to collide at.
1043
+ */
1044
+ export class AnnoCommentError extends AnnoStoreError {
1045
+ reason?: string;
1046
+ byteLength?: number;
1047
+
1048
+ constructor(message: string, { reason, byteLength }: AnnoCommentErrorOptions = {}) {
1049
+ super(message);
1050
+ this.name = "AnnoCommentError";
1051
+ this.reason = reason;
1052
+ this.byteLength = byteLength;
1053
+ }
1054
+ }
1055
+
1056
+ export interface AnnoCommentGradeErrorOptions {
1057
+ /** The offending comment text, verbatim. */
1058
+ comment?: string;
1059
+ /** The original refusal this one wraps, kept so the diagnostic chain is not
1060
+ * broken by the wrap. */
1061
+ cause?: unknown;
1062
+ }
1063
+
1064
+ /**
1065
+ * A stored comment carries a leading bracket token that is not one of the five
1066
+ * confidence grades, so the store cannot say whether a retype contradicts it.
1067
+ *
1068
+ * THIS CLASS EXISTS TO WRAP, AND THE WRAP IS THE DECISION. The parser that
1069
+ * detects the malformed token throws a class extending `Error` DIRECTLY, not
1070
+ * `ViceError` -- so a caller writing a single
1071
+ * `catch (e) { if (e instanceof ViceError) ... }` at the store boundary would
1072
+ * miss it, and a real refusal would escape as an unhandled rejection. The store
1073
+ * catches it and rethrows this instead, so everything the store throws is a
1074
+ * `ViceError`.
1075
+ *
1076
+ * THE COST, STATED RATHER THAN LEFT TO BE DISCOVERED: the original class is no
1077
+ * longer visible to `instanceof` at the store boundary. That is why the original
1078
+ * message is preserved VERBATIM inside this one and the original error rides on
1079
+ * `cause` -- nothing is lost from the diagnostic, only from the type. The
1080
+ * alternative -- rethrow unchanged and document the asymmetry -- was rejected
1081
+ * because it puts the burden on every future caller instead of on this one site.
1082
+ *
1083
+ * It is NEVER correct to swallow the original and treat the comment as ungraded:
1084
+ * that would quietly exempt a malformed comment from contradiction reporting,
1085
+ * which is the same silent un-documenting `STORE-03` exists to prevent.
1086
+ */
1087
+ export class AnnoCommentGradeError extends AnnoStoreError {
1088
+ comment?: string;
1089
+ cause?: unknown;
1090
+
1091
+ constructor(message: string, { comment, cause }: AnnoCommentGradeErrorOptions = {}) {
1092
+ super(message);
1093
+ this.name = "AnnoCommentGradeError";
1094
+ this.comment = comment;
1095
+ this.cause = cause;
1096
+ }
1097
+ }
1098
+
1099
+ // ---------------------------------------------------------------------------
1100
+ // Validators. Each one THROWS on refusal (trap 6) and returns the narrowed
1101
+ // value on acceptance.
1102
+ // ---------------------------------------------------------------------------
1103
+
1104
+ /** Narrows an unvalidated argument to a `DataType`, or throws `AnnoTypeError`
1105
+ * carrying the offending value and the full valid list. */
1106
+ export function assertDataType(value: unknown): DataType {
1107
+ if (typeof value === "string" && (DATA_TYPES as readonly string[]).includes(value)) {
1108
+ return value as DataType;
1109
+ }
1110
+ throw new AnnoTypeError(
1111
+ `data type ${JSON.stringify(value)} is not one of the ${DATA_TYPES.length} annotation data types -- expected one of: ${DATA_TYPES.join(", ")}`,
1112
+ { dataType: value, validTypes: [...DATA_TYPES] },
1113
+ );
1114
+ }
1115
+
1116
+ /**
1117
+ * Refuses an impossible range shape. Three separate refusals, each with its
1118
+ * own message: an end outside `ADDRESS_MIN..ADDRESS_MAX`, an `endInclusive`
1119
+ * below `start`, and an ODD byte count on a split-table layout -- the schema's
1120
+ * own "even count required" rule (`anno-tools.ts:305-313`), which is a
1121
+ * validation rule about the DATA rather than a property of the type, which is
1122
+ * why it is checked here and not encoded in `DataType`.
1123
+ */
1124
+ export function assertRangeShape(start: number, endInclusive: number, dataType: DataType): void {
1125
+ const ends: readonly (readonly [string, number])[] = [
1126
+ ["start", start],
1127
+ ["endInclusive", endInclusive],
1128
+ ];
1129
+ for (const [what, value] of ends) {
1130
+ if (!Number.isInteger(value) || value < ADDRESS_MIN || value > ADDRESS_MAX) {
1131
+ throw new AnnoRangeShapeError(
1132
+ `${what} ${String(value)} is outside the address space -- expected an integer ${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff)`,
1133
+ { start, endInclusive },
1134
+ );
1135
+ }
1136
+ }
1137
+ if (endInclusive < start) {
1138
+ throw new AnnoRangeShapeError(
1139
+ `endInclusive ${endInclusive} is below start ${start} -- both ends are INCLUSIVE, so a one-byte range has start === endInclusive`,
1140
+ { start, endInclusive },
1141
+ );
1142
+ }
1143
+ if (isSplitDataType(dataType) && (endInclusive - start + 1) % 2 !== 0) {
1144
+ throw new AnnoRangeShapeError(
1145
+ `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`,
1146
+ { start, endInclusive },
1147
+ );
1148
+ }
1149
+ }
1150
+
1151
+ /**
1152
+ * Parses `input` into a `ADDRESS_MIN..ADDRESS_MAX` address. Accepted forms: a
1153
+ * JS integer in range, a `"$hex"` string, and a `"0x"`/`"0X"` string.
1154
+ * Surrounding whitespace is trimmed.
1155
+ *
1156
+ * An UNPREFIXED numeric string such as `"1024"` is REFUSED -- see trap 4 in
1157
+ * the module header for the divergence from `stock-address.ts` and the reason
1158
+ * for it.
1159
+ */
1160
+ export function parseStoreAddress(input: unknown, opts: { what?: string } = {}): number {
1161
+ const what = opts.what ?? "address";
1162
+
1163
+ if (typeof input === "number") {
1164
+ if (!Number.isInteger(input) || input < ADDRESS_MIN || input > ADDRESS_MAX) {
1165
+ throw new AnnoAddressError(
1166
+ `${what}: ${String(input)} is out of range -- expected an integer ${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff)`,
1167
+ { input, what },
1168
+ );
1169
+ }
1170
+ return input;
1171
+ }
1172
+
1173
+ if (typeof input === "string") {
1174
+ const trimmed = input.trim();
1175
+ const hexPart = trimmed.startsWith("$") ? trimmed.slice(1) : /^0[xX]/.test(trimmed) ? trimmed.slice(2) : null;
1176
+ if (hexPart !== null) {
1177
+ if (hexPart === "" || !/^[0-9a-fA-F]+$/.test(hexPart)) {
1178
+ throw new AnnoAddressError(
1179
+ `${what}: "${trimmed}" is not a valid hex address -- expected "$" or "0x" followed by hex digits, e.g. "$0810" or "0x0810"`,
1180
+ { input, what },
1181
+ );
1182
+ }
1183
+ const value = parseInt(hexPart, 16);
1184
+ if (value < ADDRESS_MIN || value > ADDRESS_MAX) {
1185
+ throw new AnnoAddressError(
1186
+ `${what}: "${trimmed}" (${value}) is out of range -- expected ${ADDRESS_MIN}..${ADDRESS_MAX} ($0000-$ffff)`,
1187
+ { input, what },
1188
+ );
1189
+ }
1190
+ return value;
1191
+ }
1192
+ }
1193
+
1194
+ throw new AnnoAddressError(
1195
+ `${what}: ${JSON.stringify(input)} is not an address this store accepts -- expected an integer, a "$hex" string or a "0x" string. ` +
1196
+ `An unprefixed numeric string is refused on purpose: a mis-based address written into the store is persistent and silently wrong, ` +
1197
+ `so the base is required rather than assumed.`,
1198
+ { input, what },
1199
+ );
1200
+ }
1201
+
1202
+ /**
1203
+ * The maximum number of DANGLING-symlink hops `realpathOfNearestExisting` will
1204
+ * take before refusing. 40 is not an arbitrary comfort number: it is Linux's own
1205
+ * `MAXSYMLINKS`, so a chain this walk refuses is a chain the kernel would refuse
1206
+ * too, and the two disagree about no input.
1207
+ *
1208
+ * The bound exists because a CYCLE (`a -> b`, `b -> a`) is otherwise an infinite
1209
+ * loop inside a function whose input arrives UNVALIDATED from the transport (see
1210
+ * this module's header). `realpathSync` gets `ELOOP` from the kernel for free;
1211
+ * the manual hop below is ours, so the bound has to be ours too.
1212
+ */
1213
+ const MAX_SYMLINK_HOPS = 40;
1214
+
1215
+ /**
1216
+ * Does the path ENTRY `p` exist -- that is, does this NAME exist in its
1217
+ * directory?
1218
+ *
1219
+ * THIS IS THE WHOLE OF `CR-04`, in two sentences. `existsSync` answers a
1220
+ * different question: "does this path RESOLVE to something?", which follows
1221
+ * symbolic links and therefore reports `false` for a dangling one. `lstat`
1222
+ * answers "does this NAME exist?", which does not follow the link. The two
1223
+ * answers differ for exactly one input class -- a symlink whose target is absent
1224
+ * -- and confinement has always needed the second question while asking the
1225
+ * first.
1226
+ *
1227
+ * `throwIfNoEntry: false` makes the ABSENT case a value rather than an
1228
+ * exception, so the caller has one branch instead of a `try` around a
1229
+ * predicate. That option suppresses `ENOENT` AND NOTHING ELSE, which is the
1230
+ * whole of `WR-12`.
1231
+ *
1232
+ * REVERSED 2026-08-28, and the reversal is the record rather than a deletion
1233
+ * (this module's header discipline, 28-07 P3). The premise that was RIGHT and
1234
+ * stays: the walk must stop at a path ENTRY, and only `lstat` can see one --
1235
+ * `existsSync` follows links and cannot. The sentence that became FALSE: that
1236
+ * swapping `existsSync` for `lstatSync` changed only which QUESTION was asked.
1237
+ * It also changed what happens when the question cannot be answered.
1238
+ * `existsSync` swallowed every error and returned `false`; `lstatSync` with
1239
+ * `throwIfNoEntry: false` swallows `ENOENT` only. So three ORDINARY caller
1240
+ * inputs regressed from a named `AnnoStorePathError` to a bare `Error`
1241
+ * escaping the `ViceError` family entirely -- measured on Node 22.22, at the
1242
+ * predicate AND through `openStore`, both:
1243
+ *
1244
+ * * `ENOTDIR` -- an ancestor that is a regular file (`<ws>/notes.txt/p.annostore`),
1245
+ * which needs no symlink, no privilege and nothing pre-existing;
1246
+ * * `EACCES` -- an unreadable ancestor directory;
1247
+ * * `ELOOP` -- a symlink cycle in an ANCESTOR position, where the kernel
1248
+ * refuses at `lstat` before the manual hop counter below ever runs.
1249
+ *
1250
+ * `28-REVIEW.md` WR-12 has the before/after transcript. The `try` restores the
1251
+ * family WITHOUT restoring the old blindness: the absent case is still a value
1252
+ * and still one branch, and everything else is a decision naming both the entry
1253
+ * the walk stopped on and the path being confined.
1254
+ */
1255
+ function pathEntryExists(p: string, resolved: string): boolean {
1256
+ try {
1257
+ return lstatSync(p, { throwIfNoEntry: false }) !== undefined;
1258
+ } catch (e) {
1259
+ throw new AnnoStorePathError(
1260
+ `cannot stat ${JSON.stringify(p)} while confining ${JSON.stringify(resolved)} (${(e as Error).message})`,
1261
+ { path: resolved },
1262
+ );
1263
+ }
1264
+ }
1265
+
1266
+ /**
1267
+ * Returns the REAL absolute path of `p`, resolved through the deepest ancestor
1268
+ * whose path ENTRY exists on disk, with the non-existent tail re-joined after
1269
+ * it.
1270
+ *
1271
+ * WHY THE WALK. The common case is a store file that does NOT exist yet -- the
1272
+ * store is created on first open -- so a bare `realpathSync(p)` would throw
1273
+ * `ENOENT` on exactly the path this module most needs to check. The walk stops
1274
+ * at the first existing ancestor, resolves THAT, and re-joins the remaining
1275
+ * segments afterwards, so the answer is the path the filesystem will actually
1276
+ * use once the tail is created.
1277
+ *
1278
+ * WHY THE TAIL IS RE-JOINED AFTER the real ancestor rather than before: the
1279
+ * symlinks that matter are the ones already on disk, and they are all in the
1280
+ * existing prefix. Re-joining after resolution is what makes the returned value
1281
+ * the location a write lands at, which is the only thing confinement can
1282
+ * honestly compare.
1283
+ *
1284
+ * REVERSED 2026-08-28, and the reversal is the record rather than a deletion
1285
+ * (this module's header discipline, 28-07 P3). The premise that was RIGHT and
1286
+ * stays: the deepest EXISTING ancestor is the correct stopping point, and the
1287
+ * tail belongs after it. The sentence that became FALSE: this walk used to stop
1288
+ * at "the first path that `existsSync` reports present". `existsSync` FOLLOWS
1289
+ * links, so it reports `false` for a dangling one, and the walk stepped straight
1290
+ * PAST the link instead of stopping at it -- after which the confinement
1291
+ * compared a path the filesystem would later resolve somewhere else entirely.
1292
+ * Reproduced against committed code at `a8187d2`: a dangling leaf link written
1293
+ * `../outside/p.annostore` was ACCEPTED (`A) confinement ACCEPTED, returned:
1294
+ * /tmp/annosym-XXXX/ws/p.annostore`) and `openStore` created the store file
1295
+ * OUTSIDE the workspace root (`A) file created OUTSIDE workspace: true`);
1296
+ * separately, a dangling DIRECTORY link was accepted at the predicate
1297
+ * (`B dangling dir -> ACCEPTED`). `28-VERIFICATION.md` gap 2 / `28-REVIEW.md`
1298
+ * CR-04. The walk now stops on `pathEntryExists`, which is `lstat` and does not
1299
+ * follow the link.
1300
+ *
1301
+ * THE DANGLING STOPPING ENTRY IS RESOLVED BY HAND, because nothing else will:
1302
+ * `realpathSync` cannot resolve a chain whose end does not exist. The hop reads
1303
+ * the link and resolves its target AGAINST THE LINK'S OWN DIRECTORY, never
1304
+ * against the process cwd -- a relative target (`../outside/x`) is the common
1305
+ * form, and resolving it against the cwd is the one way a naive `readlinkSync`
1306
+ * fix gets this wrong. `tail` is deliberately NOT touched by a hop: the link's
1307
+ * own name is CONSUMED by the hop, and the segments below it still hang below
1308
+ * whatever the link resolves to. After the hop the loop re-enters the same walk,
1309
+ * so a CHAIN of dangling links is this one case repeated rather than a new one,
1310
+ * and a hop that lands on a LIVE entry falls through to `realpathSync`, which
1311
+ * resolves the rest of the chain itself. There is no third state.
1312
+ *
1313
+ * Nothing exists anywhere on the path (the walk reached the filesystem root):
1314
+ * there is nothing to resolve, so the answer is built from `current` and `tail`.
1315
+ * Those two are equal to the pre-walk `resolved` when no hop has happened, and
1316
+ * AFTER a hop `resolved` describes a path the walk is no longer on -- returning
1317
+ * it there would be a stale answer about the wrong location.
1318
+ *
1319
+ * Every `realpathSync`, `lstatSync` and `readlinkSync` failure is rethrown as
1320
+ * `AnnoStorePathError` naming the path, so a permission error resolving an
1321
+ * ancestor stays inside the `ViceError` family instead of escaping as a bare
1322
+ * `Error`.
1323
+ */
1324
+ function realpathOfNearestExisting(p: string): string {
1325
+ const resolved = resolve(p);
1326
+ const tail: string[] = [];
1327
+ let current = resolved;
1328
+ let hops = 0;
1329
+
1330
+ for (;;) {
1331
+ let reachedFilesystemRoot = false;
1332
+ while (!pathEntryExists(current, resolved)) {
1333
+ const parent = dirname(current);
1334
+ if (parent === current) {
1335
+ reachedFilesystemRoot = true;
1336
+ break;
1337
+ }
1338
+ tail.unshift(basename(current));
1339
+ current = parent;
1340
+ }
1341
+ if (reachedFilesystemRoot) {
1342
+ return tail.length === 0 ? current : join(current, ...tail);
1343
+ }
1344
+
1345
+ // The stopping ENTRY exists. Is it a symlink whose target does not? That is
1346
+ // the one class `existsSync` could not see, and the only one needing a hop.
1347
+ let stoppedAtDanglingLink: boolean;
1348
+ try {
1349
+ stoppedAtDanglingLink = lstatSync(current).isSymbolicLink() && !existsSync(current);
1350
+ } catch (e) {
1351
+ throw new AnnoStorePathError(
1352
+ `cannot stat ${JSON.stringify(current)} while confining ${JSON.stringify(resolved)} (${(e as Error).message})`,
1353
+ { path: resolved },
1354
+ );
1355
+ }
1356
+
1357
+ if (stoppedAtDanglingLink) {
1358
+ hops += 1;
1359
+ if (hops > MAX_SYMLINK_HOPS) {
1360
+ throw new AnnoStorePathError(
1361
+ `cannot resolve ${JSON.stringify(resolved)}: more than ${MAX_SYMLINK_HOPS} symbolic-link hops while resolving ` +
1362
+ `${JSON.stringify(current)} -- a symlink cycle or an over-long chain, refused rather than followed`,
1363
+ { path: resolved },
1364
+ );
1365
+ }
1366
+ let link: string;
1367
+ try {
1368
+ link = readlinkSync(current);
1369
+ } catch (e) {
1370
+ throw new AnnoStorePathError(
1371
+ `cannot read the symbolic link ${JSON.stringify(current)} while confining ${JSON.stringify(resolved)} (${(e as Error).message})`,
1372
+ { path: resolved },
1373
+ );
1374
+ }
1375
+ // Against the LINK'S directory, never the process cwd.
1376
+ current = resolve(dirname(current), link);
1377
+ continue;
1378
+ }
1379
+
1380
+ let real: string;
1381
+ try {
1382
+ real = realpathSync(current);
1383
+ } catch (e) {
1384
+ throw new AnnoStorePathError(
1385
+ `cannot resolve the real path of ${JSON.stringify(current)} while confining ${JSON.stringify(resolved)} (${(e as Error).message})`,
1386
+ { path: resolved },
1387
+ );
1388
+ }
1389
+ return tail.length === 0 ? real : join(real, ...tail);
1390
+ }
1391
+ }
1392
+
1393
+ /**
1394
+ * Resolves `path` and `workspaceRoot` to REAL paths and returns the resolved
1395
+ * absolute store path, or throws `AnnoStorePathError` when the store path is
1396
+ * not the workspace root itself or something beneath it. Boundary-safe: the
1397
+ * comparison appends the platform separator rather than testing a bare string
1398
+ * prefix, so a sibling directory whose name merely STARTS with the root's name
1399
+ * is refused.
1400
+ *
1401
+ * BOTH SIDES GO THROUGH `realpathOfNearestExisting`, and that is the
1402
+ * load-bearing detail rather than a symmetry preference:
1403
+ *
1404
+ * * The PATH must be a real path, because `resolve()` normalises `..` but
1405
+ * does NOT follow symbolic links. The pure-string version accepted a
1406
+ * symlinked subdirectory inside the workspace and the store file was
1407
+ * created outside the root (`28-REVIEW.md` CR-03, reproduced by the phase
1408
+ * verifier). A confinement check has to compare what the filesystem will
1409
+ * actually do.
1410
+ * * The ROOT must go through the SAME walk, for two independent reasons. A
1411
+ * workspace root that does not exist is a legitimate input -- the pinned
1412
+ * case in `anno-store.test.ts` passes `<dir>/nested`, which is never
1413
+ * created -- and a bare `realpathSync` on it throws a raw `ENOENT`,
1414
+ * replacing a clean named refusal with a non-family error. And resolving
1415
+ * only ONE side makes every path look foreign whenever the root itself is
1416
+ * reached through a symlink, which is the common case on hosts where the
1417
+ * temp directory is a link.
1418
+ *
1419
+ * BEHAVIOURAL CONSEQUENCE, intended and tested: because this returns the real
1420
+ * path, a store reached through a symlink that points INSIDE the workspace is
1421
+ * FOLLOWED, and the file lands at the link's real location rather than through
1422
+ * the link. The alternative -- refusing every symlink -- would refuse
1423
+ * legitimate layouts, and is the over-broad fix that
1424
+ * `anno-confinement.test.ts` discriminates against: a control that only ever
1425
+ * refuses is indistinguishable from one that works.
1426
+ *
1427
+ * This is the ONE export in this module that is a function of its arguments AND
1428
+ * the filesystem; see the narrowed trap 3 in the header.
1429
+ *
1430
+ * The path is deliberately NOT routed through either host/container
1431
+ * path-translation seam -- see trap 7 in `anno-store.ts`'s header for what a
1432
+ * translated store path would do. `node:fs` is a Node builtin, not a seam, and
1433
+ * `hostpath-consumers.test.ts`'s closed consumer set still excludes this
1434
+ * module.
1435
+ */
1436
+ export function storePathWithinWorkspace(path: string, workspaceRoot: string): string {
1437
+ const resolvedRoot = realpathOfNearestExisting(workspaceRoot);
1438
+ const resolvedPath = realpathOfNearestExisting(path);
1439
+ if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(resolvedRoot + sep)) {
1440
+ throw new AnnoStorePathError(
1441
+ `store path ${JSON.stringify(resolvedPath)} is outside the workspace root ${JSON.stringify(resolvedRoot)} -- refusing to open a store there`,
1442
+ { path: resolvedPath, workspaceRoot: resolvedRoot },
1443
+ );
1444
+ }
1445
+ return resolvedPath;
1446
+ }
1447
+
1448
+ /**
1449
+ * Returns the location of `path` RELATIVE to `workspaceRoot`, spelled with
1450
+ * POSIX `/` separators, or `"."` when the two resolve to the same directory.
1451
+ * Throws `AnnoStorePathError` -- the same class `storePathWithinWorkspace()`
1452
+ * throws -- when the spelling would leave the root.
1453
+ *
1454
+ * WHY THIS IS A SEAM RATHER THAN AN INLINE `relative()` AT ITS ONE CALL SITE.
1455
+ * The spelling this returns goes into a **compared** artifact: the memory
1456
+ * map's banner is re-rendered and diffed BYTE FOR BYTE by
1457
+ * `checkRenderedMemoryMap()`. The defect it closes (`CR-01`, gap 1 in
1458
+ * `.planning/phases/29-the-mcp-surface/29-VERIFICATION.md`) is that machine
1459
+ * identity leaked into that content comparison: the banner recorded the
1460
+ * absolute realpaths, so the same store, the same sidecar and the same
1461
+ * rendered file reported `drifted` as soon as the checkout sat at a different
1462
+ * absolute path -- while the artifact's own `render_digest`, which covers
1463
+ * content and not paths, printed IDENTICAL in both trees. The digest and the
1464
+ * verdict disagreed by construction. "Which spelling goes into the compared
1465
+ * bytes" therefore needs exactly one definition, and this is it; an inlined
1466
+ * `relative()` at the call site is how it grows a second one.
1467
+ *
1468
+ * BOTH SIDES GO THROUGH `realpathOfNearestExisting`, for the same reason
1469
+ * `storePathWithinWorkspace()` above does, and the two must not drift apart.
1470
+ * The store path arrives here having already been through that seam (the CLI
1471
+ * confines it), while the workspace root arrives from `repoRoot()` and is NOT
1472
+ * necessarily a realpath. Resolving only one side would make a legitimately
1473
+ * in-workspace store look foreign whenever the root is reached through a
1474
+ * symlink -- the common case on hosts where the temp directory is a link --
1475
+ * and would silently produce a `../…` spelling, re-introducing the very
1476
+ * machine dependence this function exists to remove, wearing a new spelling.
1477
+ *
1478
+ * THE ESCAPE CASE THROWS DELIBERATELY. Returning `"../../tmp/xyz/game.annostore"`
1479
+ * would be machine identity again: the number of `..` hops encodes where the
1480
+ * checkout sits. A banner cannot record a location outside the workspace root
1481
+ * at all, so this makes that unrepresentable rather than merely unlikely. A
1482
+ * caller that wants to record such a path has a confinement problem, not a
1483
+ * spelling problem.
1484
+ *
1485
+ * This is NOT a confinement check and must not be read as one. It computes a
1486
+ * spelling and refuses one it cannot spell; `storePathWithinWorkspace()` above
1487
+ * is the seam that decides whether a path may be opened at all.
1488
+ */
1489
+ export function workspaceRelativePath(path: string, workspaceRoot: string): string {
1490
+ const resolvedRoot = realpathOfNearestExisting(workspaceRoot);
1491
+ const resolvedPath = realpathOfNearestExisting(path);
1492
+ if (resolvedPath === resolvedRoot) return ".";
1493
+
1494
+ const spelling = relative(resolvedRoot, resolvedPath).split(sep).join("/");
1495
+ if (spelling === "" || isAbsolute(spelling) || spelling === ".." || spelling.startsWith("../")) {
1496
+ throw new AnnoStorePathError(
1497
+ `path ${JSON.stringify(resolvedPath)} is outside the workspace root ${JSON.stringify(resolvedRoot)} -- ` +
1498
+ "refusing to record a location outside the workspace root, because a relative spelling that escapes the root " +
1499
+ "encodes where the checkout sits and is the same machine-dependence under a different spelling",
1500
+ { path: resolvedPath, workspaceRoot: resolvedRoot },
1501
+ );
1502
+ }
1503
+ return spelling;
1504
+ }
1505
+
1506
+ /**
1507
+ * The ONE vocabulary-membership check. Every `assert*` over a frozen string
1508
+ * vocabulary routes through here, so "is a member" has one definition and the
1509
+ * refusal message has one shape: the offending value, then the full valid list.
1510
+ *
1511
+ * `AnnoTypeError`'s `dataType` field carries the offending value whatever the
1512
+ * vocabulary was -- the field was named for the first vocabulary that needed it
1513
+ * and is deliberately not renamed, because renaming it would be a breaking
1514
+ * change to an error field for a cosmetic gain.
1515
+ */
1516
+ function assertMember<T extends string>(value: unknown, members: readonly T[], what: string): T {
1517
+ if (typeof value === "string" && (members as readonly string[]).includes(value)) {
1518
+ return value as T;
1519
+ }
1520
+ throw new AnnoTypeError(
1521
+ `${what} ${JSON.stringify(value)} is not one of the ${members.length} valid values -- expected one of: ${members.join(", ")}`,
1522
+ { dataType: value, validTypes: [...members] },
1523
+ );
1524
+ }
1525
+
1526
+ /** Narrows an unvalidated argument to a `CommentType`, or throws
1527
+ * `AnnoTypeError` carrying the offending value and both valid members. */
1528
+ export function assertCommentType(value: unknown): CommentType {
1529
+ return assertMember(value, COMMENT_TYPES, "comment type");
1530
+ }
1531
+
1532
+ /** Narrows an unvalidated argument to a `LabelKind`, or throws `AnnoTypeError`
1533
+ * carrying the offending value and all four valid members. */
1534
+ export function assertLabelKind(value: unknown): LabelKind {
1535
+ return assertMember(value, LABEL_KINDS, "label kind");
1536
+ }
1537
+
1538
+ /** Narrows an unvalidated argument to an `XrefAccessKind`, or throws
1539
+ * `AnnoTypeError` carrying the offending value and all four valid members. A
1540
+ * fifth access kind is refused here rather than stored and puzzled over later. */
1541
+ export function assertAccessKind(value: unknown): XrefAccessKind {
1542
+ return assertMember(value, XREF_ACCESS_KINDS, "access kind");
1543
+ }
1544
+
1545
+ /** Narrows an unvalidated argument to an `EvidSourceBank`, or throws
1546
+ * `AnnoTypeError` carrying the offending value and all three valid members. */
1547
+ export function assertEvidSourceBank(value: unknown): EvidSourceBank {
1548
+ return assertMember(value, EVID_SOURCE_BANKS, "source bank");
1549
+ }
1550
+
1551
+ /** Exactly 64 lowercase hex characters -- the shape `argvDigest()`
1552
+ * (`capture-predicate.ts`) and a sha256 image hash both already produce.
1553
+ * Refuses anything else BY NAME, including a correctly-shaped but
1554
+ * UPPERCASE digest, rather than lower-casing it silently: two callers
1555
+ * disagreeing on case is exactly the kind of divergence a store write must
1556
+ * not launder into agreement. */
1557
+ export function assertRunIdentityDigest(value: unknown, what: string): string {
1558
+ if (typeof value === "string" && /^[0-9a-f]{64}$/.test(value)) {
1559
+ return value;
1560
+ }
1561
+ throw new AnnoTypeError(
1562
+ `${what} ${JSON.stringify(value)} is not exactly 64 lowercase hex characters -- expected a sha256 hex digest such as argvDigest() or an image hash produces`,
1563
+ { dataType: value },
1564
+ );
1565
+ }
1566
+
1567
+ /** A non-empty string. `seed` is not a digest and carries no shape beyond
1568
+ * that -- REPRO-04's run-identity composite treats it as an opaque token. */
1569
+ export function assertRunIdentitySeed(value: unknown): string {
1570
+ if (typeof value === "string" && value.length > 0) {
1571
+ return value;
1572
+ }
1573
+ throw new AnnoTypeError(`seed ${JSON.stringify(value)} is not a non-empty string`, { dataType: value });
1574
+ }
1575
+
1576
+ /** The legal-identifier shape, quoted from the schema's own sentence
1577
+ * (`anno-tools.ts:246-251`): "starts with a letter or underscore, followed by
1578
+ * letters/digits/underscores only". Used for label names and for project-enum
1579
+ * names, which the schema calls a "unique alphanumeric identifier" and whose own
1580
+ * documented example (`vic_registers`) carries an underscore. */
1581
+ const LEGAL_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1582
+
1583
+ /**
1584
+ * Refuses a label name that is not a legal ACME identifier, or that is a
1585
+ * 6502/6510 mnemonic. Returns the name UNCHANGED on acceptance.
1586
+ *
1587
+ * THREE REFUSALS AND NO NORMALISATION. There is no sanitisation, substitution,
1588
+ * trimming or quoting step in this function or anywhere on the store's write
1589
+ * path, and trap 7 states the reason as the concrete hazard: a
1590
+ * space-to-underscore substitution turns two names a human deliberately
1591
+ * distinguished into one, and the loss is silent and permanent.
1592
+ *
1593
+ * TWO DIFFERENT COMPARISONS, stated here so the store's collision check has one
1594
+ * definition to follow. The denylist comparison is CASE-INSENSITIVE -- `LDA`,
1595
+ * `lda` and `Lda` are all illegal, because all three assemble to the same
1596
+ * instruction. The comparison of one label name against another (the collision
1597
+ * check, which lives in `anno-store.ts` because it needs the rows) is EXACT BYTE
1598
+ * EQUALITY: no case folding, no Unicode normalisation, no whitespace trimming.
1599
+ * Any of those would be a normalisation, and a normalisation is what merges two
1600
+ * names.
1601
+ */
1602
+ export function assertLegalLabel(name: unknown): string {
1603
+ if (typeof name !== "string" || name.length === 0) {
1604
+ throw new AnnoLabelError(
1605
+ `label name ${JSON.stringify(name)} is not a legal identifier -- expected a non-empty string starting with a letter or underscore, ` +
1606
+ `then letters, digits and underscores only. An illegal name is REFUSED, never sanitised or quoted.`,
1607
+ { identifier: typeof name === "string" ? name : undefined, reason: "not a non-empty string" },
1608
+ );
1609
+ }
1610
+ if (!LEGAL_IDENTIFIER_RE.test(name)) {
1611
+ throw new AnnoLabelError(
1612
+ `label name ${JSON.stringify(name)} is not a legal identifier -- it must start with a letter or underscore and then contain ` +
1613
+ `letters, digits and underscores only. It is REFUSED rather than rewritten: substituting a character would merge this name with ` +
1614
+ `whatever name the substitution produces, and nothing would record that it happened.`,
1615
+ { identifier: name, reason: "illegal character" },
1616
+ );
1617
+ }
1618
+ if (MNEMONIC_DENYLIST.has(name.toLowerCase())) {
1619
+ throw new AnnoLabelError(
1620
+ `label name ${JSON.stringify(name)} is a 6502/6510 mnemonic and cannot be a label -- the denylist is derived from the full ` +
1621
+ `256-entry opcode table, so it covers the illegal-opcode mnemonics too, and it is compared case-insensitively.`,
1622
+ { identifier: name, reason: "6502/6510 mnemonic" },
1623
+ );
1624
+ }
1625
+ return name;
1626
+ }
1627
+
1628
+ /**
1629
+ * Refuses a project-enum name that is not the schema's "unique alphanumeric
1630
+ * identifier". Returns it unchanged.
1631
+ *
1632
+ * The mnemonic denylist is deliberately NOT applied: the schema's mnemonic rule
1633
+ * is scoped to LABEL names, which are the symbols an assembler sees, and
1634
+ * widening it here would refuse an enum name on a rule the surface this store
1635
+ * mirrors does not have.
1636
+ */
1637
+ export function assertEnumName(name: unknown): string {
1638
+ if (typeof name !== "string" || !LEGAL_IDENTIFIER_RE.test(name)) {
1639
+ throw new AnnoLabelError(
1640
+ `project enum name ${JSON.stringify(name)} is not a legal identifier -- expected a non-empty string starting with a letter or ` +
1641
+ `underscore, then letters, digits and underscores only`,
1642
+ { identifier: typeof name === "string" ? name : undefined, reason: "illegal enum name" },
1643
+ );
1644
+ }
1645
+ return name;
1646
+ }
1647
+
1648
+ /** The UTF-8 byte length of `text`. A `TextEncoder` and not `String.length`:
1649
+ * see trap 10 -- code units are not bytes, and the bound is a bound on the
1650
+ * bytes that land in the store file. */
1651
+ function utf8ByteLength(text: string): number {
1652
+ return new TextEncoder().encode(text).length;
1653
+ }
1654
+
1655
+ /**
1656
+ * Every line-break character a comment may not contain: LF, CR, and the two
1657
+ * Unicode line separators U+2028 / U+2029.
1658
+ *
1659
+ * LF and CR are the ones that genuinely end a line in generated assembler
1660
+ * source. U+2028 and U+2029 are refused alongside them because some editors
1661
+ * emit them where a human meant a line break, and a field documented as
1662
+ * single-line should not silently carry one on any of the four spellings -- a
1663
+ * check that catches three of four is a check somebody will trip over exactly
1664
+ * once, a long way from here.
1665
+ */
1666
+ const LINE_BREAK_RE = /[\n\r\u2028\u2029]/;
1667
+
1668
+ /**
1669
+ * Refuses comment (or description) text. FOUR checks, in the order they run:
1670
+ *
1671
+ * 1. Not a string at all.
1672
+ * 2. An embedded LINE BREAK -- `\n`, `\r`, U+2028 or U+2029, anywhere in the
1673
+ * text. Named before a length is, because "your comment has a line break
1674
+ * in it" is actionable and "your comment is too long" would not be.
1675
+ * 3. The `';'` prefix the schema tells callers to omit ("Do not include the
1676
+ * ';' prefix", `anno-tools.ts:269`) -- unless `allowLeadingSemicolon`.
1677
+ * 4. Over `MAX_COMMENT_BYTES` UTF-8 bytes.
1678
+ *
1679
+ * Returns the text UNCHANGED on acceptance. Every refusal is a REFUSAL and
1680
+ * never a repair: an over-long comment is not truncated, because a truncation
1681
+ * drops the end of a human's sentence and reports success, and a line break is
1682
+ * not stripped, because stripping MERGES two things somebody wrote on separate
1683
+ * lines into one text -- the same silent, permanent loss trap 7 records for
1684
+ * space-to-underscore label sanitisation.
1685
+ *
1686
+ * WHY A LINE BREAK IS A REFUSAL AND NOT A COSMETIC ISSUE: the store holds the
1687
+ * comment's WORDS and the exporter adds the `';'` prefix. A stored line break
1688
+ * therefore puts everything after it into the generated ACME source at column
1689
+ * zero -- as assembler INPUT, not as a comment.
1690
+ *
1691
+ * The line-break rule applies UNCONDITIONALLY, `allowLeadingSemicolon`
1692
+ * included. Both call sites are single-line text fields; that flag governs the
1693
+ * semicolon rule and nothing else.
1694
+ *
1695
+ * `allowLeadingSemicolon` exists for the one neighbouring text field with the
1696
+ * same byte bound and no semicolon rule: a project enum's free-text
1697
+ * description, which the schema does not describe as assembler comment text.
1698
+ */
1699
+ export function assertCommentText(text: unknown, opts: { what?: string; allowLeadingSemicolon?: boolean } = {}): string {
1700
+ const what = opts.what ?? "comment";
1701
+ if (typeof text !== "string") {
1702
+ throw new AnnoCommentError(`${what} text ${JSON.stringify(text)} is not a string`, { reason: "not a string" });
1703
+ }
1704
+ if (LINE_BREAK_RE.test(text)) {
1705
+ throw new AnnoCommentError(
1706
+ `${what} text contains an embedded line break -- the store holds the comment's words and the exporter adds the ';' prefix, so a stored ` +
1707
+ `newline would put everything after it into the generated ACME source at column zero, as assembler input rather than as a comment. ` +
1708
+ `It is REFUSED rather than stripped, because stripping merges two lines somebody wrote separately into one text and reports success`,
1709
+ { reason: "embedded newline" },
1710
+ );
1711
+ }
1712
+ if (opts.allowLeadingSemicolon !== true && /^\s*;/.test(text)) {
1713
+ throw new AnnoCommentError(
1714
+ `${what} text must not begin with the ';' prefix -- the store holds the comment's words and the exporter adds the prefix, ` +
1715
+ `so a stored ';' would be emitted twice`,
1716
+ { reason: "semicolon prefix" },
1717
+ );
1718
+ }
1719
+ const bytes = utf8ByteLength(text);
1720
+ if (bytes > MAX_COMMENT_BYTES) {
1721
+ throw new AnnoCommentError(
1722
+ `${what} text is ${bytes} UTF-8 bytes, over the ${MAX_COMMENT_BYTES}-byte bound -- it is REFUSED rather than truncated, ` +
1723
+ `because a truncation drops the end of a sentence somebody wrote and reports success`,
1724
+ { reason: "over MAX_COMMENT_BYTES", byteLength: bytes },
1725
+ );
1726
+ }
1727
+ return text;
1728
+ }
1729
+
1730
+ /** The largest value a project-enum variant key may name. Bounded so a variants
1731
+ * object cannot carry an arbitrary-length key, and NOT bounded to the address
1732
+ * space, because a variant key is a VALUE -- a register bitmask or a mode
1733
+ * number -- rather than an address. */
1734
+ export const MAX_VARIANT_KEY = 0xffffffff;
1735
+
1736
+ /**
1737
+ * Parses one project-enum variant key into its numeric value. Accepts exactly
1738
+ * the forms the schema names (`anno-tools.ts:474`): "keys are numeric strings
1739
+ * (decimal, hex 0x/$, bin 0b/%)".
1740
+ *
1741
+ * Decimal IS accepted here, and that is not an inconsistency with
1742
+ * `parseStoreAddress()`'s refusal of `"1024"` (trap 4). The two answer different
1743
+ * questions: an ADDRESS in the wrong base points at the wrong memory and the
1744
+ * error is silent and persistent, whereas a variant key's base is stated by the
1745
+ * schema itself, so an unprefixed key has one documented reading and no
1746
+ * ambiguity to resolve.
1747
+ */
1748
+ export function parseVariantKey(key: unknown): number {
1749
+ if (typeof key !== "string" || key.trim() === "") {
1750
+ throw new AnnoTypeError(
1751
+ `enum variant key ${JSON.stringify(key)} is not a numeric string -- expected decimal, "0x"/"$" hex or "0b"/"%" binary`,
1752
+ { dataType: key },
1753
+ );
1754
+ }
1755
+ const raw = key.trim();
1756
+ const forms: readonly (readonly [RegExp, number, number])[] = [
1757
+ [/^\$([0-9a-fA-F]+)$/, 16, 1],
1758
+ [/^0[xX]([0-9a-fA-F]+)$/, 16, 2],
1759
+ [/^%([01]+)$/, 2, 1],
1760
+ [/^0[bB]([01]+)$/, 2, 2],
1761
+ [/^([0-9]+)$/, 10, 0],
1762
+ ];
1763
+ for (const [pattern, radix, skip] of forms) {
1764
+ const match = pattern.exec(raw);
1765
+ if (match) {
1766
+ const value = parseInt(raw.slice(skip), radix);
1767
+ if (!Number.isInteger(value) || value < 0 || value > MAX_VARIANT_KEY) {
1768
+ throw new AnnoTypeError(
1769
+ `enum variant key ${JSON.stringify(raw)} is ${value}, outside 0..${MAX_VARIANT_KEY}`,
1770
+ { dataType: key },
1771
+ );
1772
+ }
1773
+ return value;
1774
+ }
1775
+ }
1776
+ throw new AnnoTypeError(
1777
+ `enum variant key ${JSON.stringify(raw)} is not one of the numeric-string forms the schema names -- expected decimal ("64"), ` +
1778
+ `hex ("$40" or "0x40") or binary ("%01000000" or "0b01000000")`,
1779
+ { dataType: key },
1780
+ );
1781
+ }
1782
+
1783
+ /** True for the two `_address` split layouts, false for the two `_word` ones.
1784
+ * The schema's own distinction (`anno-tools.ts:305-313`):
1785
+ * `address=16-bit LE pointers (creates X-Refs, ...)` versus
1786
+ * `word=16-bit LE values`. Exported separately from `resolveSplitTargets()` so
1787
+ * the store can ask the question without resolving anything. */
1788
+ export function producesXrefsFor(dataType: SplitDataType): boolean {
1789
+ return dataType.endsWith("_address");
1790
+ }
1791
+
1792
+ /**
1793
+ * Resolves a split table's bytes into its 16-bit targets. PURE: no I/O, no
1794
+ * state, and nothing it computes is ever written to disk.
1795
+ *
1796
+ * THE LAYOUT. `bytes` is the table's raw bytes; the first half is one byte of
1797
+ * each entry and the second half is the other. For the low-high orientation the
1798
+ * first half holds the LOW bytes, so target `i` is
1799
+ * `bytes[i] | (bytes[n + i] << 8)`; for the high-low orientation the first half
1800
+ * holds the HIGH bytes, so target `i` is `(bytes[i] << 8) | bytes[n + i]`.
1801
+ *
1802
+ * THE WORKED ARITHMETIC, verified during research and pinned by
1803
+ * `anno-types.test.ts`: the bytes `10 34 00 ff 08 12 c0 cf` resolve to
1804
+ * `$0810 $1234 $c000 $cfff` under `lo_hi_address` and to
1805
+ * `$1008 $3412 $00c0 $ffcf` under `hi_lo_address`. THAT DIFFERING TARGET SET IS
1806
+ * THE CONTROL for the decision to keep all four split layouts as first-class
1807
+ * members: it is the one observable consequence of recording orientation.
1808
+ *
1809
+ * WHY A BYTE-IDENTICAL REASSEMBLY ASSERTION CANNOT BE THAT CONTROL: a retype
1810
+ * changes no bytes. A reassembly comparison is therefore green under BOTH
1811
+ * orientations and can never go red on a collapsed vocabulary. It is worth
1812
+ * having separately -- it catches an exporter that mangles bytes -- but it is
1813
+ * not this claim's evidence and must not be presented as such.
1814
+ *
1815
+ * THIS FUNCTION DERIVES AND RETURNS. It never stores. See `anno-store.ts`'s
1816
+ * `putXref` for the reason a derived cross-reference must not reach the disk.
1817
+ */
1818
+ export function resolveSplitTargets(bytes: Uint8Array | readonly number[], dataType: unknown): SplitTargets {
1819
+ const layout = assertSplitLayout(dataType);
1820
+ const source = Array.from(bytes);
1821
+ const lowFirst = layout.startsWith("lo_hi_");
1822
+ const targets: number[] = [];
1823
+ // THE PARTNER RULE IS NOT RE-DERIVED HERE. The offset couples come from
1824
+ // `splitPartnerOffsets()`, which is the one place in the repo that knows an
1825
+ // entry's partner -- and which raises this function's own odd-byte-count
1826
+ // refusal, so the message and the rule stay together.
1827
+ for (const [firstOffset, secondOffset] of splitPartnerOffsets(source.length, layout)) {
1828
+ const first = source[firstOffset] & 0xff;
1829
+ const second = source[secondOffset] & 0xff;
1830
+ targets.push(lowFirst ? first | (second << 8) : (first << 8) | second);
1831
+ }
1832
+ return { entryCount: targets.length, targets, producesXrefs: producesXrefsFor(layout) };
1833
+ }
1834
+
1835
+ /**
1836
+ * THE ONE PLACE IN THIS REPO WHERE AN ENTRY'S PARTNER IS COMPUTED.
1837
+ *
1838
+ * Returns the ordered offset couples `[i, n + i]` for `i` in `0..n-1`, with
1839
+ * `n = byteCount / 2` -- the first-half/second-half layout, expressed once.
1840
+ *
1841
+ * ITS TWO CONSUMERS, NAMED because a third would defeat the point:
1842
+ * * `resolveSplitTargets()` above, which reads BYTES at those offsets;
1843
+ * * `splitEntryAddressPairs()` below, which reads ADDRESSES at them, and is
1844
+ * what `anno-store.ts`'s `retype()` gate consults before it fragments a
1845
+ * split row.
1846
+ *
1847
+ * A resolver and a writer that each kept their own copy of this arithmetic could
1848
+ * disagree about what an entry IS, and the disagreement would be silent: both
1849
+ * copies produce legal, decodable rows. That is CR-10's whole class, so the rule
1850
+ * has one home. `anno-types.test.ts`'s worked-arithmetic pin is the control --
1851
+ * changing the couples here to an interleaved `[2i, 2i + 1]` reddens it.
1852
+ *
1853
+ * The odd-count refusal lives here for the same reason: it is the SAME rule
1854
+ * stated as a precondition, and `assertRangeShape()` asks it of an address span
1855
+ * while this asks it of a byte count.
1856
+ */
1857
+ function splitPartnerOffsets(byteCount: number, layout: SplitDataType): readonly (readonly [number, number])[] {
1858
+ if (byteCount % 2 !== 0) {
1859
+ throw new AnnoRangeShapeError(
1860
+ `a ${layout} table needs an even byte count, but ${byteCount} byte(s) were supplied -- the low half and the high half must be ` +
1861
+ `the same length`,
1862
+ { start: 0, endInclusive: byteCount - 1 },
1863
+ );
1864
+ }
1865
+ const n = byteCount / 2;
1866
+ const offsets: (readonly [number, number])[] = [];
1867
+ for (let i = 0; i < n; i += 1) offsets.push([i, n + i] as const);
1868
+ return offsets;
1869
+ }
1870
+
1871
+ /** Refuses anything that is not one of the four split layouts, with the full
1872
+ * list. Extracted so `resolveSplitTargets()` and `splitEntryAddressPairs()`
1873
+ * refuse a non-split type in exactly one way rather than two. */
1874
+ function assertSplitLayout(dataType: unknown): SplitDataType {
1875
+ if (typeof dataType !== "string" || !(SPLIT_DATA_TYPES as readonly string[]).includes(dataType)) {
1876
+ throw new AnnoTypeError(
1877
+ `${JSON.stringify(dataType)} is not a split-table layout -- expected one of: ${SPLIT_DATA_TYPES.join(", ")}`,
1878
+ { dataType, validTypes: [...SPLIT_DATA_TYPES] },
1879
+ );
1880
+ }
1881
+ return dataType as SplitDataType;
1882
+ }
1883
+
1884
+ /**
1885
+ * The two ADDRESSES whose bytes form each entry of the split table occupying
1886
+ * `start..endInclusive`, in table order. PURE: no I/O, no state, no bytes.
1887
+ *
1888
+ * WHY THE STORE NEEDS ADDRESSES RATHER THAN RESOLVED VALUES. The store holds no
1889
+ * bytes -- it holds spans and types, and the emulator or the image holds what is
1890
+ * in them. So the preservation question the store can answer FOR ITSELF is not
1891
+ * "did this entry's 16-bit value survive" (it cannot know the value) but "does
1892
+ * this entry still read the same two addresses". That is the question
1893
+ * `retype()`'s gate asks before it fragments a split row, and it is answerable
1894
+ * from the row's span alone.
1895
+ *
1896
+ * ORDERING IS LOAD-BEARING, the same split `setDataType()` makes: the TYPE gate
1897
+ * runs first (is this a split layout at all), then the RANGE gate
1898
+ * (`assertRangeShape`, which owns the even-byte-count rule for an address span).
1899
+ * Collapsing them would make a caller unable to tell "that is not a split
1900
+ * layout" from "those two ends do not make a split table".
1901
+ */
1902
+ export function splitEntryAddressPairs(start: number, endInclusive: number, dataType: SplitDataType): SplitEntryPairs {
1903
+ const layout = assertSplitLayout(dataType);
1904
+ assertRangeShape(start, endInclusive, layout);
1905
+ const pairs = splitPartnerOffsets(endInclusive - start + 1, layout).map(
1906
+ ([first, second]) => [start + first, start + second] as const,
1907
+ );
1908
+ return { entryCount: pairs.length, pairs };
1909
+ }