@am_shork/attest 0.10.0 → 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.
package/README.md CHANGED
@@ -131,12 +131,19 @@ attest init --target cursor # .cursor/rules/attest.mdc
131
131
  attest init --target copilot # .github/instructions/attest.instructions.md
132
132
  ```
133
133
 
134
- "Red before you implement" is enforced rather than advised: `archive` records how
135
- each of a change's scenarios ended on its first run, into
136
- `changes/<name>/first-run.json`, and blocks with `never-red` on any requirement
137
- the delta adds whose scenarios were never seen to fail. Commit `first-run.json`
138
- with the change — it is the evidence, and CI has to reach the same verdict as
139
- you do.
134
+ "Every scenario has been seen to fail" is enforced rather than advised:
135
+ `archive` records how each of a change's scenarios ended in every run it
136
+ observes, into `changes/<name>/first-run.json`, and blocks with `never-red` on
137
+ any requirement the delta adds whose scenarios were never seen to fail. Commit
138
+ `first-run.json` with the change — it is the evidence, and CI has to reach the
139
+ same verdict as you do.
140
+
141
+ **It does not require you to write the test first.** A recorded failure is
142
+ permanent and a recorded pass is not, so a failure observed after the
143
+ implementation exists satisfies the gate exactly like one observed before it:
144
+ if you wrote the intent, the scenario and the code together, remove the
145
+ implementation, run `archive`, and put it back. What is enforced is that the
146
+ assertion can fail — not the order you worked in.
140
147
 
141
148
  An agent loads the document on its own — its description is already in the
142
149
  agent's context, or its path matches what you have open — so nothing has to be
@@ -152,7 +159,7 @@ Every diagnostic carries a `code`, and every code has a section in
152
159
  ```
153
160
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
154
161
  Value is not a literal.
155
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.10.0/docs/en/troubleshooting.md#registry-not-static
162
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.11.0/docs/en/troubleshooting.md#registry-not-static
156
163
  ```
157
164
 
158
165
  The anchor **is** the code, so the link cannot point somewhere the section
@@ -102,17 +102,27 @@ export declare function specLoadFailedIssues(run: RunResult, already?: ReadonlyS
102
102
  */
103
103
  export declare function notRunIssues(plan: AttestPlan, run: RunResult, specific?: readonly Issue[]): Issue[];
104
104
  /**
105
- * Never-red: a scenario attesting a requirement this change ADDs, whose first
106
- * observed run did not fail (design §6, mechanism 2).
105
+ * Never-red: a scenario attesting a requirement this change ADDs, which no run
106
+ * the gate observed has seen fail (design §6, mechanism 2).
107
107
  *
108
108
  * This is the half of the gate that certifies the tests ever *discriminated*.
109
109
  * Green at archive time says only that they pass now — a scenario that asserted
110
110
  * nothing has been green since the commit that added it and clears every other
111
111
  * check identically. The two states are reported apart because the fixes differ:
112
- * a first run that passed means the scenario does not test what it claims, while
113
- * no observation at all means the workflow's stage-1 step was skipped, and the
114
- * remedy for the second is to run the gate before implementing rather than to
115
- * rewrite anything.
112
+ * a scenario observed passing and never failing does not test what it claims,
113
+ * while no observation at all means no run has been taken yet, and the remedy
114
+ * for the second is to take one rather than to rewrite anything.
115
+ *
116
+ * **The obligation is discrimination, and it says nothing about ordering.** The
117
+ * record is monotonic toward failure but a pass is provisional (`red-record.ts`),
118
+ * so a failure observed *after* the implementation exists replaces a recorded
119
+ * pass and satisfies this exactly like one observed before it. Running the gate
120
+ * first is the cheapest route to the evidence and not the only one: removing the
121
+ * implementation, running the gate, and restoring it produces the same fact by
122
+ * the same command. Nothing here could enforce an order anyway — a stage-1 red
123
+ * can come from a typo in the spec rather than from the requirement being
124
+ * unimplemented, so an earlier observation was never stronger evidence, only
125
+ * earlier.
116
126
  *
117
127
  * Scoped to ADDED ids only. A requirement written straight into the base
118
128
  * registry — which is how existing behaviour gets described, in a brownfield
package/dist/core/gate.js CHANGED
@@ -132,17 +132,27 @@ export function notRunIssues(plan, run, specific = []) {
132
132
  ];
133
133
  }
134
134
  /**
135
- * Never-red: a scenario attesting a requirement this change ADDs, whose first
136
- * observed run did not fail (design §6, mechanism 2).
135
+ * Never-red: a scenario attesting a requirement this change ADDs, which no run
136
+ * the gate observed has seen fail (design §6, mechanism 2).
137
137
  *
138
138
  * This is the half of the gate that certifies the tests ever *discriminated*.
139
139
  * Green at archive time says only that they pass now — a scenario that asserted
140
140
  * nothing has been green since the commit that added it and clears every other
141
141
  * check identically. The two states are reported apart because the fixes differ:
142
- * a first run that passed means the scenario does not test what it claims, while
143
- * no observation at all means the workflow's stage-1 step was skipped, and the
144
- * remedy for the second is to run the gate before implementing rather than to
145
- * rewrite anything.
142
+ * a scenario observed passing and never failing does not test what it claims,
143
+ * while no observation at all means no run has been taken yet, and the remedy
144
+ * for the second is to take one rather than to rewrite anything.
145
+ *
146
+ * **The obligation is discrimination, and it says nothing about ordering.** The
147
+ * record is monotonic toward failure but a pass is provisional (`red-record.ts`),
148
+ * so a failure observed *after* the implementation exists replaces a recorded
149
+ * pass and satisfies this exactly like one observed before it. Running the gate
150
+ * first is the cheapest route to the evidence and not the only one: removing the
151
+ * implementation, running the gate, and restoring it produces the same fact by
152
+ * the same command. Nothing here could enforce an order anyway — a stage-1 red
153
+ * can come from a typo in the spec rather than from the requirement being
154
+ * unimplemented, so an earlier observation was never stronger evidence, only
155
+ * earlier.
146
156
  *
147
157
  * Scoped to ADDED ids only. A requirement written straight into the base
148
158
  * registry — which is how existing behaviour gets described, in a brownfield
@@ -165,8 +175,8 @@ export function neverRedIssues(plan, addedIds, firstRun) {
165
175
  file: s.file,
166
176
  line: s.line,
167
177
  message: outcome === 'pass'
168
- ? `scenario "${s.name}" passed on its first run, so it never showed that ${s.reqId} was unimplemented; give it an assertion that fails without the change and run the gate again — a later failing run replaces this record.`
169
- : `scenario "${s.name}" has no recorded first run for ${s.reqId}; run \`attest archive\` before implementing, so ${RED_RECORD_FILE} records it failing.`,
178
+ ? `scenario "${s.name}" was observed passing and never failing, so it has not shown that it can fail without ${s.reqId} being implemented; give it an assertion that discriminates and run the gate again — a later failing run replaces this record.`
179
+ : `scenario "${s.name}" has no observed run recorded for ${s.reqId}; run \`attest archive\` with the implementation absent — before writing it, or with it removed — so ${RED_RECORD_FILE} records the scenario failing.`,
170
180
  });
171
181
  }
172
182
  return issues;
@@ -18,11 +18,11 @@ export declare function redRecordPath(root: string, changeName: string): string;
18
18
  /** The file name, exported so a diagnostic can name it without rebuilding it. */
19
19
  export declare const RED_RECORD_FILE = "first-run.json";
20
20
  /**
21
- * reqId -> spec file -> scenario name -> the outcome of its first observed run.
21
+ * reqId -> spec file -> scenario name -> the strongest outcome observed for it.
22
22
  *
23
- * Deliberately not a list of "red scenarios": a scenario whose first run passed
24
- * is the case mechanism 2 exists to catch, so it has to be recorded as a fact
25
- * rather than as an absence. An absence then means only one thing — never
23
+ * Deliberately not a list of "red scenarios": a scenario only ever observed
24
+ * passing is the case mechanism 2 exists to catch, so it has to be recorded as a
25
+ * fact rather than as an absence. An absence then means only one thing — never
26
26
  * observed at all — and the gate can report the two separately.
27
27
  *
28
28
  * **The file level is what identifies a scenario, and it was missing.** A
@@ -1,5 +1,13 @@
1
- // Mechanism 2's record: what each of a change's scenarios did the *first* time
2
- // it ran (design §6).
1
+ // Mechanism 2's record: the strongest outcome the gate has ever observed for
2
+ // each of a change's scenarios (design §6).
3
+ //
4
+ // Not "the first run", which is what the file is named after and what this was
5
+ // once described as. The record is revised — see the asymmetry below — and the
6
+ // difference is the whole reason the *order* of the observations is free: an
7
+ // author who has already written the implementation earns the evidence by
8
+ // removing it, running the gate, and restoring it. The file keeps its name
9
+ // because archived records already carry it (`inspect.ts`) and a rename would
10
+ // spend that to fix a word.
3
11
  //
4
12
  // §8's gate requires green at archive time. That certifies "these tests pass",
5
13
  // not "these tests ever discriminated" — a scenario that asserted nothing has
@@ -36,7 +44,8 @@
36
44
  // change red. Once a scenario has been seen to fail, nothing can take it back.
37
45
  //
38
46
  // Provisional pass: plain first-write-wins was the first design here, and it
39
- // makes the defect this mechanism exists to catch *unrecoverable*. A scenario
47
+ // makes the defect this mechanism exists to catch *unrecoverable*, and it is
48
+ // also what would have made the ordering real. A scenario
40
49
  // that passed on its first run is one that asserts nothing; the fix is to give
41
50
  // it a real assertion and watch it fail — but under first-write-wins that
42
51
  // observation could never be recorded, so the only way out was deleting the
@@ -115,8 +115,34 @@ type ProposedRequirements<T extends RegistryDelta> = T extends {
115
115
  * helper over a whole registry; the ids a single change adds are few and known
116
116
  * at the call site, so the stricter shape costs nothing and rejects a mistyped
117
117
  * id outright instead of leaving it to `check`.
118
+ *
119
+ * **`added` is withheld from the type although the value still carries it**, and
120
+ * that is the one place this shape deliberately understates its own object. Both
121
+ * `reqs['AUTH-7'].params.x` and `d.added['AUTH-7'].params.x` are correct during
122
+ * stage 1; only the first survives `--apply`, which repoints the specifier at
123
+ * the merged registry, and a registry has no `added`. Nothing else catches that:
124
+ * the gate runs before the rename, `check` executes nothing, and the compiler
125
+ * sees whichever half is on disk — so the difference appears one command later,
126
+ * as a `spec-load-failed` naming the file and not the cause.
127
+ *
128
+ * The optional `never` rather than a bare `Omit` is load-bearing.
129
+ * `RegistryDelta`'s four fields are all optional, so an added-only delta with
130
+ * `added` removed has *no* property in common with it and TypeScript's weak-type
131
+ * check refuses `applyDelta(base, delta({ added: … }))` outright — nine call
132
+ * sites in this tree, none of them wrong. An optional property restores the
133
+ * overlap while still refusing the read, and the refusal survives the escape
134
+ * hatches that would make it advisory: `d.added!` and `d.added?.[…]` both land
135
+ * on `never`, and only an explicit `as any` compiles.
136
+ *
137
+ * `modified`, `removed` and `renamed` are left alone on purpose. They vanish at
138
+ * the merge too, but none of them has a by-id equivalent — there is no correct
139
+ * spelling to redirect an author to, so a spec reading one is reading delta-only
140
+ * data rather than misspelling a right thing, which is a different defect and
141
+ * has never been reported.
118
142
  */
119
- export type DefinedDelta<T extends RegistryDelta> = T & ProposedRequirements<T>;
143
+ export type DefinedDelta<T extends RegistryDelta> = Omit<T, 'added'> & {
144
+ added?: never;
145
+ } & ProposedRequirements<T>;
120
146
  /**
121
147
  * Declare a registry delta for a change (design §7). Ordered idempotent
122
148
  * application (RENAMED -> REMOVED -> MODIFIED -> ADDED) lives in applyDelta.
@@ -130,6 +156,10 @@ export type DefinedDelta<T extends RegistryDelta> = T & ProposedRequirements<T>;
130
156
  * exists to reward — and then billed them the rewrite at merge for having
131
157
  * complied. Now merging a spec changes its import and nothing else.
132
158
  *
159
+ * That rejected expression is now rejected by the compiler rather than by
160
+ * this comment — {@link DefinedDelta} withholds `added`, because it was the
161
+ * one wrong spelling that stage 1 could not tell from the right one.
162
+ *
133
163
  * Still no validation, deliberately. A delta carrying an id the registry would
134
164
  * refuse is an `add-invalid` / `rename-target-invalid` ERROR from the gate
135
165
  * (ATX-41), which is where a reviewer sees it; throwing here would move that
@@ -48,6 +48,10 @@ export function defineRequirements(input) {
48
48
  * exists to reward — and then billed them the rewrite at merge for having
49
49
  * complied. Now merging a spec changes its import and nothing else.
50
50
  *
51
+ * That rejected expression is now rejected by the compiler rather than by
52
+ * this comment — {@link DefinedDelta} withholds `added`, because it was the
53
+ * one wrong spelling that stage 1 could not tell from the right one.
54
+ *
51
55
  * Still no validation, deliberately. A delta carrying an id the registry would
52
56
  * refuse is an `add-invalid` / `rename-target-invalid` ERROR from the gate
53
57
  * (ATX-41), which is where a reviewer sees it; throwing here would move that
@@ -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
 
@@ -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';
@@ -470,9 +471,32 @@ function sameValue(a, b) {
470
471
  * property of its module resolution, uniform across the project, and already
471
472
  * answered by the specifier sitting in front of us — the third spelling
472
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.
473
494
  */
474
495
  export function repointImport(file, source, from, to) {
475
- 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;
476
500
  const dir = dirname(file);
477
501
  const edits = [];
478
502
  for (const statement of sf.statements) {
@@ -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 —
@@ -309,41 +309,105 @@ function constInitializer(sf, name) {
309
309
  function authoringCall(expr, sf, fn) {
310
310
  if (!ts.isCallExpression(expr) || expr.arguments.length !== 1)
311
311
  return undefined;
312
- if (!callsAuthoringFn(expr, localNames(sf, fn), fn))
312
+ if (!callsAuthoringFn(expr, authoringNames(sf, fn), fn))
313
313
  return undefined;
314
314
  return unwrap(expr.arguments[0]);
315
315
  }
316
316
  /**
317
- * 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.
318
318
  *
319
319
  * One predicate for both readers of this question — the extraction above and the
320
320
  * id recovery below — because they must not be able to disagree about what
321
321
  * counts as the authoring call. An alias rule taught to one and not the other
322
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.
323
347
  */
324
348
  function callsAuthoringFn(call, names, fn) {
325
349
  const callee = call.expression;
326
- return ((ts.isIdentifier(callee) && names.has(callee.text)) ||
327
- (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);
328
362
  }
329
- /** Local names bound to the imported `fn`, including aliases. */
330
- function localNames(sf, fn) {
331
- 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();
332
367
  for (const statement of sf.statements) {
333
- if (!ts.isImportDeclaration(statement))
334
- continue;
335
- const bindings = statement.importClause?.namedBindings;
336
- 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
+ }
337
376
  continue;
338
- for (const el of bindings.elements) {
339
- if ((el.propertyName ?? el.name).text === fn)
340
- names.add(el.name.text);
341
377
  }
378
+ for (const name of declaredNames(statement))
379
+ declared.add(name);
342
380
  }
343
381
  // A file that never imported it can still be read: the import may come from a
344
382
  // global or a test harness, and the callee name is the only signal we need.
345
- names.add(fn);
346
- 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
+ }
347
411
  }
348
412
  /** Strip the type-level wrappers that do not change the value (`as const`, `satisfies`, parens). */
349
413
  function unwrap(node) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.10.0",
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"