@am_shork/attest 0.9.3 → 0.10.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 +1027 -168
- package/README.md +10 -25
- package/bin/attest.js +0 -0
- 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/apply.js +5 -0
- package/dist/core/archive.js +26 -3
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +2 -0
- package/dist/core/gate.d.ts +8 -0
- package/dist/core/gate.js +38 -0
- 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.js +18 -8
- package/dist/core/render.js +8 -0
- package/dist/core/schema.d.ts +4 -0
- package/dist/core/schema.js +11 -0
- package/dist/core/skill.js +25 -0
- package/dist/core/splice.d.ts +6 -6
- package/dist/core/splice.js +51 -14
- package/dist/core/static-registry.d.ts +46 -1
- package/dist/core/static-registry.js +59 -4
- package/dist/core/validator.d.ts +2 -1
- package/dist/core/validator.js +20 -1
- package/package.json +31 -17
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
|
|
@@ -159,7 +144,7 @@ found or pasted. See
|
|
|
159
144
|
[`attest init`](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md#attest-init)
|
|
160
145
|
for the targets and what `init` deliberately does not write.
|
|
161
146
|
|
|
162
|
-
|
|
147
|
+
## When something goes wrong
|
|
163
148
|
|
|
164
149
|
Every diagnostic carries a `code`, and every code has a section in
|
|
165
150
|
**Troubleshooting** — which the diagnostic itself links to:
|
|
@@ -167,7 +152,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
167
152
|
```
|
|
168
153
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
169
154
|
Value is not a literal.
|
|
170
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.
|
|
155
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.10.0/docs/en/troubleshooting.md#registry-not-static
|
|
171
156
|
```
|
|
172
157
|
|
|
173
158
|
The anchor **is** the code, so the link cannot point somewhere the section
|
package/bin/attest.js
CHANGED
|
File without changes
|
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/apply.js
CHANGED
|
@@ -97,6 +97,10 @@ export function applyDelta(base, d) {
|
|
|
97
97
|
...patch,
|
|
98
98
|
params: { ...existing.params, ...(patch.params ?? {}) },
|
|
99
99
|
outOfScope: patch.outOfScope ?? existing.outOfScope,
|
|
100
|
+
// Replaced wholesale rather than merged, exactly as `outOfScope` is: a
|
|
101
|
+
// patch that closes a question says so by writing the list that remains,
|
|
102
|
+
// and a union would make closing the last one impossible to express.
|
|
103
|
+
open: patch.open ?? existing.open,
|
|
100
104
|
};
|
|
101
105
|
const parsed = RequirementSchema.safeParse(merged);
|
|
102
106
|
if (!parsed.success) {
|
|
@@ -209,6 +213,7 @@ function canonical(req) {
|
|
|
209
213
|
// against itself. Code-unit order throughout, and `sortDeep` says why.
|
|
210
214
|
params: sortDeep(req.params),
|
|
211
215
|
outOfScope: [...req.outOfScope],
|
|
216
|
+
open: [...req.open],
|
|
212
217
|
});
|
|
213
218
|
}
|
|
214
219
|
//# sourceMappingURL=apply.js.map
|
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/docs.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and the `##` headings of both language documents, so landing here cannot
|
|
9
9
|
* produce a dead link.
|
|
10
10
|
*/
|
|
11
|
-
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-from-failed-registry", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file", "unsafe-target-path"];
|
|
11
|
+
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "open-unresolved", "orphan-from-failed-registry", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "requirement-open", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file", "unsafe-target-path"];
|
|
12
12
|
export type IssueCode = (typeof ISSUE_CODES)[number];
|
|
13
13
|
/**
|
|
14
14
|
* The page explaining `code`, or `undefined` when nothing explains it.
|
package/dist/core/docs.js
CHANGED
|
@@ -37,6 +37,7 @@ export const ISSUE_CODES = [
|
|
|
37
37
|
'modify-missing',
|
|
38
38
|
'never-red',
|
|
39
39
|
'non-scalar-interpolation',
|
|
40
|
+
'open-unresolved',
|
|
40
41
|
'orphan-from-failed-registry',
|
|
41
42
|
'orphan-test',
|
|
42
43
|
'possible-drift',
|
|
@@ -49,6 +50,7 @@ export const ISSUE_CODES = [
|
|
|
49
50
|
'rename-source-missing',
|
|
50
51
|
'rename-target-exists',
|
|
51
52
|
'rename-target-invalid',
|
|
53
|
+
'requirement-open',
|
|
52
54
|
'spec-in-change-dir',
|
|
53
55
|
'spec-load-failed',
|
|
54
56
|
'stale-spec-doc',
|
package/dist/core/gate.d.ts
CHANGED
|
@@ -120,6 +120,14 @@ export declare function notRunIssues(plan: AttestPlan, run: RunResult, specific?
|
|
|
120
120
|
* never a moment when its scenario was supposed to fail.
|
|
121
121
|
*/
|
|
122
122
|
export declare function neverRedIssues(plan: AttestPlan, addedIds: readonly string[], firstRun: RedRecord): Issue[];
|
|
123
|
+
/**
|
|
124
|
+
* Every requirement that still declares an open question.
|
|
125
|
+
*
|
|
126
|
+
* Exported for the same reason `neverRedIssues` is: it is one of the gate's
|
|
127
|
+
* obligations, and a caller projecting the gate's verdict has to reach the same
|
|
128
|
+
* answer from the same function rather than re-deriving it.
|
|
129
|
+
*/
|
|
130
|
+
export declare function openIssues(registry: Registry): Issue[];
|
|
123
131
|
/**
|
|
124
132
|
* Decide whether a change may be archived. Returns the blocking issues; an
|
|
125
133
|
* empty array means the gate passes (design §8). Checks, in order:
|
package/dist/core/gate.js
CHANGED
|
@@ -171,6 +171,27 @@ export function neverRedIssues(plan, addedIds, firstRun) {
|
|
|
171
171
|
}
|
|
172
172
|
return issues;
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Every requirement that still declares an open question.
|
|
176
|
+
*
|
|
177
|
+
* Exported for the same reason `neverRedIssues` is: it is one of the gate's
|
|
178
|
+
* obligations, and a caller projecting the gate's verdict has to reach the same
|
|
179
|
+
* answer from the same function rather than re-deriving it.
|
|
180
|
+
*/
|
|
181
|
+
export function openIssues(registry) {
|
|
182
|
+
const out = [];
|
|
183
|
+
for (const [id, req] of Object.entries(registry)) {
|
|
184
|
+
for (const question of req.open) {
|
|
185
|
+
out.push({
|
|
186
|
+
level: 'ERROR',
|
|
187
|
+
code: 'open-unresolved',
|
|
188
|
+
reqId: id,
|
|
189
|
+
message: `Requirement "${id}" still declares an open question, so the registry is not done: ${question} Answer it and remove the entry from open, then run the gate again.`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
174
195
|
/**
|
|
175
196
|
* Decide whether a change may be archived. Returns the blocking issues; an
|
|
176
197
|
* empty array means the gate passes (design §8). Checks, in order:
|
|
@@ -209,6 +230,23 @@ export function evaluateGate({ registry, plan, run, addedIds, unmergedAddedIds,
|
|
|
209
230
|
if (addedIds && addedIds.length > 0) {
|
|
210
231
|
blocking.push(...neverRedIssues(plan, addedIds, firstRun ?? {}));
|
|
211
232
|
}
|
|
233
|
+
// 6) Open questions: nothing in the end-state registry may still be undecided.
|
|
234
|
+
//
|
|
235
|
+
// **Over the whole registry rather than the ids this change adds**, which is
|
|
236
|
+
// where it parts company with step 5 above. `never-red` is scoped to added ids
|
|
237
|
+
// because "was ever observed failing" is a fact about history and cannot be
|
|
238
|
+
// demanded retroactively of a requirement written straight into the base
|
|
239
|
+
// registry. An open field is not a historical fact; it is a claim that is true
|
|
240
|
+
// right now, so step 1's whole-registry re-validation is the precedent this
|
|
241
|
+
// belongs to. The scoping is also what closes the path the narrow form would
|
|
242
|
+
// open: `applyDelta` merges a MODIFIED patch field-wise, so an id that
|
|
243
|
+
// archived long ago can acquire `open` from a later change, and an
|
|
244
|
+
// added-ids-only gate would never look at it.
|
|
245
|
+
//
|
|
246
|
+
// The cost is deliberate: one unresolved question anywhere blocks every
|
|
247
|
+
// unrelated change from archiving. That is the intended reading of a registry
|
|
248
|
+
// that is not done, and it is recoverable by answering the question.
|
|
249
|
+
blocking.push(...openIssues(registry));
|
|
212
250
|
return blocking;
|
|
213
251
|
}
|
|
214
252
|
//# sourceMappingURL=gate.js.map
|
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`,
|
package/dist/core/red-record.js
CHANGED
|
@@ -320,20 +320,30 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
|
|
|
320
320
|
forFile[s.name] = kept;
|
|
321
321
|
changed = true;
|
|
322
322
|
}
|
|
323
|
-
// Sort
|
|
324
|
-
//
|
|
325
|
-
|
|
323
|
+
// Sort at every level, for the same byte-stability reason — the ids included.
|
|
324
|
+
//
|
|
325
|
+
// The rebuild above sorts only the ids the record already held; the loop after
|
|
326
|
+
// it *appends* the ones this run is the first to observe, in plan order. So
|
|
327
|
+
// the ids are unsorted by the time this runs, and this is the level that has
|
|
328
|
+
// to put them in order.
|
|
329
|
+
//
|
|
330
|
+
// A rebuild rather than an in-place sort, which is the trap: reassigning
|
|
331
|
+
// `record[id]` writes a value and does not move a key, so a version of this
|
|
332
|
+
// that sorted the two inner levels while walking the ids as they came would
|
|
333
|
+
// leave the outer order exactly as it found it.
|
|
334
|
+
const sorted = emptyMap();
|
|
335
|
+
for (const id of Object.keys(record).sort(byCodeUnit)) {
|
|
326
336
|
const sortedFiles = emptyMap();
|
|
327
337
|
for (const file of Object.keys(record[id]).sort(byCodeUnit)) {
|
|
328
|
-
const
|
|
338
|
+
const names = emptyMap();
|
|
329
339
|
for (const name of Object.keys(record[id][file]).sort(byCodeUnit)) {
|
|
330
|
-
|
|
340
|
+
names[name] = record[id][file][name];
|
|
331
341
|
}
|
|
332
|
-
sortedFiles[file] =
|
|
342
|
+
sortedFiles[file] = names;
|
|
333
343
|
}
|
|
334
|
-
|
|
344
|
+
sorted[id] = sortedFiles;
|
|
335
345
|
}
|
|
336
|
-
return { record, changed };
|
|
346
|
+
return { record: sorted, changed };
|
|
337
347
|
}
|
|
338
348
|
/** Serialise the record. A trailing newline, so the file is a well-formed text file. */
|
|
339
349
|
export function serialiseRedRecord(changeName, record) {
|
package/dist/core/render.js
CHANGED
|
@@ -83,6 +83,7 @@ function sanitised(registry) {
|
|
|
83
83
|
rationale: control(req.rationale),
|
|
84
84
|
params: Object.fromEntries(Object.entries(req.params).map(([k, v]) => [control(k), sanitisedValue(v)])),
|
|
85
85
|
outOfScope: req.outOfScope.map(control),
|
|
86
|
+
open: req.open.map(control),
|
|
86
87
|
};
|
|
87
88
|
}
|
|
88
89
|
return out;
|
|
@@ -231,6 +232,13 @@ function section(id, req) {
|
|
|
231
232
|
if (req.outOfScope.length > 0) {
|
|
232
233
|
out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${prose(s)}`));
|
|
233
234
|
}
|
|
235
|
+
// Last, and rendered at all because this document is the only human review
|
|
236
|
+
// surface a requirement has: a reader who is told what a requirement promises
|
|
237
|
+
// without being told what about it is still undecided has been told the
|
|
238
|
+
// stronger half of a claim on its own.
|
|
239
|
+
if (req.open.length > 0) {
|
|
240
|
+
out.push('', '**Open**', '', ...req.open.map((s) => `- ${prose(s)}`));
|
|
241
|
+
}
|
|
234
242
|
return out;
|
|
235
243
|
}
|
|
236
244
|
/** The statement as the document says it, with its params in place. */
|
package/dist/core/schema.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export declare const RequirementSchema: z.ZodObject<{
|
|
|
11
11
|
rationale: z.ZodString;
|
|
12
12
|
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
|
|
13
13
|
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
14
|
+
open: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
14
15
|
}, z.core.$strip>;
|
|
15
16
|
/**
|
|
16
17
|
* The id grammar, on its own.
|
|
@@ -45,16 +46,19 @@ export declare const RegistrySchema: z.ZodPipe<z.ZodCustom<Record<string, {
|
|
|
45
46
|
rationale: string;
|
|
46
47
|
params?: Record<string, unknown> | undefined;
|
|
47
48
|
outOfScope?: string[] | undefined;
|
|
49
|
+
open?: string[] | undefined;
|
|
48
50
|
}>, Record<string, {
|
|
49
51
|
statement: string;
|
|
50
52
|
rationale: string;
|
|
51
53
|
params?: Record<string, unknown> | undefined;
|
|
52
54
|
outOfScope?: string[] | undefined;
|
|
55
|
+
open?: string[] | undefined;
|
|
53
56
|
}>>, z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
54
57
|
statement: z.ZodString;
|
|
55
58
|
rationale: z.ZodString;
|
|
56
59
|
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
|
|
57
60
|
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
61
|
+
open: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
58
62
|
}, z.core.$strip>>>;
|
|
59
63
|
/** Parsed (output) shapes — defaults applied. */
|
|
60
64
|
export type Requirement = z.infer<typeof RequirementSchema>;
|
package/dist/core/schema.js
CHANGED
|
@@ -63,6 +63,17 @@ export const RequirementSchema = z.object({
|
|
|
63
63
|
// instance.
|
|
64
64
|
params: z.record(z.string(), paramValue).default({}),
|
|
65
65
|
outOfScope: z.array(z.string()).default([]),
|
|
66
|
+
// What is still undecided about this requirement, in free text, one entry per
|
|
67
|
+
// question. Shaped as `outOfScope` rather than as a new kind of thing: every
|
|
68
|
+
// place that has to learn about it — the patch merge, both splice writers, the
|
|
69
|
+
// renderer — already carries that field's branch to copy, and a defaulted
|
|
70
|
+
// array means an empty list and an absent field are the same state, so no
|
|
71
|
+
// caller has to distinguish "closed" from "never opened".
|
|
72
|
+
//
|
|
73
|
+
// No grammar is imposed on an entry. A greppable free-text line is what
|
|
74
|
+
// spec-kit's `[NEEDS CLARIFICATION: …]` is, and constraining it here would be
|
|
75
|
+
// deciding for every adopter what counts as a question.
|
|
76
|
+
open: z.array(z.string()).default([]),
|
|
66
77
|
});
|
|
67
78
|
/**
|
|
68
79
|
* The id grammar, on its own.
|
package/dist/core/skill.js
CHANGED
|
@@ -132,6 +132,30 @@ promises is a two-stage workflow, and the stages are separate on purpose.
|
|
|
132
132
|
or it is an \`unbound-param\` ERROR. A \`{placeholder}\` written into a
|
|
133
133
|
*rationale* is a \`rationale-placeholder\` WARNING — rationales are not
|
|
134
134
|
interpolated, so it would reach the rendered document with its braces intact.
|
|
135
|
+
- **If you cannot determine a value, say so — do not pick one.** \`open\` is a
|
|
136
|
+
list of what is still undecided about the requirement, in free text, one entry
|
|
137
|
+
per question. Each is a \`requirement-open\` WARNING from the static commands,
|
|
138
|
+
which does not fail them, so a proposal may be legitimately half-finished
|
|
139
|
+
while you are still writing it. The archive gate is where it stops: it blocks
|
|
140
|
+
with \`open-unresolved\` while any requirement in the merged registry still
|
|
141
|
+
carries one, so "done" is never diluted.
|
|
142
|
+
|
|
143
|
+
\`\`\`ts
|
|
144
|
+
'AUTH-7': {
|
|
145
|
+
statement: 'The system MUST require a TOTP code within {totpWindowSec} seconds.',
|
|
146
|
+
rationale: 'A password alone no longer meets the account-takeover risk we accept.',
|
|
147
|
+
params: { totpWindowSec: 30 },
|
|
148
|
+
open: ['is 30 seconds right, or does the authenticator app dictate it?'],
|
|
149
|
+
},
|
|
150
|
+
\`\`\`
|
|
151
|
+
|
|
152
|
+
This matters more for you than for a human author, and the reason is worth
|
|
153
|
+
stating plainly: the schema will not let you leave the field out, so an agent
|
|
154
|
+
that cannot determine a number picks one. A scenario is then written against
|
|
155
|
+
the invented value, and the result is a single source that is perfectly
|
|
156
|
+
consistent and describes a promise nobody made — the exact drift this workflow
|
|
157
|
+
exists to prevent, arriving through the mechanism meant to stop it. Writing
|
|
158
|
+
the question down costs nothing and blocks nothing until archive time.
|
|
135
159
|
- **A registry is a literal, and so is a delta.** Every value is written in the
|
|
136
160
|
file: no imported constant (\`params: { maxMb: MAX_MB }\`), no computed value,
|
|
137
161
|
no \`Date.now()\`. \`check\`, \`cover\` and \`render\` read \`*.reqs.ts\` with the
|
|
@@ -348,6 +372,7 @@ once. Branch on \`issues[].code\`, never on \`message\`:
|
|
|
348
372
|
| --- | --- |
|
|
349
373
|
| \`tests-red\` | a test is failing — the normal state until you are finished |
|
|
350
374
|
| \`never-red\` | an added requirement's scenario has no recorded failing run |
|
|
375
|
+
| \`open-unresolved\` | a requirement anywhere in the merged registry still declares an \`open\` question |
|
|
351
376
|
| \`uncovered-requirement\` | a requirement in the applied registry has no scenario |
|
|
352
377
|
| \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) — withdrawn when one of the two rows below already explains its file, so it never stands in for a load failure |
|
|
353
378
|
| \`spec-load-failed\` | a spec file could not be imported, so nothing in it ran. The run output carries the import error itself; this names which file it stopped |
|
package/dist/core/splice.d.ts
CHANGED
|
@@ -18,11 +18,11 @@ export declare class UnwritableValue extends Error {
|
|
|
18
18
|
* body needs one after, and putting that decision here would mean this function
|
|
19
19
|
* had to be told which case it was in anyway.
|
|
20
20
|
*
|
|
21
|
-
* `params` and `
|
|
22
|
-
* and `[]`. The schema defaults
|
|
23
|
-
* and the shorter one is what a person writing this entry by hand
|
|
24
|
-
* produced — which is the standard for a file `--apply` is merging
|
|
25
|
-
* than generating.
|
|
21
|
+
* `params`, `outOfScope` and `open` are omitted when empty rather than written
|
|
22
|
+
* as `{}` and `[]`. The schema defaults all three, so the two spellings mean the
|
|
23
|
+
* same thing, and the shorter one is what a person writing this entry by hand
|
|
24
|
+
* would have produced — which is the standard for a file `--apply` is merging
|
|
25
|
+
* into rather than generating.
|
|
26
26
|
*/
|
|
27
27
|
export declare function requirementSource(id: string, req: Requirement, indent: string): string;
|
|
28
28
|
/**
|
|
@@ -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;
|