@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.
- package/CHANGELOG.md +1404 -220
- package/README.md +23 -31
- package/dist/cli/action.js +8 -4
- package/dist/cli/index.js +7 -2
- package/dist/cli/json.d.ts +28 -1
- package/dist/cli/json.js +10 -1
- package/dist/core/archive.js +26 -3
- package/dist/core/gate.d.ts +16 -6
- package/dist/core/gate.js +18 -8
- package/dist/core/locate.d.ts +27 -0
- package/dist/core/locate.js +3 -2
- package/dist/core/merge.js +1 -0
- package/dist/core/red-record.d.ts +4 -4
- package/dist/core/red-record.js +30 -11
- package/dist/core/registry.d.ts +31 -1
- package/dist/core/registry.js +4 -0
- package/dist/core/skill.js +49 -28
- package/dist/core/splice.d.ts +21 -1
- package/dist/core/splice.js +37 -4
- package/dist/core/static-registry.d.ts +46 -1
- package/dist/core/static-registry.js +139 -20
- package/package.json +2 -1
package/dist/core/skill.js
CHANGED
|
@@ -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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
351
|
-
|
|
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
|
|
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
|
|
package/dist/core/splice.d.ts
CHANGED
|
@@ -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
|
package/dist/core/splice.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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):
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
315
|
-
|
|
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
|
-
/**
|
|
318
|
-
function
|
|
319
|
-
const
|
|
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 (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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"
|