@am_shork/attest 0.8.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.
@@ -36,6 +36,64 @@ export declare function requirementSource(id: string, req: Requirement, indent:
36
36
  * the same delta must produce the same file twice.
37
37
  */
38
38
  export declare function spliceRequirements(file: string, source: string, additions: Registry): string | undefined;
39
+ /**
40
+ * One requirement a change modifies: what the registry holds, and what the gate
41
+ * proved the end state to be.
42
+ *
43
+ * Both halves, rather than the delta's patch, for the reason `MergeInputs.applied`
44
+ * exists — the patch is the authoring shape and the end state is what was proved
45
+ * green. Which fields actually move is then a *derived* fact, computed here by
46
+ * comparing the two, so a delta that restates a value it does not change writes
47
+ * nothing.
48
+ */
49
+ export interface Modification {
50
+ id: string;
51
+ /** The entry as the file holds it — the gate's `base`. */
52
+ before: Requirement;
53
+ /** The entry as the gate proved it — `applied`. */
54
+ after: Requirement;
55
+ }
56
+ /**
57
+ * Why one value could not be written over, if it could not.
58
+ *
59
+ * `field` is `statement`, `outOfScope`, `params.totpWindowSec` — the path of the
60
+ * value, spelled the way the author would say it — and absent only when the
61
+ * refusal is about the entry rather than a value in it. The caller turns these
62
+ * into diagnostics; nothing here writes prose, because a refusal from this
63
+ * module refuses a whole merge and only the caller knows what to say about that.
64
+ */
65
+ export interface ModifyRefusal {
66
+ reqId: string;
67
+ field?: string;
68
+ reason: 'comment' | 'entry-not-found' | 'not-a-literal' | 'param-dropped';
69
+ }
70
+ export type ModifyResult = {
71
+ ok: true;
72
+ source: string;
73
+ } | {
74
+ ok: false;
75
+ refusals: ModifyRefusal[];
76
+ };
77
+ /**
78
+ * `source` with each modified requirement's **changed values** written over.
79
+ *
80
+ * The other half of `--apply`'s write-back, and the one the design refused for
81
+ * five releases. What makes it allowable is stated at the top of this file and
82
+ * is a property of *granularity*: this never replaces an entry, only the span of
83
+ * a value inside one. A `statement` is a single string literal, so a comment
84
+ * cannot be inside what it overwrites; a `params` key or an `outOfScope` list
85
+ * can be, and that span is the only place this has to ask. Everything else in
86
+ * the file — the keys, the commas, the layout, every comment attached to a field
87
+ * that did not move — is bytes this does not address.
88
+ *
89
+ * A refusal is per value and the caller's response is whole-merge: the list is
90
+ * returned complete rather than at the first one, so an author fixing them by
91
+ * hand sees the whole of what stopped it.
92
+ *
93
+ * `undefined` under the same condition `spliceRequirements` returns it, through
94
+ * the same reader.
95
+ */
96
+ export declare function spliceModifications(file: string, source: string, changes: readonly Modification[]): ModifyResult | undefined;
39
97
  /**
40
98
  * `source` with every import of `from` repointed at `to`.
41
99
  *
@@ -45,9 +103,10 @@ export declare function spliceRequirements(file: string, source: string, additio
45
103
  * discipline as the splice, for the same reason.
46
104
  *
47
105
  * The extension is taken from the specifier being replaced rather than chosen
48
- * here. Whether a project writes `./x.reqs.js` or `./x.reqs.ts` is a property of
49
- * its module resolution, uniform across the project, and already answered by the
50
- * specifier sitting in front of us.
106
+ * here. Whether a project writes `./x.reqs.js`, `./x.reqs.ts` or `./x.reqs` is a
107
+ * property of its module resolution, uniform across the project, and already
108
+ * answered by the specifier sitting in front of us — the third spelling
109
+ * included, where the answer is "none".
51
110
  */
52
111
  export declare function repointImport(file: string, source: string, from: string, to: string): string;
53
112
  //# sourceMappingURL=splice.d.ts.map
@@ -14,6 +14,19 @@
14
14
  // and the only edit made to the file is text inserted at it. Every other byte is
15
15
  // the byte that was already there, which is a property a test can state.
16
16
  //
17
+ // **MODIFIED is the second shape, and it is not an insertion — so it is bounded
18
+ // instead.** `spliceModifications` writes a changed value over the value that was
19
+ // there, and the clause above does not cover it: a re-read verifies the values
20
+ // and is blind to what was *lost*, so a replacement that ate a comment would pass
21
+ // one cleanly. What stands in for the pure-insertion property is the **size of
22
+ // the span**. Nothing here replaces an entry; it replaces the source span of one
23
+ // value inside an entry, which is why `statement` and `rationale` are free —
24
+ // their span is a single string literal and a comment does not fit inside a
25
+ // token — and why a `params` key or an `outOfScope` list is checked for one
26
+ // before it is written over, and refused if it carries it. That is the whole of
27
+ // the difference from REMOVED, which is still refused: deleting an entry has no
28
+ // smaller span to fall back to, so its comments have nowhere to go.
29
+ //
17
30
  // The generated text is therefore the one thing here that has to be right on its
18
31
  // own, and it is generated conservatively: strings are escaped rather than
19
32
  // interpolated, params are emitted in code-unit key order so the same delta
@@ -21,7 +34,7 @@
21
34
  // reach this file because the gate validated the delta before `--apply` ran.
22
35
  import ts from 'typescript';
23
36
  import { dirname, relative, resolve } from 'node:path';
24
- import { registryInsertionPoint } from './static-registry.js';
37
+ import { registryEntryLayouts, registryInsertionPoint } from './static-registry.js';
25
38
  import { toPosixPath } from './paths.js';
26
39
  import { byCodeUnit, sortDeep } from './order.js';
27
40
  /**
@@ -155,15 +168,23 @@ export function requirementSource(id, req, indent) {
155
168
  // are as much of the emitted text as the outer ones.
156
169
  const params = Object.entries(sortDeep(req.params));
157
170
  if (params.length > 0) {
158
- const body = params.map(([k, v]) => `${keySource(k)}: ${paramSource(v)}`).join(', ');
171
+ const body = params.map(([k, v]) => paramEntrySource(k, v)).join(', ');
159
172
  lines.push(`${inner}params: { ${body} },`);
160
173
  }
161
174
  if (req.outOfScope.length > 0) {
162
- lines.push(`${inner}outOfScope: [${req.outOfScope.map((s) => tsString(s)).join(', ')}],`);
175
+ lines.push(`${inner}outOfScope: ${outOfScopeSource(req.outOfScope)},`);
163
176
  }
164
177
  lines.push(`${indent}}`);
165
178
  return lines.join('\n');
166
179
  }
180
+ /** One `outOfScope` list as source. Shared with the modification writer below. */
181
+ function outOfScopeSource(entries) {
182
+ return `[${entries.map((s) => tsString(s)).join(', ')}]`;
183
+ }
184
+ /** One `params` key as source, at the key order the emitter writes everywhere. */
185
+ function paramEntrySource(key, value) {
186
+ return `${keySource(key)}: ${paramSource(sortDeep(value))}`;
187
+ }
167
188
  /**
168
189
  * `source` with `additions` inserted into its registry literal.
169
190
  *
@@ -187,6 +208,218 @@ export function spliceRequirements(file, source, additions) {
187
208
  const text = point.leadingComma ? `,\n${entries}` : `\n${entries},\n`;
188
209
  return source.slice(0, point.offset) + text + source.slice(point.offset);
189
210
  }
211
+ /**
212
+ * `source` with each modified requirement's **changed values** written over.
213
+ *
214
+ * The other half of `--apply`'s write-back, and the one the design refused for
215
+ * five releases. What makes it allowable is stated at the top of this file and
216
+ * is a property of *granularity*: this never replaces an entry, only the span of
217
+ * a value inside one. A `statement` is a single string literal, so a comment
218
+ * cannot be inside what it overwrites; a `params` key or an `outOfScope` list
219
+ * can be, and that span is the only place this has to ask. Everything else in
220
+ * the file — the keys, the commas, the layout, every comment attached to a field
221
+ * that did not move — is bytes this does not address.
222
+ *
223
+ * A refusal is per value and the caller's response is whole-merge: the list is
224
+ * returned complete rather than at the first one, so an author fixing them by
225
+ * hand sees the whole of what stopped it.
226
+ *
227
+ * `undefined` under the same condition `spliceRequirements` returns it, through
228
+ * the same reader.
229
+ */
230
+ export function spliceModifications(file, source, changes) {
231
+ if (changes.length === 0)
232
+ return { ok: true, source };
233
+ const layouts = registryEntryLayouts(file, source);
234
+ if (!layouts)
235
+ return undefined;
236
+ const refusals = [];
237
+ const edits = [];
238
+ const wanted = new Map(changes.map((change) => [change.id, change]));
239
+ // Ids the file does not hold, first and in code-unit order: they have no
240
+ // position to be reported at, and the walk below is driven by positions.
241
+ for (const id of [...wanted.keys()].sort(byCodeUnit)) {
242
+ if (!layouts.has(id))
243
+ refusals.push({ reqId: id, reason: 'entry-not-found' });
244
+ }
245
+ // **The file front to back, not the delta.** Driving the walk from the layout
246
+ // rather than from `changes` is what makes every edit below land at a higher
247
+ // offset than the one before it — entries in the order the file writes them,
248
+ // fields in the order the entry writes them, params in the order the entry
249
+ // writes those. That ordering is not cosmetic: it is what lets the pass at the
250
+ // bottom be a `reverse()` rather than a numeric sort, which ATX-15 does not
251
+ // allow in `src/` and would be the wrong shape anyway — an ordering that holds
252
+ // by construction beats one restored after the fact.
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) {
257
+ const change = wanted.get(id);
258
+ if (!change)
259
+ continue;
260
+ const { before, after } = change;
261
+ // Everything this entry writes goes through one of these three, which is
262
+ // what lets `editParams` be a function rather than a fourth copy of them.
263
+ const out = {
264
+ at: (field, span, text) => {
265
+ // The one question a replacement has that an insertion does not. Run over
266
+ // every span rather than only the two that can fail it, so the rule is
267
+ // "nothing is written over a comment" rather than a list of fields somebody
268
+ // has to keep in step with the schema — on `statement` and `rationale` it
269
+ // is a scan of one token and can never fire.
270
+ if (spanHasComment(source.slice(span.start, span.end))) {
271
+ refusals.push({ reqId: id, field, reason: 'comment' });
272
+ return;
273
+ }
274
+ edits.push({ start: span.start, end: span.end, text });
275
+ },
276
+ insert: (offset, text) => edits.push({ start: offset, end: offset, text }),
277
+ refuse: (field, reason) => refusals.push({ reqId: id, field, reason }),
278
+ };
279
+ // Fields the entry does not write yet. Collected rather than emitted, since
280
+ // they share one offset — the end of the entry's body — and several edits at
281
+ // one position is the one thing the back-to-front pass cannot order.
282
+ const opened = [];
283
+ const openField = (name, value) => {
284
+ opened.push(`${layout.fieldInsertion.indent}${name}: ${value}`);
285
+ };
286
+ for (const [name, span] of layout.fields) {
287
+ if (name === 'statement' || name === 'rationale') {
288
+ if (before[name] !== after[name])
289
+ out.at(name, span, tsString(after[name]));
290
+ }
291
+ else if (name === 'outOfScope') {
292
+ if (!sameValue(before.outOfScope, after.outOfScope)) {
293
+ out.at(name, span, outOfScopeSource(after.outOfScope));
294
+ }
295
+ }
296
+ else if (name === 'params') {
297
+ editParams(layout, before, after, out);
298
+ }
299
+ // Any other field is one the schema does not define, and not this
300
+ // module's to rewrite or to remove.
301
+ }
302
+ if (!layout.fields.has('statement') && before.statement !== after.statement) {
303
+ openField('statement', tsString(after.statement));
304
+ }
305
+ if (!layout.fields.has('rationale') && before.rationale !== after.rationale) {
306
+ openField('rationale', tsString(after.rationale));
307
+ }
308
+ if (!layout.fields.has('params')) {
309
+ const fresh = freshParams(before, after);
310
+ if (fresh.length > 0)
311
+ openField('params', `{ ${fresh.join(', ')} }`);
312
+ }
313
+ if (!layout.fields.has('outOfScope') && !sameValue(before.outOfScope, after.outOfScope)) {
314
+ openField('outOfScope', outOfScopeSource(after.outOfScope));
315
+ }
316
+ if (opened.length > 0) {
317
+ const point = layout.fieldInsertion;
318
+ const body = opened.join(',\n');
319
+ // The comma leads rather than trails, for the reason the registry splice's
320
+ // does: the offset sits *before* whatever trailing comma the entry already
321
+ // carries, so writing one here would produce two.
322
+ edits.push({
323
+ start: point.offset,
324
+ end: point.offset,
325
+ text: point.leadingComma ? `,\n${body}` : `\n${body},\n`,
326
+ });
327
+ }
328
+ }
329
+ if (refusals.length > 0)
330
+ return { ok: false, refusals };
331
+ // Back to front, for the reason `repointImport` applies its edits that way: an
332
+ // edit cannot move an offset that lies before it, so every span stays valid as
333
+ // the string is rewritten under them. A `reverse()` and not a sort, because
334
+ // the walk above produced them in source order.
335
+ let out = source;
336
+ for (const edit of [...edits].reverse()) {
337
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
338
+ }
339
+ return { ok: true, source: out };
340
+ }
341
+ /**
342
+ * The `params` half of one entry: a value per changed key, and one insertion for
343
+ * the keys the entry does not have.
344
+ *
345
+ * Split out because it is the only field with structure of its own, and inlining
346
+ * it put three loops inside a loop inside a loop. Its edits are emitted in the
347
+ * order the file writes the keys, then the insertion at the end of the object —
348
+ * which is what keeps the caller's ascending-offset property true through it.
349
+ */
350
+ function editParams(layout, before, after, out) {
351
+ // A key the end state has lost. Unreachable through the command — `applyDelta`
352
+ // merges `params` key by key and a patch has no spelling for a deletion — and
353
+ // refused rather than ignored, for the reason `keySource` refuses `__proto__`:
354
+ // this is the module that *writes*, and silently keeping a value the proved
355
+ // end state does not have is the one outcome worse than stopping.
356
+ for (const key of Object.keys(before.params).sort(byCodeUnit)) {
357
+ if (!Object.hasOwn(after.params, key)) {
358
+ out.refuse(`params.${key}`, 'param-dropped');
359
+ }
360
+ }
361
+ // A key the file holds and this cannot locate: its value is not written as a
362
+ // literal, which the registry reader refuses before a merge can reach here.
363
+ // Named rather than skipped, so the guard does not depend on something
364
+ // upstream holding.
365
+ for (const key of Object.keys(before.params).sort(byCodeUnit)) {
366
+ if (!layout.paramKeys.has(key) && !sameValue(before.params[key], after.params[key])) {
367
+ out.refuse(`params.${key}`, 'not-a-literal');
368
+ }
369
+ }
370
+ for (const [key, span] of layout.paramKeys) {
371
+ if (!Object.hasOwn(after.params, key))
372
+ continue;
373
+ const value = after.params[key];
374
+ if (sameValue(before.params[key], value))
375
+ continue;
376
+ // The value alone: the span is the value's, and the key in front of it is a
377
+ // byte this does not address — the same rule that keeps the comma, the
378
+ // comment and the layout where they are.
379
+ out.at(`params.${key}`, span, paramSource(sortDeep(value)));
380
+ }
381
+ const fresh = freshParams(before, after);
382
+ if (fresh.length === 0)
383
+ return;
384
+ if (!layout.paramsInsertion) {
385
+ // A `params` that is not an object literal, so there is no inside to write
386
+ // into. Same guard as the keys above, one level out.
387
+ out.refuse('params', 'not-a-literal');
388
+ return;
389
+ }
390
+ const point = layout.paramsInsertion;
391
+ out.insert(point.offset, point.leadingComma ? `, ${fresh.join(', ')}` : ` ${fresh.join(', ')} `);
392
+ }
393
+ /** The params the end state has and the file does not, as source, in key order. */
394
+ function freshParams(before, after) {
395
+ return Object.keys(after.params)
396
+ .sort(byCodeUnit)
397
+ .filter((key) => !Object.hasOwn(before.params, key))
398
+ .map((key) => paramEntrySource(key, after.params[key]));
399
+ }
400
+ /**
401
+ * Whether a span of source carries a comment.
402
+ *
403
+ * Scanned rather than matched, and that is the whole of why it is correct: `//`
404
+ * inside a string is not a comment, and a registry's statements are prose full
405
+ * of URLs. The scanner is the same one the reader's parser is built on, so the
406
+ * two agree about what a comment is by construction.
407
+ */
408
+ function spanHasComment(text) {
409
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, /* skipTrivia */ false);
410
+ scanner.setText(text);
411
+ for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
412
+ if (token === ts.SyntaxKind.SingleLineCommentTrivia ||
413
+ token === ts.SyntaxKind.MultiLineCommentTrivia) {
414
+ return true;
415
+ }
416
+ }
417
+ return false;
418
+ }
419
+ /** Content equality for one value, by the canonical form the engine compares on. */
420
+ function sameValue(a, b) {
421
+ return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b));
422
+ }
190
423
  /**
191
424
  * `source` with every import of `from` repointed at `to`.
192
425
  *
@@ -196,9 +429,10 @@ export function spliceRequirements(file, source, additions) {
196
429
  * discipline as the splice, for the same reason.
197
430
  *
198
431
  * The extension is taken from the specifier being replaced rather than chosen
199
- * here. Whether a project writes `./x.reqs.js` or `./x.reqs.ts` is a property of
200
- * its module resolution, uniform across the project, and already answered by the
201
- * specifier sitting in front of us.
432
+ * here. Whether a project writes `./x.reqs.js`, `./x.reqs.ts` or `./x.reqs` is a
433
+ * property of its module resolution, uniform across the project, and already
434
+ * answered by the specifier sitting in front of us — the third spelling
435
+ * included, where the answer is "none".
202
436
  */
203
437
  export function repointImport(file, source, from, to) {
204
438
  const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
@@ -212,10 +446,13 @@ export function repointImport(file, source, from, to) {
212
446
  continue;
213
447
  if (!resolvesTo(dir, spec.text, from))
214
448
  continue;
449
+ // Unconditional, because "no extension" is an answer and not a missing one:
450
+ // a bundler-resolution project writes every specifier that way, so the empty
451
+ // case has to strip what `to` carries rather than leave it. Guarding this on
452
+ // a non-empty `ext` is only safe while `resolvesTo` rejects an extensionless
453
+ // specifier — the two move together.
215
454
  const ext = spec.text.endsWith('.js') ? '.js' : spec.text.endsWith('.ts') ? '.ts' : '';
216
- let target = toPosixPath(relative(dir, to));
217
- if (ext)
218
- target = target.replace(/\.[^./]+$/, ext);
455
+ let target = toPosixPath(relative(dir, to)).replace(/\.[^./]+$/, ext);
219
456
  // A bare `x.reqs.js` is a package specifier, not a sibling file.
220
457
  if (!target.startsWith('.'))
221
458
  target = `./${target}`;
@@ -248,14 +485,19 @@ export function repointImport(file, source, from, to) {
248
485
  /**
249
486
  * Whether `specifier`, written in a file under `dir`, names `target`.
250
487
  *
251
- * The `.js`-for-`.ts` spelling is accepted because NodeNext resolution requires
252
- * it, and a project using it writes every specifier that way — including the one
253
- * this is looking for.
488
+ * Three spellings of one import, because module resolution decides which a
489
+ * project writes and it writes that one everywhere — including here. The
490
+ * `.js`-for-`.ts` spelling is what NodeNext requires; the extensionless one is
491
+ * what bundler resolution produces, and matching it is the whole reason a
492
+ * specifier can be repointed at all in such a project.
254
493
  */
255
494
  function resolvesTo(dir, specifier, target) {
256
495
  if (!specifier.startsWith('.'))
257
496
  return false;
258
497
  const resolved = resolve(dir, specifier);
259
- return resolved === target || resolved.replace(/\.js$/, '.ts') === target;
498
+ // Only `.ts` is appended, not `.js` as well: `target` is the delta, whose name
499
+ // is the constant `requirements.delta.ts`, so the symmetrical clause would be
500
+ // unreachable — a hypothetical seam rather than a real one.
501
+ return (resolved === target || resolved.replace(/\.js$/, '.ts') === target || `${resolved}.ts` === target);
260
502
  }
261
503
  //# sourceMappingURL=splice.js.map
@@ -101,4 +101,59 @@ export interface RegistryInsertion {
101
101
  leadingComma: boolean;
102
102
  }
103
103
  export declare function registryInsertionPoint(file: string, source: string): RegistryInsertion | undefined;
104
+ /** The source span of a value, as offsets into the file it was read from. */
105
+ export interface ValueSpan {
106
+ start: number;
107
+ end: number;
108
+ }
109
+ /**
110
+ * Where each part of one requirement's entry sits in the file, for a merge that
111
+ * has to **replace** a value rather than add one (design §7).
112
+ *
113
+ * This is the reading half of writing MODIFIED back. A splice is a pure
114
+ * insertion and is safe by construction; a modification is not, and the property
115
+ * that replaces "every other byte is the byte that was there" is the *size of
116
+ * what it overwrites*. So nothing here describes an entry — it describes the
117
+ * spans of its individual values, and the writer replaces one value at a time.
118
+ * Note what that buys at the top of `splice.ts`: a `statement` is one string
119
+ * literal, and a comment does not fit inside a token.
120
+ *
121
+ * The spans are of the values, never of the properties that hold them: the key,
122
+ * the colon, the comma and every comment around them are outside every span
123
+ * this hands back, so they cannot be written over by anything using it.
124
+ */
125
+ export interface RegistryEntryLayout {
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>;
133
+ /** Where a new `params` key goes — absent when the entry writes no `params`. */
134
+ paramsInsertion?: RegistryInsertion;
135
+ /** Where a new field goes, inside the entry's own body. */
136
+ fieldInsertion: RegistryInsertion;
137
+ }
138
+ /**
139
+ * The layout of every entry in a registry file, by requirement id.
140
+ *
141
+ * `undefined` under exactly the condition `registryInsertionPoint` returns it —
142
+ * one walker answers "where is this registry's literal" for both, so a file the
143
+ * splice refuses cannot be one the modification accepts. Entries whose *shape*
144
+ * this cannot describe (a computed key, a value built by a call) are absent from
145
+ * the result rather than partially described, which the caller reads as a
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.
157
+ */
158
+ export declare function registryEntryLayouts(file: string, source: string): Map<string, RegistryEntryLayout> | undefined;
104
159
  //# sourceMappingURL=static-registry.d.ts.map
@@ -425,12 +425,32 @@ function objectValue(expr) {
425
425
  return out;
426
426
  }
427
427
  function propertyName(name) {
428
+ const key = staticName(name);
429
+ if (key !== undefined)
430
+ return key;
431
+ // The reason is re-derived here rather than carried out of `staticName`,
432
+ // which has no diagnostics to give: the extraction refuses a whole file over
433
+ // this, so the sentence has to name which of the two it met.
434
+ throw new NotStatic(name, ts.isNumericLiteral(name) ? 'a number this reader cannot spell out' : 'a computed key');
435
+ }
436
+ /**
437
+ * A property's key when the source text alone fixes it, `undefined` when it does
438
+ * not.
439
+ *
440
+ * The total half of `propertyName`, which is the form the layout reader needs:
441
+ * that one promises a result for a whole file and cannot throw over one entry it
442
+ * merely fails to describe. One function for both so a key the extractor reads
443
+ * and a key the writer locates can never be two different strings.
444
+ */
445
+ function staticName(name) {
428
446
  if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) {
429
447
  return name.text;
430
448
  }
431
- if (ts.isNumericLiteral(name))
432
- return String(numericValue(name));
433
- throw new NotStatic(name, 'a computed key');
449
+ if (ts.isNumericLiteral(name)) {
450
+ const value = numericText(name);
451
+ return value === undefined ? undefined : String(value);
452
+ }
453
+ return undefined;
434
454
  }
435
455
  /**
436
456
  * The value of a numeric literal, read from its source spelling.
@@ -441,12 +461,127 @@ function propertyName(name) {
441
461
  * first, since `Number('1_000')` is NaN.
442
462
  */
443
463
  function numericValue(node) {
444
- const value = Number(node.getText().replace(/_/g, ''));
445
- if (Number.isNaN(value))
464
+ const value = numericText(node);
465
+ if (value === undefined)
446
466
  throw new NotStatic(node, 'a number this reader cannot spell out');
447
467
  return value;
448
468
  }
469
+ /** The same reading, without the refusal — see `staticName`. */
470
+ function numericText(node) {
471
+ const value = Number(node.getText().replace(/_/g, ''));
472
+ return Number.isNaN(value) ? undefined : value;
473
+ }
449
474
  export function registryInsertionPoint(file, source) {
475
+ const found = registryLiteral(file, source);
476
+ return found && objectInsertion(found.sf, source, found.literal);
477
+ }
478
+ /**
479
+ * Where a new member goes in an object literal.
480
+ *
481
+ * Three sites ask, at three depths — the registry's own body, a requirement's
482
+ * body, and its `params` — and the answer is the same question each time. One
483
+ * function because the interesting half is a rule about *commas*, and a second
484
+ * copy of it is a second trailing-comma policy the moment one is edited.
485
+ */
486
+ function objectInsertion(sf, source, obj) {
487
+ const last = obj.properties[obj.properties.length - 1];
488
+ if (!last) {
489
+ // An empty body: open it rather than continue it. What separates the new
490
+ // member from the braces is the caller's, since a registry opens a line for
491
+ // its entry and a `params` writes one on the line it is already on.
492
+ return {
493
+ offset: obj.getStart(sf) + 1,
494
+ indent: indentOf(sf, source, obj) + ' ',
495
+ leadingComma: false,
496
+ };
497
+ }
498
+ // Deliberately the *end of the last property*, not the end of the literal:
499
+ // inserting here sits before any trailing comma the file already has, so one
500
+ // leading comma is correct whether or not that comma is present, and the
501
+ // file's own trailing-comma style is left exactly as it was.
502
+ return { offset: last.getEnd(), indent: indentOf(sf, source, last), leadingComma: true };
503
+ }
504
+ /**
505
+ * The layout of every entry in a registry file, by requirement id.
506
+ *
507
+ * `undefined` under exactly the condition `registryInsertionPoint` returns it —
508
+ * one walker answers "where is this registry's literal" for both, so a file the
509
+ * splice refuses cannot be one the modification accepts. Entries whose *shape*
510
+ * this cannot describe (a computed key, a value built by a call) are absent from
511
+ * the result rather than partially described, which the caller reads as a
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.
523
+ */
524
+ export function registryEntryLayouts(file, source) {
525
+ const found = registryLiteral(file, source);
526
+ if (!found)
527
+ return undefined;
528
+ const { sf, literal } = found;
529
+ const layouts = new Map();
530
+ for (const entry of literal.properties) {
531
+ if (!ts.isPropertyAssignment(entry))
532
+ continue;
533
+ const id = staticName(entry.name);
534
+ if (id === undefined || id === '__proto__')
535
+ continue;
536
+ const body = unwrap(entry.initializer);
537
+ if (!ts.isObjectLiteralExpression(body))
538
+ continue;
539
+ const fields = new Map();
540
+ const paramKeys = new Map();
541
+ let params;
542
+ for (const field of body.properties) {
543
+ if (!ts.isPropertyAssignment(field))
544
+ continue;
545
+ const name = staticName(field.name);
546
+ if (name === undefined || name === '__proto__')
547
+ continue;
548
+ // The unwrapped value, so an `as const` or a parenthesis stays outside the
549
+ // span and survives the replacement it wraps.
550
+ const value = unwrap(field.initializer);
551
+ fields.set(name, { start: value.getStart(sf), end: value.getEnd() });
552
+ if (name === 'params' && ts.isObjectLiteralExpression(value))
553
+ params = value;
554
+ }
555
+ if (params) {
556
+ for (const param of params.properties) {
557
+ if (!ts.isPropertyAssignment(param))
558
+ continue;
559
+ const key = staticName(param.name);
560
+ if (key === undefined || key === '__proto__')
561
+ continue;
562
+ const value = unwrap(param.initializer);
563
+ paramKeys.set(key, { start: value.getStart(sf), end: value.getEnd() });
564
+ }
565
+ }
566
+ layouts.set(id, {
567
+ fields,
568
+ paramKeys,
569
+ ...(params ? { paramsInsertion: objectInsertion(sf, source, params) } : {}),
570
+ fieldInsertion: objectInsertion(sf, source, body),
571
+ });
572
+ }
573
+ return layouts;
574
+ }
575
+ /**
576
+ * The `defineRequirements({ … })` literal of a registry file, with the source
577
+ * file it was parsed from.
578
+ *
579
+ * Both write-side readers go through it, for the reason there is one reader:
580
+ * "which literal is the registry" must have one answer, and a second walk that
581
+ * accepted a file the first refuses would let a modification edit a file the
582
+ * splice would not touch.
583
+ */
584
+ function registryLiteral(file, source) {
450
585
  // The write site, and the one where a recovered AST does damage rather than
451
586
  // merely misreports: an offset taken from a file that does not compile would
452
587
  // splice a new requirement into it. `undefined` is this function's documented
@@ -462,18 +597,7 @@ export function registryInsertionPoint(file, source) {
462
597
  const arg = authoringCall(exported, sf, 'defineRequirements');
463
598
  if (!arg || !ts.isObjectLiteralExpression(arg))
464
599
  return undefined;
465
- const last = arg.properties[arg.properties.length - 1];
466
- if (!last) {
467
- // An empty registry: open the body rather than continue it. The trailing
468
- // newline is written by the caller, so `{` does not end up sharing a line
469
- // with the entry and the closing `}`.
470
- return { offset: arg.getStart(sf) + 1, indent: indentOf(sf, source, arg) + ' ', leadingComma: false };
471
- }
472
- // Deliberately the *end of the last property*, not the end of the literal:
473
- // inserting here sits before any trailing comma the file already has, so one
474
- // leading comma is correct whether or not that comma is present, and the
475
- // file's own trailing-comma style is left exactly as it was.
476
- return { offset: last.getEnd(), indent: indentOf(sf, source, last), leadingComma: true };
600
+ return { sf, literal: arg };
477
601
  }
478
602
  /**
479
603
  * The whitespace a node's line opens with.
@@ -13,6 +13,7 @@ import type { AttestPlan, Issue, ParamRef, Registry } from './types.js';
13
13
  export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): Issue[];
14
14
  /**
15
15
  * Static structural validation (design §5.3). Emits ERROR-level issues for:
16
+ * - empty-spec: the root declares no requirements at all
16
17
  * - orphan-test: a scenario covers an unknown requirement id
17
18
  * - uncovered-requirement: a requirement has no scenario
18
19
  * - unbound-param: a statement placeholder has no matching param