@am_shork/attest 0.9.0 → 0.9.1

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/CHANGELOG.md CHANGED
@@ -13,6 +13,143 @@ input, removes/renames a public API or `--json` field, or changes a default
13
13
  runtime behavior an existing invocation relies on — diagnostic message text is
14
14
  not API.
15
15
 
16
+ ## [0.9.1] - 2026-08-13
17
+
18
+ ### Changed
19
+
20
+ - **Every command starts 11–28% faster, from three lines in `bin/attest.js`.**
21
+ `typescript` is the fixed toll on an `attest` invocation and nothing about the
22
+ design can move it: measured, the CLI's dependency graph costs ~790 ms to load
23
+ and the compiler is ~672 ms of that, against ~28 ms for commander, ~17 ms for
24
+ chalk and ~70 ms for zod. It is also not deferrable — every command except
25
+ `init` reads a registry or a spec through the AST, so the import is needed and
26
+ not merely eager. What *is* movable is the price of the same load, and Node's
27
+ compile cache moves it: V8 keeps its compilation output between runs. Two
28
+ independent A/B rounds, nine interleaved runs each, median — `check self`
29
+ 1153 → 970 and 1106 → 971 ms, `cover self` 1122 → 951 and 1346 → 1195 ms,
30
+ `status` 1110 → 923 ms, `init` 904 → 711 and 1365 → 984 ms. The absolute
31
+ numbers move with the machine; the direction did not, in either round or on
32
+ any command.
33
+ **The launcher's import had to become dynamic, and that is the load-bearing
34
+ half.** A static `import` is hoisted and its whole graph evaluated before any
35
+ statement in the file runs, so with one the cache is enabled *after*
36
+ `typescript` has already been compiled and does nothing whatever — while still
37
+ reporting success and leaving every command working. Measured: the static
38
+ spelling came back at 873 ms against an unpatched 897 ms, inside the noise,
39
+ where the dynamic one is 669 ms. A defence that reads as present and is not is
40
+ this repository's dominant failure shape, and it fits in a three-line file.
41
+ No behaviour changes: same output, same exit codes, same files. The cache is
42
+ optional-chained, because `engines` allows Node 20 and the API arrived in
43
+ 22.1 — on an older runtime the speed-up is simply absent. It never throws, and
44
+ a cache directory that cannot be written returns a status this ignores, since
45
+ the only thing a failure costs is the saving. It is written under the OS temp
46
+ directory and never into the project, which is the one place every other file
47
+ Attest writes belongs.
48
+
49
+ - **`loadRegistry`'s fan-out is bounded — the third one, where `[0.9.0]` closed
50
+ "both".** That entry is not wrong about what it did; it is wrong about how many
51
+ there were. `locate.ts` reads registry files through a
52
+ `Promise.all(paths.map(…))` of its own, so `check` on a tree carrying N
53
+ `*.reqs.ts` held N reads and N `SourceFile`s at once — the same shape, on the
54
+ same command, against the same input nobody here chooses. Measured in
55
+ `tests/locate-fanout.spec.ts`, which now counts a third pool: **64 before, 32
56
+ after**, on 64 registry files. Unbounded, the figure is the count of the files;
57
+ bounded, it is the constant.
58
+ *Why it hid is the part worth keeping.* The other two live in functions named
59
+ for walking and parsing, and both had a comment about concurrency saying
60
+ nothing about a bound. This one has a comment that reads as a considered
61
+ decision — "read the files concurrently, then fold the results in sorted file
62
+ order: the issue list stays deterministic regardless of which one finished
63
+ first" — and it is a correct, load-bearing answer to a *different* question.
64
+ Something that has visibly been thought about does not get thought about again,
65
+ which is what put a third copy of one shape past a fix that named itself as
66
+ covering all of them.
67
+ *The reader is what makes it more than arithmetic here.* `parseSpecs` bounds
68
+ sources; this bounds whatever the reader holds, which under `--eval`,
69
+ `verify` and `archive` is a Vite SSR module evaluation rather than a string.
70
+ Hence a third constant rather than sharing one, on the reason `WALK_CONCURRENCY`
71
+ already gives: the three bound different resources and cannot be tuned
72
+ together.
73
+ Descriptor exhaustion is still the failure it invites and still unmeasured —
74
+ Windows uses Win32 handles rather than POSIX descriptors, so `ulimit -n` does
75
+ not govern it there, and Linux is the half CI runs. The peak is portable
76
+ arithmetic, which is the standard both earlier halves were accepted on.
77
+ No behaviour change and no `ATX-n`, on that same precedent: the registry, the
78
+ issue list, its order and `prefixOwners` are identical, all asserted beside the
79
+ peak. The fold is now driven by index rather than by arrival, which is what
80
+ keeps "sorted file order" true through a pool that completes out of order —
81
+ the same indexing `parseSpecs` uses, for the same reason.
82
+
83
+ ### Fixed
84
+
85
+ - **`--apply` wrote a `*.reqs.ts` that no longer parses, when a MODIFIED entry
86
+ changed two `params` and one of them had an integer-like key.** The write-back
87
+ rests on an ordering property stated in `splice.ts` and nowhere enforced: the
88
+ walk emits its edits in ascending offset order, so the pass at the bottom can
89
+ be a `reverse()` rather than a numeric sort — "an ordering that holds by
90
+ construction beats one restored after the fact". It did not hold by
91
+ construction. `registryEntryLayouts` handed the layout back as plain objects,
92
+ and **JavaScript hoists an integer-like key to the front of every object**, so
93
+ `params: { budgetMb: 12, 10: 'ten' }` was described in an order the file never
94
+ wrote. A `params` key genuinely can be a number: `staticName` spells a numeric
95
+ literal key as `String(value)` and the schema's `z.record(z.string(), …)`
96
+ accepts the result, on both reader paths.
97
+ The consequence is the one shape this command must not have. With the order
98
+ reversed, the lower edit was applied first and every offset after it shifted
99
+ underneath the spans still to be written — measured, changing `budgetMb`
100
+ `12 → 4096` alongside `10: 'ten' → 'a'` produced `params: { budgetMb: 4096,
101
+ 10'a'n' },`, off by exactly the two bytes the first replacement grew. The
102
+ registry was **already on disk** by then: `applyMerge` writes each file before
103
+ `verifyWritten` re-reads it, so the re-read caught it and stopped the merge —
104
+ which is what that check is for — but what it stopped at was a hand-written,
105
+ hand-commented registry left unparseable, and the merge's own account of the
106
+ failure is an `internal-error` naming the file.
107
+ The fix is the containers, not a sort. `fields`, `paramKeys` and the layout map
108
+ itself are `Map`s, which keep insertion order for every key type, so the
109
+ property is back to holding by construction rather than by the keys happening
110
+ not to be numbers — the standard `keySource` and `compareIds` are already
111
+ written to, arriving at the one place that had been resting on an upstream
112
+ grammar. Contained to `splice.ts`, which is the only consumer.
113
+ No new requirement: ATX-76 already says the write-back replaces *the source
114
+ span of each value that changed*, and this was a defect against that sentence
115
+ rather than a gap in it. The regression sits in `tests/splice.spec.ts`, at the
116
+ seam the invariant lives on; reaching it through a `self/` scenario would need
117
+ a whole fixture project to express one numeric key, for a weaker signal.
118
+
119
+ - **The package shipped a file its own `exports` map forbade reading.** npm puts
120
+ `package.json` in every tarball whatever `files` says, and the map named only
121
+ `.` and `./define` — so `@am_shork/attest/package.json`, which tooling reaches
122
+ for to read a dependency's version, came back
123
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`. Measured against a real installed tarball,
124
+ not reasoned about. Fixed by naming it, which is the whole change.
125
+ *The test that should have caught it was checking the wrong half.* "Ships the
126
+ entry points the exports map promises" asserted that `dist/index.js`,
127
+ `dist/core/registry.js` and `bin/attest.js` **exist** in the install — which an
128
+ `exports` map blocking every subpath would leave exactly as true. Existence and
129
+ reachability are decided by different fields, `files` shipping the bytes and
130
+ `exports` deciding what may address them, so one cannot stand in for the other.
131
+ `tests/consumer.spec.ts` now resolves each promised specifier from inside the
132
+ consumer project, asserting line by line so the diff names the one that
133
+ stopped. It went red on `./package.json` and green on the other two, which is
134
+ what says the new test measures the thing rather than the fix.
135
+
136
+ - **`cli/report.ts` was carrying a false reason for a defence, and it
137
+ contradicted `render.ts`.** The comment on `formatCoverage` justified
138
+ sanitising a requirement id with "under `--eval` the registry is whatever the
139
+ module exported, and `evalReader` checks that it is an object and nothing
140
+ further" — true when it was written, and untrue since ATX-38 made the
141
+ evaluating reader validate against the same schema as the static one. So
142
+ `^[A-Z]+-\d+$` holds on both paths, `render.ts` says so explicitly, and the two
143
+ modules read as disagreeing about whether a registry id can carry a control
144
+ character.
145
+ The sanitising stays and the reason is corrected: the grammar is held
146
+ somewhere else, `CoverageRow.reqId` is a `string`, and the function is
147
+ exported — a defence that lapses when the other one does is not a defence,
148
+ which is the standard `splice.ts`'s `keySource` is already written to. No
149
+ behaviour change; the call was, and remains, one that cannot fire through any
150
+ command. Recorded because in this repository a comment is the decision record,
151
+ and a false one is read as evidence the next time the question comes up.
152
+
16
153
  ## [0.9.0] - 2026-08-12
17
154
 
18
155
  ### Added
@@ -5251,6 +5388,40 @@ it sat between 0.2.0 and 0.1.7 for two releases, where standing still meant
5251
5388
  sinking one version deeper each time a release was cut above it, and a rejection
5252
5389
  filed under a version reads as belonging to it.
5253
5390
 
5391
+ ### Deferring the `typescript` import so `init` and `--version` do not pay it
5392
+
5393
+ Priced on 2026-08-13, in the session that enabled the compile cache above, and
5394
+ rejected on the size of what it actually reaches. The proposal was the move
5395
+ `pipeline.ts` already makes for the runner half — `vite`/`vitest` behind an
5396
+ `await import(...)`, on the argument that a short-lived process pays cold start
5397
+ as the whole bill — applied a second time to the AST half.
5398
+
5399
+ The two are not the same shape, and the measurement is why. The runner lazy-load
5400
+ took a cost off five of seven commands, because `check`, `cover`, `render`,
5401
+ `status` and `init` genuinely cannot start a run. The AST half has one
5402
+ beneficiary: **every command except `init` reads a registry or a spec through the
5403
+ compiler**, so for `check`, `cover`, `render`, `status`, `verify` and `archive`
5404
+ deferring the import moves when the 672 ms is paid and not whether. What is left
5405
+ is `init`, which is run once per project, and `--version` / `--help`. Against the
5406
+ compile cache — which took 11–28% off *all* of them for three lines — the
5407
+ remaining prize is roughly a further 260 ms on `init` alone.
5408
+
5409
+ The structural price is not small either, and it is concentrated in the wrong
5410
+ place. Everything funnels into the compiler through `pipeline.ts`'s static graph,
5411
+ including two edges that are easy to miss: `status` reaches it via
5412
+ `red-record.ts` → `merge.ts` → `splice.ts`, for the single path function
5413
+ `mergedSpecPath`. So deferring it is not one thunk beside the existing two; it is
5414
+ `runInit` and its destination resolution leaving `pipeline.ts`, and
5415
+ `resolveOutFile`'s stated property — private to that module "rather than
5416
+ something a shell is trusted to call" — weakening to pay for it. A once-per-project
5417
+ command is not worth that.
5418
+
5419
+ Worth reopening if either half of the arithmetic moves: a `typescript` whose AST
5420
+ surface can be imported without the checker (the `typescript/unstable/ast*` entry
5421
+ points `core/compiler.ts` already names, once they stop saying unstable), or a
5422
+ second command that needs none of it. `mergedSpecPath` sitting in the heavy
5423
+ module is independently worth fixing whether or not this is.
5424
+
5254
5425
  ### Widening `status` to the rest of the gate's static half
5255
5426
 
5256
5427
  Scoped and decided on 2026-08-12, in the session that gave `status` its two
package/README.md CHANGED
@@ -167,7 +167,7 @@ Every diagnostic carries a `code`, and every code has a section in
167
167
  ```
168
168
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
169
169
  Value is not a literal.
170
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.0/docs/en/troubleshooting.md#registry-not-static
170
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.1/docs/en/troubleshooting.md#registry-not-static
171
171
  ```
172
172
 
173
173
  The anchor **is** the code, so the link cannot point somewhere the section
package/bin/attest.js CHANGED
@@ -1,3 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
  // Thin launcher: delegates to the built CLI shell (src/cli -> dist/cli).
3
- import '../dist/cli/index.js';
3
+ //
4
+ // **The compile cache is enabled here because here is the only place it can
5
+ // be.** `typescript` is the fixed toll on every invocation: measured on this
6
+ // machine, the CLI's whole dependency graph costs ~790 ms to load and the
7
+ // compiler is ~672 ms of it, against ~28 ms for commander, ~17 ms for chalk and
8
+ // ~70 ms for zod. Nothing can move that by loading it later, either — every
9
+ // command except `init` reads a registry or a spec through the AST, so the
10
+ // import is not merely eager, it is *needed*. What a code cache changes is the
11
+ // price of the same load: V8 keeps its compilation output between runs, and the
12
+ // saving lands on the commands a pipeline actually repeats. Measured over two
13
+ // independent A/B rounds, nine interleaved runs each, median: **11% to 28% off
14
+ // every command** — `check self` 1153 -> 970 and 1106 -> 971 ms, `cover self`
15
+ // 1122 -> 951 and 1346 -> 1195 ms, `status` 1110 -> 923 ms, `init` 904 -> 711
16
+ // and 1365 -> 984 ms. Quoted as a range because the absolute figures move with
17
+ // whatever else the machine is doing; the direction did not, across either
18
+ // round or any command in them.
19
+ //
20
+ // **The import below has to stay dynamic, and this is the whole reason.** A
21
+ // static `import` is hoisted and its module graph is evaluated *before* any
22
+ // statement in this file runs — so with one, the call above happens after
23
+ // `typescript` has already been compiled and the cache does nothing at all. It
24
+ // still returns success, and every command still works, so there is nothing to
25
+ // notice: measured, the static spelling came back at 873 ms against an
26
+ // unpatched 897 ms, i.e. inside the noise, while the dynamic one is 669 ms.
27
+ // That is this repository's dominant failure shape — a defence that reads as
28
+ // present and is not — arriving in a three-line file.
29
+ //
30
+ // Optional-chained because `engines` allows Node 20 and this API arrived in
31
+ // 22.1: on an older runtime the speed-up is absent, which is the right
32
+ // behaviour for an optimisation and not a reason to refuse to start. It never
33
+ // throws either — a cache directory that cannot be written comes back as a
34
+ // status this deliberately ignores, since the only thing a failure costs is the
35
+ // saving above.
36
+ //
37
+ // The cache lands under the OS temp directory, never in the project: every
38
+ // other file Attest writes is one the user commits (see `core/write.ts`), and
39
+ // this is the one that must not be.
40
+ import module from 'node:module';
41
+
42
+ module.enableCompileCache?.();
43
+
44
+ await import('../dist/cli/index.js');
@@ -250,10 +250,16 @@ export function formatCoverage(rows) {
250
250
  const detail = r.covered
251
251
  ? chalk.dim(`${r.scenarioCount} scenario${r.scenarioCount === 1 ? '' : 's'}`)
252
252
  : chalk.red('no scenario');
253
- // Sanitised, even though a registry id has been through the schema's
254
- // `^[A-Z]+-\d+$`: that is true of the *static* reader only. Under
255
- // `--eval` the registry is whatever the module exported, and `evalReader`
256
- // checks that it is an object and nothing further.
253
+ // Sanitised even though a registry id cannot carry a control character:
254
+ // `^[A-Z]+-\d+$` holds on both reader paths — the static one by
255
+ // construction, the evaluating one since ATX-38 so this can never fire.
256
+ // It stays because the grammar is held somewhere else, and a defence that
257
+ // lapses when the other one does is not a defence; `CoverageRow.reqId` is
258
+ // a `string`, and this function is exported. Same standard as
259
+ // `splice.ts`'s `keySource`, and the reason it is not an inconsistency
260
+ // with `render.ts` deciding the opposite: that one states it need *not*
261
+ // sanitise ids, which is true, and builds its container prototype-free
262
+ // anyway for exactly this reason.
257
263
  return ` ${mark} ${chalk.bold(inline(r.reqId))} ${detail}`;
258
264
  })
259
265
  .join('\n');
@@ -43,22 +43,27 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
43
43
  /**
44
44
  * Run `fn` over every item with at most `limit` of them in flight.
45
45
  *
46
- * Extracted at the second call site rather than the first: this file has two
47
- * unbounded fan-outs to close, one over directories and one over files, and
48
- * a shape written twice is a shape one of the two copies will eventually be
49
- * fixed without.
46
+ * Extracted at the second call site rather than the first, because a shape
47
+ * written N times is one a later fix reaches only some copies of.
48
+ *
49
+ * **There are three, and the third is the one to look for.** `findFiles` and
50
+ * `parseSpecs` are named for what they fan out over; `loadRegistry` is named for
51
+ * merging, and its concurrency sits under a comment about *ordering* that
52
+ * answers a different question convincingly. Anything here that reads a list the
53
+ * project's tree decides the length of belongs in this pool, whatever the
54
+ * function around it is called.
50
55
  *
51
56
  * The order `fn` is *called* in is the input order; the order it *completes* in
52
57
  * is not, so a caller that needs a stable result either indexes into a
53
- * preallocated array by `index` or sorts afterwards. Both callers here do one of
54
- * those, deliberately.
58
+ * preallocated array by `index` or sorts afterwards. All three callers here do
59
+ * one of those, deliberately.
55
60
  *
56
- * No result is collected and none is needed — both callers write into something
57
- * they already own, and a version returning `T[]` would have to choose an
61
+ * No result is collected and none is needed — every caller writes into something
62
+ * it already owns, and a version returning `T[]` would have to choose an
58
63
  * ordering on their behalf. A throw from `fn` propagates and abandons the rest,
59
- * which is the existing behaviour at both sites: `parseSpecs` catches per file
60
- * so that one hostile spec scraps only itself (ATX-65), and a failed `readdir`
61
- * really does end the walk.
64
+ * which is the existing behaviour at all three sites: `parseSpecs` and
65
+ * `loadRegistry` catch per file so that one hostile source scraps only itself
66
+ * (ATX-65), and a failed `readdir` really does end the walk.
62
67
  */
63
68
  async function forEachBounded(items, limit, fn) {
64
69
  let cursor = 0;
@@ -299,6 +304,14 @@ async function declaredIds(absPath) {
299
304
  return [];
300
305
  }
301
306
  }
307
+ /**
308
+ * How many registry files are read at once. A third figure equal to the other
309
+ * two and, for the reason `WALK_CONCURRENCY` gives, a third constant: what this
310
+ * bounds is neither directories nor spec sources but whatever the *reader*
311
+ * holds — a source and a `SourceFile` under `staticReader`, a Vite SSR module
312
+ * evaluation under `evalReader` — and those are not tunable together.
313
+ */
314
+ const REGISTRY_CONCURRENCY = 32;
302
315
  /**
303
316
  * Read and merge every `*.reqs.ts` registry under root, with the given reader.
304
317
  * A registry the reader rejects becomes its own ERROR; a duplicate id across
@@ -317,7 +330,16 @@ export async function loadRegistry(root, reader, files) {
317
330
  const paths = files ?? (await scanProject(root)).reqsFiles;
318
331
  // Read the files concurrently, then fold the results in sorted file order:
319
332
  // the issue list stays deterministic regardless of which one finished first.
320
- const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await readGuarded(reader, file) })));
333
+ //
334
+ // Indexed rather than appended, exactly as `parseSpecs` does it and for the
335
+ // same reason: `forEachBounded` *calls* in input order and *completes* in
336
+ // whatever order the reads finish, so the fold below is driven by position.
337
+ const outcomes = new Array(paths.length);
338
+ await forEachBounded(paths, REGISTRY_CONCURRENCY, async (file, i) => {
339
+ // `readGuarded` catches, so nothing here can throw and abandon the rest —
340
+ // which is the one thing the pool does not do for its caller.
341
+ outcomes[i] = await readGuarded(reader, file);
342
+ });
321
343
  const registry = {};
322
344
  const issues = [];
323
345
  const unreadable = [];
@@ -331,9 +353,10 @@ export async function loadRegistry(root, reader, files) {
331
353
  // have to turn a display path back into one; the messages below relativise at
332
354
  // the point they are built, which is where the reader's path belongs anyway.
333
355
  const prefixOwner = new Map();
334
- for (const entry of loaded) {
335
- const display = relativePath(root, entry.file);
336
- const { outcome } = entry;
356
+ for (let i = 0; i < paths.length; i += 1) {
357
+ const file = paths[i];
358
+ const outcome = outcomes[i];
359
+ const display = relativePath(root, file);
337
360
  if ('issue' in outcome) {
338
361
  issues.push({ ...outcome.issue, file: display });
339
362
  // Read the source again for the ids alone. Both readers land here, and the
@@ -342,7 +365,7 @@ export async function loadRegistry(root, reader, files) {
342
365
  // is already an ERROR is not a cost worth arranging around, and sharing
343
366
  // the reader's source instead would mean the two readers passing different
344
367
  // things to one diagnostic.
345
- unreadable.push({ file: display, ids: await declaredIds(entry.file) });
368
+ unreadable.push({ file: display, ids: await declaredIds(file) });
346
369
  continue;
347
370
  }
348
371
  // One issue per colliding prefix rather than per requirement: the fact is
@@ -353,9 +376,9 @@ export async function loadRegistry(root, reader, files) {
353
376
  const prefix = idPrefix(id);
354
377
  const owner = prefixOwner.get(prefix);
355
378
  if (owner === undefined) {
356
- prefixOwner.set(prefix, entry.file);
379
+ prefixOwner.set(prefix, file);
357
380
  }
358
- else if (owner !== entry.file && !reported.has(prefix)) {
381
+ else if (owner !== file && !reported.has(prefix)) {
359
382
  reported.add(prefix);
360
383
  // No `reqId`: this is about two files, not about any one of the
361
384
  // requirements that happen to reveal it.
@@ -239,7 +239,7 @@ export function spliceModifications(file, source, changes) {
239
239
  // Ids the file does not hold, first and in code-unit order: they have no
240
240
  // position to be reported at, and the walk below is driven by positions.
241
241
  for (const id of [...wanted.keys()].sort(byCodeUnit)) {
242
- if (!layouts[id])
242
+ if (!layouts.has(id))
243
243
  refusals.push({ reqId: id, reason: 'entry-not-found' });
244
244
  }
245
245
  // **The file front to back, not the delta.** Driving the walk from the layout
@@ -250,7 +250,10 @@ export function spliceModifications(file, source, changes) {
250
250
  // bottom be a `reverse()` rather than a numeric sort, which ATX-15 does not
251
251
  // allow in `src/` and would be the wrong shape anyway — an ordering that holds
252
252
  // by construction beats one restored after the fact.
253
- for (const [id, layout] of Object.entries(layouts)) {
253
+ // It is also why the layout is `Map`s rather than objects at every level; the
254
+ // container is what carries the order, and `registryEntryLayouts` says why an
255
+ // object cannot.
256
+ for (const [id, layout] of layouts) {
254
257
  const change = wanted.get(id);
255
258
  if (!change)
256
259
  continue;
@@ -280,7 +283,7 @@ export function spliceModifications(file, source, changes) {
280
283
  const openField = (name, value) => {
281
284
  opened.push(`${layout.fieldInsertion.indent}${name}: ${value}`);
282
285
  };
283
- for (const [name, span] of Object.entries(layout.fields)) {
286
+ for (const [name, span] of layout.fields) {
284
287
  if (name === 'statement' || name === 'rationale') {
285
288
  if (before[name] !== after[name])
286
289
  out.at(name, span, tsString(after[name]));
@@ -296,18 +299,18 @@ export function spliceModifications(file, source, changes) {
296
299
  // Any other field is one the schema does not define, and not this
297
300
  // module's to rewrite or to remove.
298
301
  }
299
- if (!layout.fields['statement'] && before.statement !== after.statement) {
302
+ if (!layout.fields.has('statement') && before.statement !== after.statement) {
300
303
  openField('statement', tsString(after.statement));
301
304
  }
302
- if (!layout.fields['rationale'] && before.rationale !== after.rationale) {
305
+ if (!layout.fields.has('rationale') && before.rationale !== after.rationale) {
303
306
  openField('rationale', tsString(after.rationale));
304
307
  }
305
- if (!layout.fields['params']) {
308
+ if (!layout.fields.has('params')) {
306
309
  const fresh = freshParams(before, after);
307
310
  if (fresh.length > 0)
308
311
  openField('params', `{ ${fresh.join(', ')} }`);
309
312
  }
310
- if (!layout.fields['outOfScope'] && !sameValue(before.outOfScope, after.outOfScope)) {
313
+ if (!layout.fields.has('outOfScope') && !sameValue(before.outOfScope, after.outOfScope)) {
311
314
  openField('outOfScope', outOfScopeSource(after.outOfScope));
312
315
  }
313
316
  if (opened.length > 0) {
@@ -360,11 +363,11 @@ function editParams(layout, before, after, out) {
360
363
  // Named rather than skipped, so the guard does not depend on something
361
364
  // upstream holding.
362
365
  for (const key of Object.keys(before.params).sort(byCodeUnit)) {
363
- if (!layout.paramKeys[key] && !sameValue(before.params[key], after.params[key])) {
366
+ if (!layout.paramKeys.has(key) && !sameValue(before.params[key], after.params[key])) {
364
367
  out.refuse(`params.${key}`, 'not-a-literal');
365
368
  }
366
369
  }
367
- for (const [key, span] of Object.entries(layout.paramKeys)) {
370
+ for (const [key, span] of layout.paramKeys) {
368
371
  if (!Object.hasOwn(after.params, key))
369
372
  continue;
370
373
  const value = after.params[key];
@@ -123,10 +123,13 @@ export interface ValueSpan {
123
123
  * this hands back, so they cannot be written over by anything using it.
124
124
  */
125
125
  export interface RegistryEntryLayout {
126
- /** Value span of each field the entry writes, by field name. */
127
- fields: Record<string, ValueSpan>;
128
- /** Value span of each key of `params`, when the entry writes one as a literal. */
129
- paramKeys: Record<string, ValueSpan>;
126
+ /** Value span of each field the entry writes, by field name, in file order. */
127
+ fields: Map<string, ValueSpan>;
128
+ /**
129
+ * Value span of each key of `params`, when the entry writes one as a literal,
130
+ * in file order.
131
+ */
132
+ paramKeys: Map<string, ValueSpan>;
130
133
  /** Where a new `params` key goes — absent when the entry writes no `params`. */
131
134
  paramsInsertion?: RegistryInsertion;
132
135
  /** Where a new field goes, inside the entry's own body. */
@@ -141,6 +144,16 @@ export interface RegistryEntryLayout {
141
144
  * this cannot describe (a computed key, a value built by a call) are absent from
142
145
  * the result rather than partially described, which the caller reads as a
143
146
  * refusal for that id: an entry nothing can locate is not one to edit.
147
+ *
148
+ * **Maps at all three levels, because the iteration order is the contract.**
149
+ * `spliceModifications` walks this in file order to get its edits in ascending
150
+ * offset order, which is what lets it apply them with a `reverse()` rather than
151
+ * a sort. A plain object cannot carry that order: an *integer-like* key is
152
+ * hoisted to the front of every JavaScript object, and a `params` key can be
153
+ * one — `staticName` spells a numeric literal key as `String(value)`, and the
154
+ * schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
155
+ * order for every key type, so the property holds by construction rather than by
156
+ * the keys happening not to be numbers.
144
157
  */
145
- export declare function registryEntryLayouts(file: string, source: string): Record<string, RegistryEntryLayout> | undefined;
158
+ export declare function registryEntryLayouts(file: string, source: string): Map<string, RegistryEntryLayout> | undefined;
146
159
  //# sourceMappingURL=static-registry.d.ts.map
@@ -510,13 +510,23 @@ function objectInsertion(sf, source, obj) {
510
510
  * this cannot describe (a computed key, a value built by a call) are absent from
511
511
  * the result rather than partially described, which the caller reads as a
512
512
  * refusal for that id: an entry nothing can locate is not one to edit.
513
+ *
514
+ * **Maps at all three levels, because the iteration order is the contract.**
515
+ * `spliceModifications` walks this in file order to get its edits in ascending
516
+ * offset order, which is what lets it apply them with a `reverse()` rather than
517
+ * a sort. A plain object cannot carry that order: an *integer-like* key is
518
+ * hoisted to the front of every JavaScript object, and a `params` key can be
519
+ * one — `staticName` spells a numeric literal key as `String(value)`, and the
520
+ * schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
521
+ * order for every key type, so the property holds by construction rather than by
522
+ * the keys happening not to be numbers.
513
523
  */
514
524
  export function registryEntryLayouts(file, source) {
515
525
  const found = registryLiteral(file, source);
516
526
  if (!found)
517
527
  return undefined;
518
528
  const { sf, literal } = found;
519
- const layouts = {};
529
+ const layouts = new Map();
520
530
  for (const entry of literal.properties) {
521
531
  if (!ts.isPropertyAssignment(entry))
522
532
  continue;
@@ -526,8 +536,8 @@ export function registryEntryLayouts(file, source) {
526
536
  const body = unwrap(entry.initializer);
527
537
  if (!ts.isObjectLiteralExpression(body))
528
538
  continue;
529
- const fields = {};
530
- const paramKeys = {};
539
+ const fields = new Map();
540
+ const paramKeys = new Map();
531
541
  let params;
532
542
  for (const field of body.properties) {
533
543
  if (!ts.isPropertyAssignment(field))
@@ -538,7 +548,7 @@ export function registryEntryLayouts(file, source) {
538
548
  // The unwrapped value, so an `as const` or a parenthesis stays outside the
539
549
  // span and survives the replacement it wraps.
540
550
  const value = unwrap(field.initializer);
541
- fields[name] = { start: value.getStart(sf), end: value.getEnd() };
551
+ fields.set(name, { start: value.getStart(sf), end: value.getEnd() });
542
552
  if (name === 'params' && ts.isObjectLiteralExpression(value))
543
553
  params = value;
544
554
  }
@@ -550,15 +560,15 @@ export function registryEntryLayouts(file, source) {
550
560
  if (key === undefined || key === '__proto__')
551
561
  continue;
552
562
  const value = unwrap(param.initializer);
553
- paramKeys[key] = { start: value.getStart(sf), end: value.getEnd() };
563
+ paramKeys.set(key, { start: value.getStart(sf), end: value.getEnd() });
554
564
  }
555
565
  }
556
- layouts[id] = {
566
+ layouts.set(id, {
557
567
  fields,
558
568
  paramKeys,
559
569
  ...(params ? { paramsInsertion: objectInsertion(sf, source, params) } : {}),
560
570
  fieldInsertion: objectInsertion(sf, source, body),
561
- };
571
+ });
562
572
  }
563
573
  return layouts;
564
574
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "TDD-native spec framework: tests are the source of truth for verification, ID-bound requirements the source of truth for intent.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -25,7 +25,8 @@
25
25
  "./define": {
26
26
  "types": "./dist/core/registry.d.ts",
27
27
  "default": "./dist/core/registry.js"
28
- }
28
+ },
29
+ "./package.json": "./package.json"
29
30
  },
30
31
  "files": [
31
32
  "dist",