@am_shork/attest 0.8.0 → 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,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,215 @@ 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[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
+ }
190
420
  /**
191
421
  * `source` with every import of `from` repointed at `to`.
192
422
  *
@@ -196,9 +426,10 @@ export function spliceRequirements(file, source, additions) {
196
426
  * discipline as the splice, for the same reason.
197
427
  *
198
428
  * 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.
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".
202
433
  */
203
434
  export function repointImport(file, source, from, to) {
204
435
  const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
@@ -212,10 +443,13 @@ export function repointImport(file, source, from, to) {
212
443
  continue;
213
444
  if (!resolvesTo(dir, spec.text, from))
214
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.
215
451
  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);
452
+ let target = toPosixPath(relative(dir, to)).replace(/\.[^./]+$/, ext);
219
453
  // A bare `x.reqs.js` is a package specifier, not a sibling file.
220
454
  if (!target.startsWith('.'))
221
455
  target = `./${target}`;
@@ -248,14 +482,19 @@ export function repointImport(file, source, from, to) {
248
482
  /**
249
483
  * Whether `specifier`, written in a file under `dir`, names `target`.
250
484
  *
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.
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.
254
490
  */
255
491
  function resolvesTo(dir, specifier, target) {
256
492
  if (!specifier.startsWith('.'))
257
493
  return false;
258
494
  const resolved = resolve(dir, specifier);
259
- 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);
260
499
  }
261
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
@@ -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,117 @@ 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
+ 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) {
450
575
  // The write site, and the one where a recovered AST does damage rather than
451
576
  // merely misreports: an offset taken from a file that does not compile would
452
577
  // splice a new requirement into it. `undefined` is this function's documented
@@ -462,18 +587,7 @@ export function registryInsertionPoint(file, source) {
462
587
  const arg = authoringCall(exported, sf, 'defineRequirements');
463
588
  if (!arg || !ts.isObjectLiteralExpression(arg))
464
589
  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 };
590
+ return { sf, literal: arg };
477
591
  }
478
592
  /**
479
593
  * 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
@@ -28,6 +28,7 @@ export function uncoveredIssues(registry, plan) {
28
28
  }
29
29
  /**
30
30
  * Static structural validation (design §5.3). Emits ERROR-level issues for:
31
+ * - empty-spec: the root declares no requirements at all
31
32
  * - orphan-test: a scenario covers an unknown requirement id
32
33
  * - uncovered-requirement: a requirement has no scenario
33
34
  * - unbound-param: a statement placeholder has no matching param
@@ -47,6 +48,27 @@ export function uncoveredIssues(registry, plan) {
47
48
  export function validateStructure(registry, plan, unreadable = []) {
48
49
  const issues = [];
49
50
  const knownIds = new Set(Object.keys(registry));
51
+ // empty-spec: nothing under this root declares intent, so no finding below is
52
+ // about a requirement — and the report has no line saying why. It is stated
53
+ // first because everything else this function can emit against an empty
54
+ // registry is fallout from it: every scenario in the project is an orphan, and
55
+ // the diagnosis after 135 of them is a diagnosis nobody reaches.
56
+ //
57
+ // Guarded on `unreadable`, which is the same withdrawal the orphan advice
58
+ // makes below and for the same reason. "Point attest at the directory holding
59
+ // your *.reqs.ts files" is right for a root that has none, and **wrong** for
60
+ // one whose registry is sitting right there and failed to load: the files were
61
+ // found, the fix is the load error already in the report, and following the
62
+ // hint would move a working path. A registry that could not be read is not an
63
+ // absent one, and the two take different repairs.
64
+ if (knownIds.size === 0 && unreadable.length === 0) {
65
+ issues.push({
66
+ level: 'ERROR',
67
+ code: 'empty-spec',
68
+ message: `No requirements found under this root, so there is no intent here to attest. ` +
69
+ `Point attest at the directory holding your *.reqs.ts files, or add one.`,
70
+ });
71
+ }
50
72
  // orphan-test: covers a requirement that does not exist.
51
73
  //
52
74
  // "Add it to the registry, or fix the id" is the right advice for an id that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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": {