@am_shork/attest 0.9.4 → 0.11.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.
@@ -289,40 +289,59 @@ one stage where the assertion is being authored against a value nobody has
289
289
  implemented yet. MODIFIED ids are deliberately not readable this way — the end
290
290
  state is the base entry with the patch applied, and the base is not in that file.
291
291
 
292
- **The scenarios must be red.** Every added requirement needs at least one
293
- scenario in a \`*.proposed.spec.ts\`, and at this stage they must fail
294
- they describe behaviour that does not exist yet. Prove it:
292
+ Reading a proposed requirement through \`added\` \`d.added['AUTH-7'].params.x\`
293
+ is refused by the compiler rather than by this paragraph. It is correct today and
294
+ wrong one command later, because \`--apply\` repoints the import at the registry
295
+ and a registry has no \`added\`; the by-id form above is the one that survives.
296
+
297
+ **Each added scenario must be observed failing, once.** Every added requirement
298
+ needs at least one scenario in a \`*.proposed.spec.ts\`, and the gate has to see
299
+ each of them fail at least once before it will archive the change. Take the
300
+ observation:
295
301
 
296
302
  \`\`\`
297
303
  attest archive <name> --json # expect a failing verdict carrying tests-red
298
304
  \`\`\`
299
305
 
300
- If it passes at this stage, the scenarios assert nothing real — and this is no
301
- longer advice. That run writes \`changes/<name>/first-run.json\`, recording how
302
- each of the change's scenarios ended the first time it was seen, and the archive
303
- gate later refuses any added requirement whose scenarios have no recorded
304
- failing run (\`never-red\`). A scenario that was green from the start no longer
305
- clears the gate.
306
-
307
- Two consequences worth knowing before you start:
308
-
309
- - **Run this before you implement anything.** The record is written by the
310
- command, not by you; skipping the step means the first observation is of
311
- already-working code, which is a \`never-red\` block.
312
- - **A recorded failure is permanent, a recorded pass is not.** The green run at
313
- the end cannot erase the red that is the point but a scenario recorded as
314
- passing can still be fixed: give it an assertion that fails without the change
315
- and run the gate again, and the real failure replaces it.
306
+ That run writes \`changes/<name>/first-run.json\`, recording how each of the
307
+ change's scenarios ended, and the archive gate later refuses any added
308
+ requirement whose scenarios have no recorded failing run (\`never-red\`). A
309
+ scenario that has only ever been seen green does not clear the gate — if it
310
+ passes while the behaviour it describes is absent, it asserts nothing real.
311
+
312
+ **The obligation is that the scenario can fail. It is not an obligation to write
313
+ it first.** The run above is the cheapest way to take the observation, because at
314
+ this point in stage 1 nothing is implemented and the scenarios are red by
315
+ construction. It is not the only way, and the record is what makes the other one
316
+ work: a recorded failure is permanent, a recorded pass is provisional, so a
317
+ failure seen later replaces a pass seen earlier. Two routes, one fact:
318
+
319
+ - **Nothing implemented yet**run the command above and the record is taken.
320
+ This is the shape stage 1 is written around.
321
+ - **The implementation already exists** you wrote the intent, the scenario and
322
+ the code in one pass, which is a perfectly good way to work. Remove the
323
+ implementation, run the command above, then restore it. The record that comes
324
+ back is the same evidence obtained by the same command, and the restored code
325
+ makes it green again without erasing it.
326
+
327
+ Do not reorder work you have already done to satisfy this; take the observation
328
+ the second way. What no route excuses is a scenario that stays green with its
329
+ subject removed — that is the defect \`never-red\` exists to name, and it is
330
+ visible only if you actually take the observation.
331
+
332
+ A recorded pass is not a dead end either: give the scenario an assertion that
333
+ discriminates and run the gate again, and the real failure replaces it.
316
334
 
317
335
  Commit \`first-run.json\` with the change. It is evidence a reviewer reads, and
318
336
  the gate has to reach the same verdict on a CI checkout as on your machine.
319
337
 
320
338
  **Never write or edit that file yourself.** The gate trusts it and cannot check
321
- it: everything else Attest writes can be regenerated and compared, but a first
322
- run cannot be recomputed once the change is green which is the whole reason
323
- the record exists. Producing it any way other than by running the gate destroys
324
- the only thing it is for. If it says something you did not expect, run the gate
325
- again; a real failing run replaces a recorded pass.
339
+ it: everything else Attest writes can be regenerated from the tree as it is and
340
+ compared, and this cannot a green tree says nothing about whether a scenario
341
+ could ever have failed, which is the whole reason the record exists. Producing
342
+ it any way other than by running the gate destroys the only thing it is for. If
343
+ it says something you did not expect, take the observation again — remove the
344
+ behaviour and run the gate; a real failing run replaces a recorded pass.
326
345
 
327
346
  To see where the change stands at any point, without paying for a run:
328
347
 
@@ -347,8 +366,9 @@ still not a verdict — \`attest archive\` is the only thing that decides.
347
366
  - **What pins each expectation** — a fixture, a literal, an independently
348
367
  derived value; never the param the code under test consumed?
349
368
  - **Does each statement carry one obligation**, or several under one SHALL?
350
- - **Was any of this ever red**, or does part of it already work? \`never-red\`
351
- asks for a recorded failing run only on the ids you ADD.
369
+ - **Was each scenario ever observed failing** with its subject absent, whether
370
+ because it was not written yet or because you removed it? \`never-red\` asks
371
+ for that on the ids you ADD, and asks nothing about when you took it.
352
372
 
353
373
  Then present the proposal, the ids, and the red output, and **stop**. Wait for
354
374
  agreement before implementing.
@@ -442,8 +462,9 @@ failure this framework exists to make visible:
442
462
  identical content is a no-op.
443
463
  4. **Do not write or edit \`first-run.json\`.** It is the one file here the gate
444
464
  trusts without being able to check it, so a hand-written \`"fail"\` clears
445
- \`never-red\` while proving nothing at all. Run the gate before implementing;
446
- that is what produces the record honestly.
465
+ \`never-red\` while proving nothing at all. Run the gate against a tree where
466
+ the behaviour is absent — that is what produces the record honestly, and it
467
+ is the one step here nothing else can stand in for.
447
468
 
448
469
  ### When the gate passes
449
470
 
@@ -65,7 +65,7 @@ export interface Modification {
65
65
  export interface ModifyRefusal {
66
66
  reqId: string;
67
67
  field?: string;
68
- reason: 'comment' | 'entry-not-found' | 'not-a-literal' | 'param-dropped';
68
+ reason: 'comment' | 'duplicate-key' | 'entry-not-found' | 'not-a-literal' | 'param-dropped';
69
69
  }
70
70
  export type ModifyResult = {
71
71
  ok: true;
@@ -107,6 +107,26 @@ export declare function spliceModifications(file: string, source: string, change
107
107
  * property of its module resolution, uniform across the project, and already
108
108
  * answered by the specifier sitting in front of us — the third spelling
109
109
  * included, where the answer is "none".
110
+ *
111
+ * **A source that does not compile is returned untouched** — ATX-69, over the
112
+ * one writer that reaches the compiler directly. `ts.createSourceFile` recovers:
113
+ * handed a file that does not compile it returns a tree built from what the
114
+ * parser guessed, and every offset below comes out of that tree and is written
115
+ * back over the bytes of a `*.spec.ts` the user cannot regenerate. The two other
116
+ * writers in this file ask the same question through the reader that hands them
117
+ * their offsets; this one has to ask it itself.
118
+ *
119
+ * It is unreachable through the command today — the gate refuses a spec it
120
+ * could not parse before a merge starts (`pipeline.ts`) — and that is exactly
121
+ * why it is checked here: a defence that holds only because something upstream
122
+ * holds is not a defence, which is the standard `keySource` above is written to.
123
+ *
124
+ * The refusal is the source unchanged rather than a throw or an issue, because
125
+ * there is no failure channel to use and this function's whole discipline is
126
+ * that it touches one string literal or nothing. `applyMerge` writes only when
127
+ * the text moved, so an unparseable spec is renamed with the import it already
128
+ * had — which is the state a re-run finds and reports, rather than a file
129
+ * rewritten at offsets nobody can trust.
110
130
  */
111
131
  export declare function repointImport(file: string, source: string, from: string, to: string): string;
112
132
  //# sourceMappingURL=splice.d.ts.map
@@ -34,6 +34,7 @@
34
34
  // reach this file because the gate validated the delta before `--apply` ran.
35
35
  import ts from 'typescript';
36
36
  import { dirname, relative, resolve } from 'node:path';
37
+ import { parseSource } from './compiler.js';
37
38
  import { registryEntryLayouts, registryInsertionPoint } from './static-registry.js';
38
39
  import { toPosixPath } from './paths.js';
39
40
  import { byCodeUnit, sortDeep } from './order.js';
@@ -252,9 +253,18 @@ export function spliceModifications(file, source, changes) {
252
253
  const edits = [];
253
254
  const wanted = new Map(changes.map((change) => [change.id, change]));
254
255
  // Ids the file does not hold, first and in code-unit order: they have no
255
- // position to be reported at, and the walk below is driven by positions.
256
+ // position to be reported at, and the walk below is driven by positions. An
257
+ // entry the reader refused to describe is reported as *why* rather than as
258
+ // absent — a file that writes one key twice does hold the entry, and
259
+ // `entry-not-found` would send its reader looking for something that is
260
+ // there. Both are decided before a single edit is pushed, because a refusal
261
+ // arriving mid-walk would leave `edits` half-built for an entry whose offsets
262
+ // are exactly the ones not to be trusted.
256
263
  for (const id of [...wanted.keys()].sort(byCodeUnit)) {
257
- if (!layouts.has(id))
264
+ const twice = layouts.repeated.get(id);
265
+ if (twice)
266
+ refusals.push({ reqId: id, ...twice, reason: 'duplicate-key' });
267
+ else if (!layouts.byId.has(id))
258
268
  refusals.push({ reqId: id, reason: 'entry-not-found' });
259
269
  }
260
270
  // **The file front to back, not the delta.** Driving the walk from the layout
@@ -268,7 +278,7 @@ export function spliceModifications(file, source, changes) {
268
278
  // It is also why the layout is `Map`s rather than objects at every level; the
269
279
  // container is what carries the order, and `registryEntryLayouts` says why an
270
280
  // object cannot.
271
- for (const [id, layout] of layouts) {
281
+ for (const [id, layout] of layouts.byId) {
272
282
  const change = wanted.get(id);
273
283
  if (!change)
274
284
  continue;
@@ -461,9 +471,32 @@ function sameValue(a, b) {
461
471
  * property of its module resolution, uniform across the project, and already
462
472
  * answered by the specifier sitting in front of us — the third spelling
463
473
  * included, where the answer is "none".
474
+ *
475
+ * **A source that does not compile is returned untouched** — ATX-69, over the
476
+ * one writer that reaches the compiler directly. `ts.createSourceFile` recovers:
477
+ * handed a file that does not compile it returns a tree built from what the
478
+ * parser guessed, and every offset below comes out of that tree and is written
479
+ * back over the bytes of a `*.spec.ts` the user cannot regenerate. The two other
480
+ * writers in this file ask the same question through the reader that hands them
481
+ * their offsets; this one has to ask it itself.
482
+ *
483
+ * It is unreachable through the command today — the gate refuses a spec it
484
+ * could not parse before a merge starts (`pipeline.ts`) — and that is exactly
485
+ * why it is checked here: a defence that holds only because something upstream
486
+ * holds is not a defence, which is the standard `keySource` above is written to.
487
+ *
488
+ * The refusal is the source unchanged rather than a throw or an issue, because
489
+ * there is no failure channel to use and this function's whole discipline is
490
+ * that it touches one string literal or nothing. `applyMerge` writes only when
491
+ * the text moved, so an unparseable spec is renamed with the import it already
492
+ * had — which is the state a re-run finds and reports, rather than a file
493
+ * rewritten at offsets nobody can trust.
464
494
  */
465
495
  export function repointImport(file, source, from, to) {
466
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
496
+ const parsed = parseSource(file, source);
497
+ if ('error' in parsed)
498
+ return source;
499
+ const sf = parsed.sf;
467
500
  const dir = dirname(file);
468
501
  const edits = [];
469
502
  for (const statement of sf.statements) {
@@ -135,6 +135,31 @@ export interface RegistryEntryLayout {
135
135
  /** Where a new field goes, inside the entry's own body. */
136
136
  fieldInsertion: RegistryInsertion;
137
137
  }
138
+ /**
139
+ * The key an entry wrote twice, as the path the author would say it —
140
+ * `statement`, `params.limit`.
141
+ *
142
+ * Absent when what repeats is the entry's **own id**, which no field path
143
+ * names: `params.limit` is a place inside a requirement, and a second entry
144
+ * under one id is not inside anything.
145
+ */
146
+ export interface RepeatedKey {
147
+ field?: string;
148
+ }
149
+ /** Every entry's layout, and the entries this reader refuses to describe. */
150
+ export interface RegistryLayouts {
151
+ /**
152
+ * Value spans per requirement id, in the order the file writes them.
153
+ *
154
+ * Named `byId` rather than `entries` so nothing reads `layouts.entries` as
155
+ * the `Map` method of that name — the order this carries is the contract,
156
+ * and a reader who thinks they are calling `Map.prototype.entries` is a
157
+ * reader who has stopped seeing it.
158
+ */
159
+ byId: Map<string, RegistryEntryLayout>;
160
+ /** Ids left out of {@link RegistryLayouts.byId} because a key repeats. */
161
+ repeated: Map<string, RepeatedKey>;
162
+ }
138
163
  /**
139
164
  * The layout of every entry in a registry file, by requirement id.
140
165
  *
@@ -154,6 +179,26 @@ export interface RegistryEntryLayout {
154
179
  * schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
155
180
  * order for every key type, so the property holds by construction rather than by
156
181
  * the keys happening not to be numbers.
182
+ *
183
+ * **A key written twice defeats that pairing, which is why it is refused rather
184
+ * than described.** `Map.set` on a key it already holds keeps the *first*
185
+ * insertion's position and takes the *last* call's value — so an entry writing
186
+ * `params: { limit: 1, other: 'x', limit: 1 }` yields `limit` at position one
187
+ * carrying the span of the occurrence that sits after `other`, and the walk's
188
+ * offsets stop ascending — so an earlier edit moves the bytes under a later one
189
+ * and the replacement lands outside the value it was addressing.
190
+ *
191
+ * Nothing upstream refuses such a file, which is why the guard is here: a
192
+ * duplicate key is a TypeScript *semantic* error and `parseSource` asks only
193
+ * the syntactic question, so both readers take the file and hand back the last
194
+ * occurrence, and `--apply` is the one command that writes.
195
+ *
196
+ * Refused at every level for one reason, and the id level is not the mild one:
197
+ * two entries under a single id put a *later* entry's spans at an *earlier*
198
+ * entry's position, so the disorder is between entries rather than inside one.
199
+ * Nothing narrower is worth the reach — an entry whose keys are ambiguous is
200
+ * one no verdict describes, and the file is a defect to fix rather than one to
201
+ * edit around.
157
202
  */
158
- export declare function registryEntryLayouts(file: string, source: string): Map<string, RegistryEntryLayout> | undefined;
203
+ export declare function registryEntryLayouts(file: string, source: string): RegistryLayouts | undefined;
159
204
  //# sourceMappingURL=static-registry.d.ts.map
@@ -217,7 +217,7 @@ export function readDeltaSource(file, source) {
217
217
  */
218
218
  export function declaredIdsFromSource(file, source) {
219
219
  const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
220
- const names = localNames(sf, DEFINE);
220
+ const names = authoringNames(sf, DEFINE);
221
221
  const ids = new Set();
222
222
  // Any `defineRequirements({ … })` in the file, not only the exported one: the
223
223
  // failure being diagnosed is often that the call is not where it should be —
@@ -295,43 +295,119 @@ function constInitializer(sf, name) {
295
295
  * The callee is matched against the local name the import bound — an alias or a
296
296
  * namespace import is the same call — rather than against any single-argument
297
297
  * call, so `buildRegistry({…})` is not mistaken for a registry.
298
+ *
299
+ * **Unwrapped here, once, rather than by each caller.** `as const`, `satisfies`
300
+ * and a bare pair of parentheses do not change the value, so `literalValue`
301
+ * strips them before it reads — while the write side asks
302
+ * `ts.isObjectLiteralExpression` of whatever this hands back. A wrapper returned
303
+ * from here is therefore a registry the readers accept and the writers cannot
304
+ * locate, on the one command that edits a `*.reqs.ts`. Both sides come through
305
+ * this function precisely so they cannot disagree about which literal is the
306
+ * registry; leaving the unwrap to each caller is what puts the disagreement
307
+ * inside it.
298
308
  */
299
309
  function authoringCall(expr, sf, fn) {
300
310
  if (!ts.isCallExpression(expr) || expr.arguments.length !== 1)
301
311
  return undefined;
302
- return callsAuthoringFn(expr, localNames(sf, fn), fn) ? expr.arguments[0] : undefined;
312
+ if (!callsAuthoringFn(expr, authoringNames(sf, fn), fn))
313
+ return undefined;
314
+ return unwrap(expr.arguments[0]);
303
315
  }
304
316
  /**
305
- * Whether a call is a call of `fn`, by the local names the imports bound to it.
317
+ * Whether a call is a call of `fn`, by what the file binds the callee to.
306
318
  *
307
319
  * One predicate for both readers of this question — the extraction above and the
308
320
  * id recovery below — because they must not be able to disagree about what
309
321
  * counts as the authoring call. An alias rule taught to one and not the other
310
322
  * would make recovery silently miss exactly the files the reader refuses.
323
+ *
324
+ * **A name this file binds to something other than an import is not `fn`.**
325
+ * Matching by spelling alone lets a file hand the two readers different
326
+ * registries while contradicting itself in plain sight — `const
327
+ * defineRequirements = (r) => ({ ...r, 'ADMIN-1': … })` in front of the export
328
+ * leaves this reader with the literal argument and the evaluator with whatever
329
+ * that function returns. A wrong answer rather than a refusal, of the shape the
330
+ * `let` guard above is kept for, on the commands a reviewer runs *because* they
331
+ * execute nothing (design §5.2, ATX-16).
332
+ *
333
+ * So the rule is about the binding: an import binding is trusted, a name this
334
+ * file never binds is trusted (it comes from a global or a harness, which is
335
+ * what the bare name is for), and a `const`/`let`/`var`/`function`/`class` in
336
+ * this file is not — whether the callee is that name or a property reached
337
+ * through it. Refusing is all that is needed: the file becomes
338
+ * `registry-not-static` with `--eval` named as the way back, which is ATX-17
339
+ * unchanged.
340
+ *
341
+ * **What this still cannot see is the module graph**, and it is not written to:
342
+ * `import { defineRequirements } from './wrapper.js'` binds the name by an
343
+ * import, so it is accepted, and what that module exports is not a question the
344
+ * source text of *this* file answers. Resolving it would mean reading — and
345
+ * then trusting — another file, which is the evaluator's job and the reason
346
+ * `--eval` exists. The line drawn here is what the file contradicts about itself.
311
347
  */
312
348
  function callsAuthoringFn(call, names, fn) {
313
349
  const callee = call.expression;
314
- return ((ts.isIdentifier(callee) && names.has(callee.text)) ||
315
- (ts.isPropertyAccessExpression(callee) && callee.name.text === fn));
350
+ if (ts.isIdentifier(callee))
351
+ return names.called.has(callee.text);
352
+ if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== fn)
353
+ return false;
354
+ // `attest.defineRequirements(…)`. The object is held to the same rule as the
355
+ // bare name, which is what keeps the two forms from having two answers: a
356
+ // namespace import is trusted, a name this file never binds is trusted, and
357
+ // anything this file built is not. A deeper access — `a.b.defineRequirements`
358
+ // — is not an identifier and so is not one of those, which no namespace
359
+ // import can produce anyway.
360
+ const object = callee.expression;
361
+ return ts.isIdentifier(object) && !names.declared.has(object.text);
316
362
  }
317
- /** Local names bound to the imported `fn`, including aliases. */
318
- function localNames(sf, fn) {
319
- const names = new Set();
363
+ /** How the file binds the names a callee could be written with. */
364
+ function authoringNames(sf, fn) {
365
+ const called = new Set();
366
+ const declared = new Set();
320
367
  for (const statement of sf.statements) {
321
- if (!ts.isImportDeclaration(statement))
322
- continue;
323
- const bindings = statement.importClause?.namedBindings;
324
- if (!bindings || !ts.isNamedImports(bindings))
368
+ if (ts.isImportDeclaration(statement)) {
369
+ const bindings = statement.importClause?.namedBindings;
370
+ if (bindings && ts.isNamedImports(bindings)) {
371
+ for (const el of bindings.elements) {
372
+ if ((el.propertyName ?? el.name).text === fn)
373
+ called.add(el.name.text);
374
+ }
375
+ }
325
376
  continue;
326
- for (const el of bindings.elements) {
327
- if ((el.propertyName ?? el.name).text === fn)
328
- names.add(el.name.text);
329
377
  }
378
+ for (const name of declaredNames(statement))
379
+ declared.add(name);
330
380
  }
331
381
  // A file that never imported it can still be read: the import may come from a
332
382
  // global or a test harness, and the callee name is the only signal we need.
333
- names.add(fn);
334
- return names;
383
+ // Withdrawn exactly when the file itself says the name is something else.
384
+ if (!declared.has(fn))
385
+ called.add(fn);
386
+ return { called, declared };
387
+ }
388
+ /** The module-scope names one statement binds, if it is not an import. */
389
+ function* declaredNames(statement) {
390
+ if (ts.isVariableStatement(statement)) {
391
+ for (const decl of statement.declarationList.declarations)
392
+ yield* bindingNames(decl.name);
393
+ return;
394
+ }
395
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name) {
396
+ yield statement.name.text;
397
+ }
398
+ }
399
+ /** Every name a binding introduces, destructuring included. */
400
+ function* bindingNames(name) {
401
+ if (ts.isIdentifier(name)) {
402
+ yield name.text;
403
+ return;
404
+ }
405
+ // `const { defineRequirements } = evil;` binds the name as surely as an
406
+ // assignment does, and reads less like a shadow rather than more.
407
+ for (const element of name.elements) {
408
+ if (ts.isBindingElement(element))
409
+ yield* bindingNames(element.name);
410
+ }
335
411
  }
336
412
  /** Strip the type-level wrappers that do not change the value (`as const`, `satisfies`, parens). */
337
413
  function unwrap(node) {
@@ -520,31 +596,66 @@ function objectInsertion(sf, source, obj) {
520
596
  * schema's `z.record(z.string(), …)` accepts the result. A `Map` keeps insertion
521
597
  * order for every key type, so the property holds by construction rather than by
522
598
  * the keys happening not to be numbers.
599
+ *
600
+ * **A key written twice defeats that pairing, which is why it is refused rather
601
+ * than described.** `Map.set` on a key it already holds keeps the *first*
602
+ * insertion's position and takes the *last* call's value — so an entry writing
603
+ * `params: { limit: 1, other: 'x', limit: 1 }` yields `limit` at position one
604
+ * carrying the span of the occurrence that sits after `other`, and the walk's
605
+ * offsets stop ascending — so an earlier edit moves the bytes under a later one
606
+ * and the replacement lands outside the value it was addressing.
607
+ *
608
+ * Nothing upstream refuses such a file, which is why the guard is here: a
609
+ * duplicate key is a TypeScript *semantic* error and `parseSource` asks only
610
+ * the syntactic question, so both readers take the file and hand back the last
611
+ * occurrence, and `--apply` is the one command that writes.
612
+ *
613
+ * Refused at every level for one reason, and the id level is not the mild one:
614
+ * two entries under a single id put a *later* entry's spans at an *earlier*
615
+ * entry's position, so the disorder is between entries rather than inside one.
616
+ * Nothing narrower is worth the reach — an entry whose keys are ambiguous is
617
+ * one no verdict describes, and the file is a defect to fix rather than one to
618
+ * edit around.
523
619
  */
524
620
  export function registryEntryLayouts(file, source) {
525
621
  const found = registryLiteral(file, source);
526
622
  if (!found)
527
623
  return undefined;
528
624
  const { sf, literal } = found;
529
- const layouts = new Map();
625
+ const byId = new Map();
626
+ const repeated = new Map();
530
627
  for (const entry of literal.properties) {
531
628
  if (!ts.isPropertyAssignment(entry))
532
629
  continue;
533
630
  const id = staticName(entry.name);
534
631
  if (id === undefined || id === '__proto__')
535
632
  continue;
633
+ if (byId.has(id) || repeated.has(id)) {
634
+ // Deleted, not left as the first occurrence: both readers take the *last*
635
+ // one, so describing the first would hand the writer a span whose value
636
+ // nothing evaluates.
637
+ byId.delete(id);
638
+ repeated.set(id, {});
639
+ continue;
640
+ }
536
641
  const body = unwrap(entry.initializer);
537
642
  if (!ts.isObjectLiteralExpression(body))
538
643
  continue;
539
644
  const fields = new Map();
540
645
  const paramKeys = new Map();
541
646
  let params;
647
+ /** The first repeated key seen, as the path the refusal names. */
648
+ let twice;
542
649
  for (const field of body.properties) {
543
650
  if (!ts.isPropertyAssignment(field))
544
651
  continue;
545
652
  const name = staticName(field.name);
546
653
  if (name === undefined || name === '__proto__')
547
654
  continue;
655
+ if (fields.has(name)) {
656
+ twice ??= name;
657
+ continue;
658
+ }
548
659
  // The unwrapped value, so an `as const` or a parenthesis stays outside the
549
660
  // span and survives the replacement it wraps.
550
661
  const value = unwrap(field.initializer);
@@ -559,18 +670,26 @@ export function registryEntryLayouts(file, source) {
559
670
  const key = staticName(param.name);
560
671
  if (key === undefined || key === '__proto__')
561
672
  continue;
673
+ if (paramKeys.has(key)) {
674
+ twice ??= `params.${key}`;
675
+ continue;
676
+ }
562
677
  const value = unwrap(param.initializer);
563
678
  paramKeys.set(key, { start: value.getStart(sf), end: value.getEnd() });
564
679
  }
565
680
  }
566
- layouts.set(id, {
681
+ if (twice !== undefined) {
682
+ repeated.set(id, { field: twice });
683
+ continue;
684
+ }
685
+ byId.set(id, {
567
686
  fields,
568
687
  paramKeys,
569
688
  ...(params ? { paramsInsertion: objectInsertion(sf, source, params) } : {}),
570
689
  fieldInsertion: objectInsertion(sf, source, body),
571
690
  });
572
691
  }
573
- return layouts;
692
+ return { byId, repeated };
574
693
  }
575
694
  /**
576
695
  * The `defineRequirements({ … })` literal of a registry file, with the source
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.9.4",
3
+ "version": "0.11.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
  "packageManager": "pnpm@10.28.0",
@@ -56,6 +56,7 @@
56
56
  "attest": "node bin/attest.js",
57
57
  "check:self": "node bin/attest.js check self",
58
58
  "verify:self": "node bin/attest.js verify self",
59
+ "debug:self": "vitest run --config vitest.self.config.ts",
59
60
  "cover:self": "node bin/attest.js cover self",
60
61
  "render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
61
62
  "render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"