@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/README.md
CHANGED
|
@@ -16,28 +16,6 @@ anyone when a tuning knob changes. A param may be any JSON value, so a
|
|
|
16
16
|
composite constant — a vendor blacklist, a `kind -> payload` table — gets the
|
|
17
17
|
same single source as a lone number, which is where drift is worst.
|
|
18
18
|
|
|
19
|
-
What that does not buy is a warning when you change the value. `check` runs
|
|
20
|
-
nothing, so editing a param leaves it at `✓ No issues` — nothing became unbound,
|
|
21
|
-
nothing became uncovered. The value cannot *diverge* from the assertion, which is
|
|
22
|
-
the stronger property; noticing that it *moved* is `verify`'s job, and only when
|
|
23
|
-
a scenario asserts on the value it read from `params`.
|
|
24
|
-
|
|
25
|
-
Reading the param is necessary and **not sufficient**, which is worth knowing
|
|
26
|
-
before you rely on it: an assertion that recomputes its expectation from the same
|
|
27
|
-
param the code under test just read has no independent term, so both sides move
|
|
28
|
-
together and the test stays green through any edit. Pin the expectation to
|
|
29
|
-
something that does not move with the param — a fixture, a literal in the test,
|
|
30
|
-
or a second independently derived value.
|
|
31
|
-
|
|
32
|
-
A composite param has a second failure of its own, and it runs the other way. When
|
|
33
|
-
a test **loops over** a list it read from `params`, that list is the set of cases
|
|
34
|
-
the run covers: drop a member and every assertion inside the loop still passes
|
|
35
|
-
over what is left, so the suite quietly tests less with nothing to show for it.
|
|
36
|
-
Pin the extent beside the loop — the members against a literal when their identity
|
|
37
|
-
is the promise, the length when the size is. That literal is not the copy the
|
|
38
|
-
single source exists to prevent: it is not what the system is measured against,
|
|
39
|
-
it is what the intent claimed to cover.
|
|
40
|
-
|
|
41
19
|
## Prerequisites
|
|
42
20
|
|
|
43
21
|
- Node ≥ 20.19
|
|
@@ -134,7 +112,14 @@ for exactly one machine-readable document on stdout. Flags, per-command
|
|
|
134
112
|
behaviour and the JSON shape are in the
|
|
135
113
|
[CLI reference](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md).
|
|
136
114
|
|
|
137
|
-
|
|
115
|
+
Reading a param is necessary and **not sufficient** — an assertion that
|
|
116
|
+
recomputes its expectation from the same param the code just read has no
|
|
117
|
+
independent term, and a scenario that loops over a list param covers exactly
|
|
118
|
+
that list. Both have a known shape and a known repair, and both are in
|
|
119
|
+
[Judging your own intent layer](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/intent-quality.md#writing-a-param-that-pins-something),
|
|
120
|
+
along with the decision table — the shape a composite param is best at.
|
|
121
|
+
|
|
122
|
+
## Working with an agent
|
|
138
123
|
|
|
139
124
|
The engine above is only half the framework. The other half is the workflow —
|
|
140
125
|
agree on intent, write the delta, drive the scenarios red, then implement to
|
|
@@ -146,12 +131,19 @@ attest init --target cursor # .cursor/rules/attest.mdc
|
|
|
146
131
|
attest init --target copilot # .github/instructions/attest.instructions.md
|
|
147
132
|
```
|
|
148
133
|
|
|
149
|
-
"
|
|
150
|
-
each of a change's scenarios ended
|
|
151
|
-
`changes/<name>/first-run.json`, and blocks with `never-red` on
|
|
152
|
-
the delta adds whose scenarios were never seen to fail. Commit
|
|
153
|
-
with the change — it is the evidence, and CI has to reach the
|
|
154
|
-
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.
|
|
155
147
|
|
|
156
148
|
An agent loads the document on its own — its description is already in the
|
|
157
149
|
agent's context, or its path matches what you have open — so nothing has to be
|
|
@@ -159,7 +151,7 @@ found or pasted. See
|
|
|
159
151
|
[`attest init`](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md#attest-init)
|
|
160
152
|
for the targets and what `init` deliberately does not write.
|
|
161
153
|
|
|
162
|
-
|
|
154
|
+
## When something goes wrong
|
|
163
155
|
|
|
164
156
|
Every diagnostic carries a `code`, and every code has a section in
|
|
165
157
|
**Troubleshooting** — which the diagnostic itself links to:
|
|
@@ -167,7 +159,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
167
159
|
```
|
|
168
160
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
169
161
|
Value is not a literal.
|
|
170
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.
|
|
162
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.11.0/docs/en/troubleshooting.md#registry-not-static
|
|
171
163
|
```
|
|
172
164
|
|
|
173
165
|
The anchor **is** the code, so the link cannot point somewhere the section
|
package/dist/cli/action.js
CHANGED
|
@@ -73,10 +73,14 @@ export async function runAction(command, opts, action) {
|
|
|
73
73
|
}
|
|
74
74
|
catch (err) {
|
|
75
75
|
if (opts.json) {
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
76
|
+
// Sanitised, though not on this line: `renderJson` strips every string in
|
|
77
|
+
// the document as it serialises it (ATX-74), so the crash path is covered
|
|
78
|
+
// by going through the same function every other report goes through.
|
|
79
|
+
//
|
|
80
|
+
// Not by `JSON.stringify`, which is what this comment used to claim. That
|
|
81
|
+
// escapes C0 and nothing else — DEL and the C1 range survive it — so the
|
|
82
|
+
// replacer in `renderJson` is load-bearing rather than belt and braces.
|
|
83
|
+
// What the project under test writes is still the divert's business, above.
|
|
80
84
|
console.log(renderJson(errorReport(VERSION, command, err)));
|
|
81
85
|
}
|
|
82
86
|
else {
|
package/dist/cli/index.js
CHANGED
|
@@ -261,10 +261,15 @@ program
|
|
|
261
261
|
const { issues: blocking, written } = await run(root(dir), change, {
|
|
262
262
|
vitestConfig: vitestConfig(opts),
|
|
263
263
|
});
|
|
264
|
+
// Built once and handed to both renderings, rather than each being told
|
|
265
|
+
// separately what the merge did. Two renderings of one result agreeing by
|
|
266
|
+
// transcription is what let the verdict disagree with itself (ATX-60),
|
|
267
|
+
// and the paths are the other thing this command has to say.
|
|
268
|
+
const merge = { applied: opts.apply === true, written };
|
|
264
269
|
return {
|
|
265
|
-
report: archiveReport(VERSION, change, blocking),
|
|
270
|
+
report: archiveReport(VERSION, change, blocking, merge),
|
|
266
271
|
human: () => {
|
|
267
|
-
console.log(formatArchiveVerdict(change, blocking,
|
|
272
|
+
console.log(formatArchiveVerdict(change, blocking, merge));
|
|
268
273
|
},
|
|
269
274
|
};
|
|
270
275
|
}));
|
package/dist/cli/json.d.ts
CHANGED
|
@@ -33,6 +33,22 @@ export interface JsonReport {
|
|
|
33
33
|
};
|
|
34
34
|
/** `archive` and `status`: the change being gated or reported on. */
|
|
35
35
|
change?: string;
|
|
36
|
+
/**
|
|
37
|
+
* `archive --apply` only: every path the merge touched, relative to the
|
|
38
|
+
* project root, in the order it touched them.
|
|
39
|
+
*
|
|
40
|
+
* Additive, so no SCHEMA_VERSION bump. **Absent** when `--apply` was not
|
|
41
|
+
* asked for — no merge was attempted, and there is nothing to report — while
|
|
42
|
+
* an **empty array** means the merge ran and had nothing left to write, which
|
|
43
|
+
* is the ordinary state of a re-run after a completed one. Folding the two
|
|
44
|
+
* into `[]` would make the field unable to say which happened.
|
|
45
|
+
*
|
|
46
|
+
* The human rendering has printed these since `--apply` shipped, on the
|
|
47
|
+
* grounds that this is the command that edits files the user cannot
|
|
48
|
+
* regenerate and they need to know which ones to read before committing. That
|
|
49
|
+
* argument does not stop at the reader with a terminal.
|
|
50
|
+
*/
|
|
51
|
+
written?: string[];
|
|
36
52
|
/** `status` only: one row per requirement the change adds. */
|
|
37
53
|
progress?: StatusRow[];
|
|
38
54
|
/** `status` only: roll-up of the rows by obligation state. */
|
|
@@ -83,7 +99,18 @@ export declare function renderReport(version: string, issues: Issue[], outFile?:
|
|
|
83
99
|
* was added for, to say something the array already says.
|
|
84
100
|
*/
|
|
85
101
|
export declare function initReport(version: string, issues: Issue[], outFiles: string[]): JsonReport;
|
|
86
|
-
|
|
102
|
+
/**
|
|
103
|
+
* `archive`, and — when `--apply` was asked for — what the merge wrote.
|
|
104
|
+
*
|
|
105
|
+
* `merge` is the same bundle `formatArchiveVerdict` takes, passed to both from
|
|
106
|
+
* one place in the shell. The two renderings of one result had already grown
|
|
107
|
+
* apart once over the verdict (ATX-60); handing them the same object is what
|
|
108
|
+
* stops the paths from being the second thing they disagree about.
|
|
109
|
+
*/
|
|
110
|
+
export declare function archiveReport(version: string, change: string, blocking: Issue[], merge?: {
|
|
111
|
+
applied: boolean;
|
|
112
|
+
written: readonly string[];
|
|
113
|
+
}): JsonReport;
|
|
87
114
|
/**
|
|
88
115
|
* `status`. The progress rows never touch the verdict: `ok` comes from the same
|
|
89
116
|
* `hasError` predicate as everywhere else, over an `issues` array that carries
|
package/dist/cli/json.js
CHANGED
|
@@ -109,7 +109,15 @@ export function initReport(version, issues, outFiles) {
|
|
|
109
109
|
const [only] = outFiles;
|
|
110
110
|
return outFiles.length === 1 && only !== undefined ? { ...report, outFile: only } : report;
|
|
111
111
|
}
|
|
112
|
-
|
|
112
|
+
/**
|
|
113
|
+
* `archive`, and — when `--apply` was asked for — what the merge wrote.
|
|
114
|
+
*
|
|
115
|
+
* `merge` is the same bundle `formatArchiveVerdict` takes, passed to both from
|
|
116
|
+
* one place in the shell. The two renderings of one result had already grown
|
|
117
|
+
* apart once over the verdict (ATX-60); handing them the same object is what
|
|
118
|
+
* stops the paths from being the second thing they disagree about.
|
|
119
|
+
*/
|
|
120
|
+
export function archiveReport(version, change, blocking, merge) {
|
|
113
121
|
return {
|
|
114
122
|
...envelope({
|
|
115
123
|
version,
|
|
@@ -123,6 +131,7 @@ export function archiveReport(version, change, blocking) {
|
|
|
123
131
|
issues: blocking,
|
|
124
132
|
}),
|
|
125
133
|
change,
|
|
134
|
+
...(merge?.applied ? { written: [...merge.written] } : {}),
|
|
126
135
|
};
|
|
127
136
|
}
|
|
128
137
|
/**
|
package/dist/core/archive.js
CHANGED
|
@@ -35,11 +35,26 @@
|
|
|
35
35
|
// present without a given scenario is one that did and then drifted.
|
|
36
36
|
import { readdir } from 'node:fs/promises';
|
|
37
37
|
import { join } from 'node:path';
|
|
38
|
+
import { forEachBounded } from './locate.js';
|
|
38
39
|
import { byCodeUnit } from './order.js';
|
|
39
40
|
import { relativePath } from './paths.js';
|
|
40
41
|
import { keepOutcome, readRedRecordIn } from './red-record.js';
|
|
41
42
|
/** The directory archived changes are moved into, under the project root. */
|
|
42
43
|
const ARCHIVE_DIR = 'archive';
|
|
44
|
+
/**
|
|
45
|
+
* How many archived first-run records are read at once. Its own constant for
|
|
46
|
+
* the reason `WALK_CONCURRENCY` gives, and a fourth resource: what this bounds
|
|
47
|
+
* is the *parsed records held at once*, not descriptors. `readFile` does
|
|
48
|
+
* open-read-close inside one libuv threadpool work item, so concurrent
|
|
49
|
+
* descriptors track the threadpool and never the fan-out — measured, 3,000
|
|
50
|
+
* unbounded reads peaked at 53 open handles. Heap is what tracks the input, and
|
|
51
|
+
* a project's archive grows by one folder per change merged, forever, so the
|
|
52
|
+
* list is as much the project's shape as a spec tree is.
|
|
53
|
+
* `Promise.all(names.map(...))` put every record in flight at once: measured at
|
|
54
|
+
* the `fs` seam, 64 changes peaked at 64 concurrent reads, and 2,000 records of
|
|
55
|
+
* 45 KB peaked at 352 MB of heap against 177 MB bounded.
|
|
56
|
+
*/
|
|
57
|
+
const ARCHIVE_CONCURRENCY = 32;
|
|
43
58
|
/**
|
|
44
59
|
* Read every archived change folder under `root`.
|
|
45
60
|
*
|
|
@@ -67,10 +82,18 @@ export async function readArchivedChanges(root) {
|
|
|
67
82
|
.filter((e) => e.isDirectory())
|
|
68
83
|
.map((e) => e.name)
|
|
69
84
|
.sort(byCodeUnit);
|
|
70
|
-
|
|
85
|
+
// Preallocated and written by `index`, which is how a `forEachBounded` caller
|
|
86
|
+
// keeps a stable result: the pool calls in input order but completes in
|
|
87
|
+
// whatever order the disk answers, and `names` is already sorted above.
|
|
88
|
+
const changes = new Array(names.length);
|
|
89
|
+
await forEachBounded(names, ARCHIVE_CONCURRENCY, async (name, i) => {
|
|
90
|
+
// `readRedRecordIn` catches its own read and parse, so nothing here can
|
|
91
|
+
// throw and abandon the rest — the same thing `readGuarded` is for on the
|
|
92
|
+
// registry pool, and what keeps every slot of the array above filled.
|
|
71
93
|
const path = join(dir, name);
|
|
72
|
-
|
|
73
|
-
})
|
|
94
|
+
changes[i] = { name, path: relativePath(root, path), firstRun: await readRedRecordIn(path) };
|
|
95
|
+
});
|
|
96
|
+
return changes;
|
|
74
97
|
}
|
|
75
98
|
/**
|
|
76
99
|
* The archive's first-run evidence, merged across every archived change.
|
package/dist/core/gate.d.ts
CHANGED
|
@@ -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,
|
|
106
|
-
* observed
|
|
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
|
|
113
|
-
* no observation at all means
|
|
114
|
-
*
|
|
115
|
-
*
|
|
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,
|
|
136
|
-
* observed
|
|
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
|
|
143
|
-
* no observation at all means
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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}"
|
|
169
|
-
: `scenario "${s.name}" has no
|
|
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;
|
package/dist/core/locate.d.ts
CHANGED
|
@@ -20,6 +20,33 @@ export declare const isProposedSpecFile: (name: string) => boolean;
|
|
|
20
20
|
* of them until its gate passes.
|
|
21
21
|
*/
|
|
22
22
|
export declare const isSpecFile: (name: string) => boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Run `fn` over every item with at most `limit` of them in flight.
|
|
25
|
+
*
|
|
26
|
+
* Extracted at the second call site rather than the first, because a shape
|
|
27
|
+
* written N times is one a later fix reaches only some copies of.
|
|
28
|
+
*
|
|
29
|
+
* **There are three, and the third is the one to look for.** `findFiles` and
|
|
30
|
+
* `parseSpecs` are named for what they fan out over; `loadRegistry` is named for
|
|
31
|
+
* merging, and its concurrency sits under a comment about *ordering* that
|
|
32
|
+
* answers a different question convincingly. Anything here that reads a list the
|
|
33
|
+
* project's tree decides the length of belongs in this pool, whatever the
|
|
34
|
+
* function around it is called.
|
|
35
|
+
*
|
|
36
|
+
* The order `fn` is *called* in is the input order; the order it *completes* in
|
|
37
|
+
* is not, so a caller that needs a stable result either indexes into a
|
|
38
|
+
* preallocated array by `index` or sorts afterwards. All three callers here do
|
|
39
|
+
* one of those, deliberately — as does `readArchivedChanges`, the caller
|
|
40
|
+
* outside this file, which preallocates and writes by `index`.
|
|
41
|
+
*
|
|
42
|
+
* No result is collected and none is needed — every caller writes into something
|
|
43
|
+
* it already owns, and a version returning `T[]` would have to choose an
|
|
44
|
+
* ordering on their behalf. A throw from `fn` propagates and abandons the rest,
|
|
45
|
+
* which is the existing behaviour at all three sites: `parseSpecs` and
|
|
46
|
+
* `loadRegistry` catch per file so that one hostile source scraps only itself
|
|
47
|
+
* (ATX-65), and a failed `readdir` really does end the walk.
|
|
48
|
+
*/
|
|
49
|
+
export declare function forEachBounded<T>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<void>): Promise<void>;
|
|
23
50
|
/**
|
|
24
51
|
* Recursively find files under root whose basename matches `match`.
|
|
25
52
|
*
|
package/dist/core/locate.js
CHANGED
|
@@ -56,7 +56,8 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
|
|
|
56
56
|
* The order `fn` is *called* in is the input order; the order it *completes* in
|
|
57
57
|
* is not, so a caller that needs a stable result either indexes into a
|
|
58
58
|
* preallocated array by `index` or sorts afterwards. All three callers here do
|
|
59
|
-
* one of those, deliberately
|
|
59
|
+
* one of those, deliberately — as does `readArchivedChanges`, the caller
|
|
60
|
+
* outside this file, which preallocates and writes by `index`.
|
|
60
61
|
*
|
|
61
62
|
* No result is collected and none is needed — every caller writes into something
|
|
62
63
|
* it already owns, and a version returning `T[]` would have to choose an
|
|
@@ -65,7 +66,7 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
|
|
|
65
66
|
* `loadRegistry` catch per file so that one hostile source scraps only itself
|
|
66
67
|
* (ATX-65), and a failed `readdir` really does end the walk.
|
|
67
68
|
*/
|
|
68
|
-
async function forEachBounded(items, limit, fn) {
|
|
69
|
+
export async function forEachBounded(items, limit, fn) {
|
|
69
70
|
let cursor = 0;
|
|
70
71
|
const worker = async () => {
|
|
71
72
|
for (let i = cursor++; i < items.length; i = cursor++) {
|
package/dist/core/merge.js
CHANGED
|
@@ -319,6 +319,7 @@ function modifyRefusal(root, file, refusal) {
|
|
|
319
319
|
const where = refusal.field ? `${refusal.reqId}.${refusal.field}` : refusal.reqId;
|
|
320
320
|
const why = {
|
|
321
321
|
comment: `a comment sits inside the value it would replace, and which side of that edit the comment belongs to is not something --apply can decide`,
|
|
322
|
+
'duplicate-key': `that name is written more than once in the file, so the span --apply located is not the one the registry's readers evaluate`,
|
|
322
323
|
'entry-not-found': `the registry file that owns its prefix holds no entry for it`,
|
|
323
324
|
'not-a-literal': `the value it would replace is not written as a literal`,
|
|
324
325
|
'param-dropped': `the proved end state does not carry that param, and a delta has no way to say one was removed`,
|
|
@@ -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
|
|
21
|
+
* reqId -> spec file -> scenario name -> the strongest outcome observed for it.
|
|
22
22
|
*
|
|
23
|
-
* Deliberately not a list of "red scenarios": a scenario
|
|
24
|
-
* is the case mechanism 2 exists to catch, so it has to be recorded as a
|
|
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
|
package/dist/core/red-record.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
// Mechanism 2's record:
|
|
2
|
-
//
|
|
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
|
|
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
|
|
@@ -320,20 +329,30 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
|
|
|
320
329
|
forFile[s.name] = kept;
|
|
321
330
|
changed = true;
|
|
322
331
|
}
|
|
323
|
-
// Sort
|
|
324
|
-
//
|
|
325
|
-
|
|
332
|
+
// Sort at every level, for the same byte-stability reason — the ids included.
|
|
333
|
+
//
|
|
334
|
+
// The rebuild above sorts only the ids the record already held; the loop after
|
|
335
|
+
// it *appends* the ones this run is the first to observe, in plan order. So
|
|
336
|
+
// the ids are unsorted by the time this runs, and this is the level that has
|
|
337
|
+
// to put them in order.
|
|
338
|
+
//
|
|
339
|
+
// A rebuild rather than an in-place sort, which is the trap: reassigning
|
|
340
|
+
// `record[id]` writes a value and does not move a key, so a version of this
|
|
341
|
+
// that sorted the two inner levels while walking the ids as they came would
|
|
342
|
+
// leave the outer order exactly as it found it.
|
|
343
|
+
const sorted = emptyMap();
|
|
344
|
+
for (const id of Object.keys(record).sort(byCodeUnit)) {
|
|
326
345
|
const sortedFiles = emptyMap();
|
|
327
346
|
for (const file of Object.keys(record[id]).sort(byCodeUnit)) {
|
|
328
|
-
const
|
|
347
|
+
const names = emptyMap();
|
|
329
348
|
for (const name of Object.keys(record[id][file]).sort(byCodeUnit)) {
|
|
330
|
-
|
|
349
|
+
names[name] = record[id][file][name];
|
|
331
350
|
}
|
|
332
|
-
sortedFiles[file] =
|
|
351
|
+
sortedFiles[file] = names;
|
|
333
352
|
}
|
|
334
|
-
|
|
353
|
+
sorted[id] = sortedFiles;
|
|
335
354
|
}
|
|
336
|
-
return { record, changed };
|
|
355
|
+
return { record: sorted, changed };
|
|
337
356
|
}
|
|
338
357
|
/** Serialise the record. A trailing newline, so the file is a well-formed text file. */
|
|
339
358
|
export function serialiseRedRecord(changeName, record) {
|
package/dist/core/registry.d.ts
CHANGED
|
@@ -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 &
|
|
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
|
package/dist/core/registry.js
CHANGED
|
@@ -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
|