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