@am_shork/attest 0.7.4 → 0.9.0

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.
@@ -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,27 +34,30 @@
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
  /**
28
- * A TypeScript single-quoted string literal holding exactly `value`.
41
+ * The *inside* of a TypeScript string literal quoted with `quote`, holding
42
+ * exactly `value`.
29
43
  *
30
- * Hand-escaped rather than `JSON.stringify`, for one reason that is not style:
31
- * the registries this writes into are single-quoted throughout, and a merged
32
- * entry that arrives double-quoted is a diff hunk about quotation marks in the
33
- * middle of a merge the reviewer is trying to read. Control characters go out as
34
- * `\uXXXX` rather than raw, so a statement someone pasted a newline into cannot
35
- * produce a file that no longer parses.
44
+ * Split out from `tsString` because the other site that writes into a string
45
+ * literal `repointImport` writes between quotes that are already in the
46
+ * file, whose character is the file's choice and not this module's. Escaping
47
+ * has to answer to that character: `'` needs no escape inside `"…"`, and
48
+ * escaping it there would be a wrong byte rather than a safe one.
49
+ *
50
+ * Control characters go out as `\uXXXX` rather than raw, so a value someone
51
+ * pasted a newline into cannot produce a file that no longer parses.
36
52
  */
37
- function tsString(value) {
38
- let out = "'";
53
+ function tsStringBody(value, quote) {
54
+ let out = '';
39
55
  for (const ch of value) {
40
56
  const code = ch.codePointAt(0) ?? 0;
41
57
  if (ch === '\\')
42
58
  out += '\\\\';
43
- else if (ch === "'")
44
- out += "\\'";
59
+ else if (ch === quote)
60
+ out += `\\${ch}`;
45
61
  else if (ch === '\n')
46
62
  out += '\\n';
47
63
  else if (ch === '\r')
@@ -53,7 +69,18 @@ function tsString(value) {
53
69
  else
54
70
  out += ch;
55
71
  }
56
- return `${out}'`;
72
+ return out;
73
+ }
74
+ /**
75
+ * A TypeScript single-quoted string literal holding exactly `value`.
76
+ *
77
+ * Hand-escaped rather than `JSON.stringify`, for one reason that is not style:
78
+ * the registries this writes into are single-quoted throughout, and a merged
79
+ * entry that arrives double-quoted is a diff hunk about quotation marks in the
80
+ * middle of a merge the reviewer is trying to read.
81
+ */
82
+ function tsString(value) {
83
+ return `'${tsStringBody(value, "'")}'`;
57
84
  }
58
85
  /**
59
86
  * Thrown when a value reaches the emitter that cannot be written as source
@@ -141,15 +168,23 @@ export function requirementSource(id, req, indent) {
141
168
  // are as much of the emitted text as the outer ones.
142
169
  const params = Object.entries(sortDeep(req.params));
143
170
  if (params.length > 0) {
144
- const body = params.map(([k, v]) => `${keySource(k)}: ${paramSource(v)}`).join(', ');
171
+ const body = params.map(([k, v]) => paramEntrySource(k, v)).join(', ');
145
172
  lines.push(`${inner}params: { ${body} },`);
146
173
  }
147
174
  if (req.outOfScope.length > 0) {
148
- lines.push(`${inner}outOfScope: [${req.outOfScope.map((s) => tsString(s)).join(', ')}],`);
175
+ lines.push(`${inner}outOfScope: ${outOfScopeSource(req.outOfScope)},`);
149
176
  }
150
177
  lines.push(`${indent}}`);
151
178
  return lines.join('\n');
152
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
+ }
153
188
  /**
154
189
  * `source` with `additions` inserted into its registry literal.
155
190
  *
@@ -173,6 +208,215 @@ export function spliceRequirements(file, source, additions) {
173
208
  const text = point.leadingComma ? `,\n${entries}` : `\n${entries},\n`;
174
209
  return source.slice(0, point.offset) + text + source.slice(point.offset);
175
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[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
+ for (const [id, layout] of Object.entries(layouts)) {
254
+ const change = wanted.get(id);
255
+ if (!change)
256
+ continue;
257
+ const { before, after } = change;
258
+ // Everything this entry writes goes through one of these three, which is
259
+ // what lets `editParams` be a function rather than a fourth copy of them.
260
+ const out = {
261
+ at: (field, span, text) => {
262
+ // The one question a replacement has that an insertion does not. Run over
263
+ // every span rather than only the two that can fail it, so the rule is
264
+ // "nothing is written over a comment" rather than a list of fields somebody
265
+ // has to keep in step with the schema — on `statement` and `rationale` it
266
+ // is a scan of one token and can never fire.
267
+ if (spanHasComment(source.slice(span.start, span.end))) {
268
+ refusals.push({ reqId: id, field, reason: 'comment' });
269
+ return;
270
+ }
271
+ edits.push({ start: span.start, end: span.end, text });
272
+ },
273
+ insert: (offset, text) => edits.push({ start: offset, end: offset, text }),
274
+ refuse: (field, reason) => refusals.push({ reqId: id, field, reason }),
275
+ };
276
+ // Fields the entry does not write yet. Collected rather than emitted, since
277
+ // they share one offset — the end of the entry's body — and several edits at
278
+ // one position is the one thing the back-to-front pass cannot order.
279
+ const opened = [];
280
+ const openField = (name, value) => {
281
+ opened.push(`${layout.fieldInsertion.indent}${name}: ${value}`);
282
+ };
283
+ for (const [name, span] of Object.entries(layout.fields)) {
284
+ if (name === 'statement' || name === 'rationale') {
285
+ if (before[name] !== after[name])
286
+ out.at(name, span, tsString(after[name]));
287
+ }
288
+ else if (name === 'outOfScope') {
289
+ if (!sameValue(before.outOfScope, after.outOfScope)) {
290
+ out.at(name, span, outOfScopeSource(after.outOfScope));
291
+ }
292
+ }
293
+ else if (name === 'params') {
294
+ editParams(layout, before, after, out);
295
+ }
296
+ // Any other field is one the schema does not define, and not this
297
+ // module's to rewrite or to remove.
298
+ }
299
+ if (!layout.fields['statement'] && before.statement !== after.statement) {
300
+ openField('statement', tsString(after.statement));
301
+ }
302
+ if (!layout.fields['rationale'] && before.rationale !== after.rationale) {
303
+ openField('rationale', tsString(after.rationale));
304
+ }
305
+ if (!layout.fields['params']) {
306
+ const fresh = freshParams(before, after);
307
+ if (fresh.length > 0)
308
+ openField('params', `{ ${fresh.join(', ')} }`);
309
+ }
310
+ if (!layout.fields['outOfScope'] && !sameValue(before.outOfScope, after.outOfScope)) {
311
+ openField('outOfScope', outOfScopeSource(after.outOfScope));
312
+ }
313
+ if (opened.length > 0) {
314
+ const point = layout.fieldInsertion;
315
+ const body = opened.join(',\n');
316
+ // The comma leads rather than trails, for the reason the registry splice's
317
+ // does: the offset sits *before* whatever trailing comma the entry already
318
+ // carries, so writing one here would produce two.
319
+ edits.push({
320
+ start: point.offset,
321
+ end: point.offset,
322
+ text: point.leadingComma ? `,\n${body}` : `\n${body},\n`,
323
+ });
324
+ }
325
+ }
326
+ if (refusals.length > 0)
327
+ return { ok: false, refusals };
328
+ // Back to front, for the reason `repointImport` applies its edits that way: an
329
+ // edit cannot move an offset that lies before it, so every span stays valid as
330
+ // the string is rewritten under them. A `reverse()` and not a sort, because
331
+ // the walk above produced them in source order.
332
+ let out = source;
333
+ for (const edit of [...edits].reverse()) {
334
+ out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
335
+ }
336
+ return { ok: true, source: out };
337
+ }
338
+ /**
339
+ * The `params` half of one entry: a value per changed key, and one insertion for
340
+ * the keys the entry does not have.
341
+ *
342
+ * Split out because it is the only field with structure of its own, and inlining
343
+ * it put three loops inside a loop inside a loop. Its edits are emitted in the
344
+ * order the file writes the keys, then the insertion at the end of the object —
345
+ * which is what keeps the caller's ascending-offset property true through it.
346
+ */
347
+ function editParams(layout, before, after, out) {
348
+ // A key the end state has lost. Unreachable through the command — `applyDelta`
349
+ // merges `params` key by key and a patch has no spelling for a deletion — and
350
+ // refused rather than ignored, for the reason `keySource` refuses `__proto__`:
351
+ // this is the module that *writes*, and silently keeping a value the proved
352
+ // end state does not have is the one outcome worse than stopping.
353
+ for (const key of Object.keys(before.params).sort(byCodeUnit)) {
354
+ if (!Object.hasOwn(after.params, key)) {
355
+ out.refuse(`params.${key}`, 'param-dropped');
356
+ }
357
+ }
358
+ // A key the file holds and this cannot locate: its value is not written as a
359
+ // literal, which the registry reader refuses before a merge can reach here.
360
+ // Named rather than skipped, so the guard does not depend on something
361
+ // upstream holding.
362
+ for (const key of Object.keys(before.params).sort(byCodeUnit)) {
363
+ if (!layout.paramKeys[key] && !sameValue(before.params[key], after.params[key])) {
364
+ out.refuse(`params.${key}`, 'not-a-literal');
365
+ }
366
+ }
367
+ for (const [key, span] of Object.entries(layout.paramKeys)) {
368
+ if (!Object.hasOwn(after.params, key))
369
+ continue;
370
+ const value = after.params[key];
371
+ if (sameValue(before.params[key], value))
372
+ continue;
373
+ // The value alone: the span is the value's, and the key in front of it is a
374
+ // byte this does not address — the same rule that keeps the comma, the
375
+ // comment and the layout where they are.
376
+ out.at(`params.${key}`, span, paramSource(sortDeep(value)));
377
+ }
378
+ const fresh = freshParams(before, after);
379
+ if (fresh.length === 0)
380
+ return;
381
+ if (!layout.paramsInsertion) {
382
+ // A `params` that is not an object literal, so there is no inside to write
383
+ // into. Same guard as the keys above, one level out.
384
+ out.refuse('params', 'not-a-literal');
385
+ return;
386
+ }
387
+ const point = layout.paramsInsertion;
388
+ out.insert(point.offset, point.leadingComma ? `, ${fresh.join(', ')}` : ` ${fresh.join(', ')} `);
389
+ }
390
+ /** The params the end state has and the file does not, as source, in key order. */
391
+ function freshParams(before, after) {
392
+ return Object.keys(after.params)
393
+ .sort(byCodeUnit)
394
+ .filter((key) => !Object.hasOwn(before.params, key))
395
+ .map((key) => paramEntrySource(key, after.params[key]));
396
+ }
397
+ /**
398
+ * Whether a span of source carries a comment.
399
+ *
400
+ * Scanned rather than matched, and that is the whole of why it is correct: `//`
401
+ * inside a string is not a comment, and a registry's statements are prose full
402
+ * of URLs. The scanner is the same one the reader's parser is built on, so the
403
+ * two agree about what a comment is by construction.
404
+ */
405
+ function spanHasComment(text) {
406
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, /* skipTrivia */ false);
407
+ scanner.setText(text);
408
+ for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
409
+ if (token === ts.SyntaxKind.SingleLineCommentTrivia ||
410
+ token === ts.SyntaxKind.MultiLineCommentTrivia) {
411
+ return true;
412
+ }
413
+ }
414
+ return false;
415
+ }
416
+ /** Content equality for one value, by the canonical form the engine compares on. */
417
+ function sameValue(a, b) {
418
+ return JSON.stringify(sortDeep(a)) === JSON.stringify(sortDeep(b));
419
+ }
176
420
  /**
177
421
  * `source` with every import of `from` repointed at `to`.
178
422
  *
@@ -182,9 +426,10 @@ export function spliceRequirements(file, source, additions) {
182
426
  * discipline as the splice, for the same reason.
183
427
  *
184
428
  * The extension is taken from the specifier being replaced rather than chosen
185
- * here. Whether a project writes `./x.reqs.js` or `./x.reqs.ts` is a property of
186
- * its module resolution, uniform across the project, and already answered by the
187
- * specifier sitting in front of us.
429
+ * here. Whether a project writes `./x.reqs.js`, `./x.reqs.ts` or `./x.reqs` is a
430
+ * property of its module resolution, uniform across the project, and already
431
+ * answered by the specifier sitting in front of us — the third spelling
432
+ * included, where the answer is "none".
188
433
  */
189
434
  export function repointImport(file, source, from, to) {
190
435
  const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
@@ -198,17 +443,37 @@ export function repointImport(file, source, from, to) {
198
443
  continue;
199
444
  if (!resolvesTo(dir, spec.text, from))
200
445
  continue;
446
+ // Unconditional, because "no extension" is an answer and not a missing one:
447
+ // a bundler-resolution project writes every specifier that way, so the empty
448
+ // case has to strip what `to` carries rather than leave it. Guarding this on
449
+ // a non-empty `ext` is only safe while `resolvesTo` rejects an extensionless
450
+ // specifier — the two move together.
201
451
  const ext = spec.text.endsWith('.js') ? '.js' : spec.text.endsWith('.ts') ? '.ts' : '';
202
- let target = toPosixPath(relative(dir, to));
203
- if (ext)
204
- target = target.replace(/\.[^./]+$/, ext);
452
+ let target = toPosixPath(relative(dir, to)).replace(/\.[^./]+$/, ext);
205
453
  // A bare `x.reqs.js` is a package specifier, not a sibling file.
206
454
  if (!target.startsWith('.'))
207
455
  target = `./${target}`;
208
- // Inside the quotes: the file's own quote style is left exactly as it was.
209
- edits.push({ start: spec.getStart(sf) + 1, end: spec.getEnd() - 1, text: target });
456
+ // Inside the quotes, so the file's own quote style is left exactly as it
457
+ // was which is why the escaping is told which quote it writes between.
458
+ // `to` is the registry file `--apply` chose, making this the one value in
459
+ // the emitter the checked repository names, and a name carrying that quote
460
+ // would otherwise close the literal and put what follows into a committed
461
+ // `*.spec.ts` as code (§7). Escaped rather than refused, unlike the
462
+ // `__proto__` key above: a specifier is a string, and every string has a
463
+ // correct spelling as one.
464
+ const quote = source[spec.getStart(sf)];
465
+ edits.push({
466
+ start: spec.getStart(sf) + 1,
467
+ end: spec.getEnd() - 1,
468
+ text: tsStringBody(target, quote),
469
+ });
210
470
  }
211
471
  let out = source;
472
+ // Back to front, which is what makes a correction term unnecessary: an edit
473
+ // cannot move an offset that lies before it, so every `start`/`end` above
474
+ // stays valid as the string is rewritten under them. Applying these in source
475
+ // order works only by carrying a running delta and adding it to each
476
+ // subsequent pair — the same result, one more thing to get wrong.
212
477
  for (const edit of edits.reverse()) {
213
478
  out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
214
479
  }
@@ -217,14 +482,19 @@ export function repointImport(file, source, from, to) {
217
482
  /**
218
483
  * Whether `specifier`, written in a file under `dir`, names `target`.
219
484
  *
220
- * The `.js`-for-`.ts` spelling is accepted because NodeNext resolution requires
221
- * it, and a project using it writes every specifier that way — including the one
222
- * this is looking for.
485
+ * Three spellings of one import, because module resolution decides which a
486
+ * project writes and it writes that one everywhere — including here. The
487
+ * `.js`-for-`.ts` spelling is what NodeNext requires; the extensionless one is
488
+ * what bundler resolution produces, and matching it is the whole reason a
489
+ * specifier can be repointed at all in such a project.
223
490
  */
224
491
  function resolvesTo(dir, specifier, target) {
225
492
  if (!specifier.startsWith('.'))
226
493
  return false;
227
494
  const resolved = resolve(dir, specifier);
228
- return resolved === target || resolved.replace(/\.js$/, '.ts') === target;
495
+ // Only `.ts` is appended, not `.js` as well: `target` is the delta, whose name
496
+ // is the constant `requirements.delta.ts`, so the symmetrical clause would be
497
+ // unreachable — a hypothetical seam rather than a real one.
498
+ return (resolved === target || resolved.replace(/\.js$/, '.ts') === target || `${resolved}.ts` === target);
229
499
  }
230
500
  //# sourceMappingURL=splice.js.map
@@ -101,4 +101,46 @@ 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. */
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>;
130
+ /** Where a new `params` key goes — absent when the entry writes no `params`. */
131
+ paramsInsertion?: RegistryInsertion;
132
+ /** Where a new field goes, inside the entry's own body. */
133
+ fieldInsertion: RegistryInsertion;
134
+ }
135
+ /**
136
+ * The layout of every entry in a registry file, by requirement id.
137
+ *
138
+ * `undefined` under exactly the condition `registryInsertionPoint` returns it —
139
+ * one walker answers "where is this registry's literal" for both, so a file the
140
+ * splice refuses cannot be one the modification accepts. Entries whose *shape*
141
+ * this cannot describe (a computed key, a value built by a call) are absent from
142
+ * the result rather than partially described, which the caller reads as a
143
+ * refusal for that id: an entry nothing can locate is not one to edit.
144
+ */
145
+ export declare function registryEntryLayouts(file: string, source: string): Record<string, RegistryEntryLayout> | undefined;
104
146
  //# sourceMappingURL=static-registry.d.ts.map
@@ -21,7 +21,8 @@
21
21
  // to "is this a literal", and those two answers must not be able to disagree.
22
22
  import ts from 'typescript';
23
23
  import { parseSource } from './compiler.js';
24
- import { RegistryValidationError, withProposedRequirements } from './registry.js';
24
+ import { withProposedRequirements } from './registry.js';
25
+ import { registryValidationMessage } from './registry-issues.js';
25
26
  import { RegistrySchema } from './schema.js';
26
27
  /** The authoring function a registry file must default-export the result of. */
27
28
  const DEFINE = 'defineRequirements';
@@ -128,12 +129,12 @@ export function readRegistrySource(file, source) {
128
129
  const result = RegistrySchema.safeParse(extracted.value);
129
130
  if (!result.success) {
130
131
  // defineRequirements throws RegistryValidationError and loadRegistry wraps
131
- // it; going through the same error keeps the message identical rather than
132
- // merely similar.
132
+ // it; sharing one formatter keeps the message identical rather than merely
133
+ // similar.
133
134
  return {
134
135
  ok: false,
135
136
  code: 'registry-invalid',
136
- message: `Failed to load registry: ${new RegistryValidationError(result.error.issues).message}`,
137
+ message: `Failed to load registry: ${registryValidationMessage(result.error.issues)}`,
137
138
  };
138
139
  }
139
140
  return { ok: true, registry: result.data };
@@ -424,12 +425,32 @@ function objectValue(expr) {
424
425
  return out;
425
426
  }
426
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) {
427
446
  if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNoSubstitutionTemplateLiteral(name)) {
428
447
  return name.text;
429
448
  }
430
- if (ts.isNumericLiteral(name))
431
- return String(numericValue(name));
432
- 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;
433
454
  }
434
455
  /**
435
456
  * The value of a numeric literal, read from its source spelling.
@@ -440,12 +461,117 @@ function propertyName(name) {
440
461
  * first, since `Number('1_000')` is NaN.
441
462
  */
442
463
  function numericValue(node) {
443
- const value = Number(node.getText().replace(/_/g, ''));
444
- if (Number.isNaN(value))
464
+ const value = numericText(node);
465
+ if (value === undefined)
445
466
  throw new NotStatic(node, 'a number this reader cannot spell out');
446
467
  return value;
447
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
+ }
448
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
+ export function registryEntryLayouts(file, source) {
515
+ const found = registryLiteral(file, source);
516
+ if (!found)
517
+ return undefined;
518
+ const { sf, literal } = found;
519
+ const layouts = {};
520
+ for (const entry of literal.properties) {
521
+ if (!ts.isPropertyAssignment(entry))
522
+ continue;
523
+ const id = staticName(entry.name);
524
+ if (id === undefined || id === '__proto__')
525
+ continue;
526
+ const body = unwrap(entry.initializer);
527
+ if (!ts.isObjectLiteralExpression(body))
528
+ continue;
529
+ const fields = {};
530
+ const paramKeys = {};
531
+ let params;
532
+ for (const field of body.properties) {
533
+ if (!ts.isPropertyAssignment(field))
534
+ continue;
535
+ const name = staticName(field.name);
536
+ if (name === undefined || name === '__proto__')
537
+ continue;
538
+ // The unwrapped value, so an `as const` or a parenthesis stays outside the
539
+ // span and survives the replacement it wraps.
540
+ const value = unwrap(field.initializer);
541
+ fields[name] = { start: value.getStart(sf), end: value.getEnd() };
542
+ if (name === 'params' && ts.isObjectLiteralExpression(value))
543
+ params = value;
544
+ }
545
+ if (params) {
546
+ for (const param of params.properties) {
547
+ if (!ts.isPropertyAssignment(param))
548
+ continue;
549
+ const key = staticName(param.name);
550
+ if (key === undefined || key === '__proto__')
551
+ continue;
552
+ const value = unwrap(param.initializer);
553
+ paramKeys[key] = { start: value.getStart(sf), end: value.getEnd() };
554
+ }
555
+ }
556
+ layouts[id] = {
557
+ fields,
558
+ paramKeys,
559
+ ...(params ? { paramsInsertion: objectInsertion(sf, source, params) } : {}),
560
+ fieldInsertion: objectInsertion(sf, source, body),
561
+ };
562
+ }
563
+ return layouts;
564
+ }
565
+ /**
566
+ * The `defineRequirements({ … })` literal of a registry file, with the source
567
+ * file it was parsed from.
568
+ *
569
+ * Both write-side readers go through it, for the reason there is one reader:
570
+ * "which literal is the registry" must have one answer, and a second walk that
571
+ * accepted a file the first refuses would let a modification edit a file the
572
+ * splice would not touch.
573
+ */
574
+ function registryLiteral(file, source) {
449
575
  // The write site, and the one where a recovered AST does damage rather than
450
576
  // merely misreports: an offset taken from a file that does not compile would
451
577
  // splice a new requirement into it. `undefined` is this function's documented
@@ -461,18 +587,7 @@ export function registryInsertionPoint(file, source) {
461
587
  const arg = authoringCall(exported, sf, 'defineRequirements');
462
588
  if (!arg || !ts.isObjectLiteralExpression(arg))
463
589
  return undefined;
464
- const last = arg.properties[arg.properties.length - 1];
465
- if (!last) {
466
- // An empty registry: open the body rather than continue it. The trailing
467
- // newline is written by the caller, so `{` does not end up sharing a line
468
- // with the entry and the closing `}`.
469
- return { offset: arg.getStart(sf) + 1, indent: indentOf(sf, source, arg) + ' ', leadingComma: false };
470
- }
471
- // Deliberately the *end of the last property*, not the end of the literal:
472
- // inserting here sits before any trailing comma the file already has, so one
473
- // leading comma is correct whether or not that comma is present, and the
474
- // file's own trailing-comma style is left exactly as it was.
475
- return { offset: last.getEnd(), indent: indentOf(sf, source, last), leadingComma: true };
590
+ return { sf, literal: arg };
476
591
  }
477
592
  /**
478
593
  * The whitespace a node's line opens with.