@am_shork/attest 0.5.0 → 0.7.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 +1063 -143
- package/README.md +1 -1
- package/dist/cli/index.js +13 -11
- package/dist/cli/json.js +6 -1
- package/dist/cli/report.d.ts +18 -0
- package/dist/cli/report.js +41 -0
- package/dist/core/compiler.d.ts +40 -0
- package/dist/core/compiler.js +64 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +6 -0
- package/dist/core/gate.d.ts +48 -2
- package/dist/core/gate.js +73 -14
- package/dist/core/loader.js +13 -0
- package/dist/core/locate.d.ts +62 -0
- package/dist/core/locate.js +94 -11
- package/dist/core/merge.d.ts +54 -0
- package/dist/core/merge.js +257 -0
- package/dist/core/pipeline.d.ts +13 -0
- package/dist/core/pipeline.js +319 -40
- package/dist/core/render.js +129 -14
- package/dist/core/req-suite.d.ts +5 -0
- package/dist/core/req-suite.js +27 -0
- package/dist/core/runner.js +26 -8
- package/dist/core/skill.js +52 -12
- package/dist/core/splice.d.ts +52 -0
- package/dist/core/splice.js +189 -0
- package/dist/core/static-registry.d.ts +30 -0
- package/dist/core/static-registry.js +36 -0
- package/dist/core/status.js +21 -3
- package/dist/core/terminal.d.ts +23 -0
- package/dist/core/terminal.js +25 -9
- package/dist/core/validator.d.ts +5 -1
- package/dist/core/validator.js +23 -2
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +43 -15
- package/package.json +1 -1
package/dist/core/render.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// back, so the direction OpenSpec's pure-Markdown model went (Markdown as
|
|
7
7
|
// truth, and the free-form drift that comes with it) stays closed.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// Four properties this file must keep:
|
|
10
10
|
// - **Intent only.** The document says what the system promises, never what is
|
|
11
11
|
// proven or green: coverage and results are verdicts, and verdicts belong to
|
|
12
12
|
// `cover` / `verify`, which recompute them on demand. Putting them here also
|
|
@@ -20,7 +20,13 @@
|
|
|
20
20
|
// - **Params interpolated into the statement.** The source says
|
|
21
21
|
// "{idleTimeoutMin} minutes"; a human reader wants "30 minutes". This is the
|
|
22
22
|
// one thing the projection gives that reading the source does not.
|
|
23
|
+
// - **None of the registry's control characters survive into it.** The
|
|
24
|
+
// document is quoted prose from a repository that may not be the reader's,
|
|
25
|
+
// and it is a *file* — committed, served, and read again long after the run
|
|
26
|
+
// that wrote it. See `sanitised` for why the defence sits here rather than
|
|
27
|
+
// at the terminal write.
|
|
23
28
|
import { byCodeUnit } from './order.js';
|
|
29
|
+
import { control } from './terminal.js';
|
|
24
30
|
const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.reqs.ts` registry and regenerate. -->';
|
|
25
31
|
/**
|
|
26
32
|
* Render the registry as a standalone Markdown document.
|
|
@@ -31,18 +37,65 @@ const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.r
|
|
|
31
37
|
* worse than no gate.
|
|
32
38
|
*/
|
|
33
39
|
export function renderMarkdown(registry) {
|
|
34
|
-
const
|
|
40
|
+
const clean = sanitised(registry);
|
|
41
|
+
const ids = Object.keys(clean).sort(compareIds);
|
|
35
42
|
const out = [BANNER, '', '# Requirements', ''];
|
|
36
43
|
if (ids.length === 0) {
|
|
37
44
|
out.push('_No requirements are defined yet._', '');
|
|
38
45
|
return out.join('\n');
|
|
39
46
|
}
|
|
40
|
-
out.push(...overviewTable(
|
|
47
|
+
out.push(...overviewTable(clean, ids), '');
|
|
41
48
|
for (const id of ids) {
|
|
42
|
-
out.push(...section(id,
|
|
49
|
+
out.push(...section(id, clean[id]), '');
|
|
43
50
|
}
|
|
44
51
|
return out.join('\n');
|
|
45
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* The registry with every string its author controls stripped of control
|
|
55
|
+
* characters (ATX-58).
|
|
56
|
+
*
|
|
57
|
+
* **At the entry rather than at each emitter**, which is the whole of why this
|
|
58
|
+
* defect existed. ATX-37 put every byte the *CLI* prints through `control`, and
|
|
59
|
+
* this document is built by concatenation that never went past it — so a
|
|
60
|
+
* statement carrying `ESC [2K CR` erased the reviewer's line and repainted a
|
|
61
|
+
* verdict, from `attest render` with no flag at all. Sanitising here means a
|
|
62
|
+
* field added to `Requirement` later is covered by having been added, instead of
|
|
63
|
+
* by someone remembering; four call sites each doing it is the arrangement that
|
|
64
|
+
* produced the gap in the first place.
|
|
65
|
+
*
|
|
66
|
+
* **Over the document, not over stdout.** The obvious fix — sanitise the
|
|
67
|
+
* terminal write — is wrong, and the loop that found this said so: `--out`
|
|
68
|
+
* carried the payload into the file too. That file is committed, served, and
|
|
69
|
+
* read later by `cat`, by `less -R`, or by a site generator, so the artifact
|
|
70
|
+
* outlives the run and the run is the wrong place to defend. It also keeps
|
|
71
|
+
* `--check` honest, since both sides of the comparison are built from here.
|
|
72
|
+
*
|
|
73
|
+
* Ids are not sanitised and need not be: `RegistrySchema` holds every key to
|
|
74
|
+
* `^[A-Z]+-\d+$` on **both** reader paths — the static one by construction, the
|
|
75
|
+
* evaluating one since ATX-38 — so no id can carry a control character to begin
|
|
76
|
+
* with. The container is still built without a prototype, for the reason
|
|
77
|
+
* `red-record.ts` builds its own that way: that grammar is held somewhere else,
|
|
78
|
+
* and a defence that reads an inherited key when the other one lapses is not a
|
|
79
|
+
* defence. `Object.entries` and the sort below see own keys either way.
|
|
80
|
+
*/
|
|
81
|
+
function sanitised(registry) {
|
|
82
|
+
const out = Object.create(null);
|
|
83
|
+
for (const [id, req] of Object.entries(registry)) {
|
|
84
|
+
out[id] = {
|
|
85
|
+
statement: control(req.statement),
|
|
86
|
+
rationale: control(req.rationale),
|
|
87
|
+
params: Object.fromEntries(Object.entries(req.params).map(([k, v]) => [control(k), sanitisedValue(v)])),
|
|
88
|
+
outOfScope: req.outOfScope.map(control),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
/** A param value with its strings sanitised; numbers and booleans have none. */
|
|
94
|
+
function sanitisedValue(value) {
|
|
95
|
+
if (Array.isArray(value))
|
|
96
|
+
return value.map((v) => (typeof v === 'string' ? control(v) : v));
|
|
97
|
+
return typeof value === 'string' ? control(value) : value;
|
|
98
|
+
}
|
|
46
99
|
/**
|
|
47
100
|
* Line endings are a checkout artifact, not content.
|
|
48
101
|
*
|
|
@@ -85,23 +138,56 @@ export function staleIssue(target, current, fresh) {
|
|
|
85
138
|
message: `${why} Regenerate it with: ${target.command}`,
|
|
86
139
|
};
|
|
87
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* `AUTH-3` as its two ordering keys. An id this reader cannot take apart is its
|
|
143
|
+
* own prefix with no number, which is the reading `locate.ts`'s `idPrefix`
|
|
144
|
+
* gives — the two used to disagree, and one of them was wrong.
|
|
145
|
+
*
|
|
146
|
+
* `lastIndexOf('-')` with no guard was the old spelling, and on an id with no
|
|
147
|
+
* dash it silently dropped the last character (`AUTH` → prefix `AUT`) and
|
|
148
|
+
* produced `NaN` for the number. A comparator that answers `NaN` is not merely
|
|
149
|
+
* imprecise, it is **non-transitive**: measured on
|
|
150
|
+
* `['AUTH-3','AUTH-abc','AUTH-2']` the result was `AUTH-3, AUTH-abc, AUTH-2`,
|
|
151
|
+
* which is not a sorted permutation of anything. Both cases are handled here so
|
|
152
|
+
* the comparator below is total by construction rather than by the input
|
|
153
|
+
* happening to be well-formed.
|
|
154
|
+
*/
|
|
155
|
+
function orderingKey(id) {
|
|
156
|
+
const at = id.indexOf('-');
|
|
157
|
+
const tail = at === -1 ? '' : id.slice(at + 1);
|
|
158
|
+
if (at === -1 || !/^\d+$/.test(tail))
|
|
159
|
+
return [id, -1];
|
|
160
|
+
return [id.slice(0, at), Number(tail)];
|
|
161
|
+
}
|
|
88
162
|
/**
|
|
89
163
|
* Order ids the way a reader expects: prefix alphabetically, then the number
|
|
90
164
|
* numerically. A plain string sort puts ATX-10 between ATX-1 and ATX-2, which
|
|
91
165
|
* scrambles the document as soon as a registry reaches ten requirements.
|
|
166
|
+
*
|
|
167
|
+
* Total, and a function of the ids alone. `renderMarkdown` feeds `--check`, so
|
|
168
|
+
* an order that depends on anything else — the locale, the insertion order, the
|
|
169
|
+
* engine's tie-breaking — is a freshness gate that can disagree with the run
|
|
170
|
+
* that generated the file it is checking.
|
|
171
|
+
*
|
|
172
|
+
* A malformed id cannot reach here through any command: `RequirementIdSchema`
|
|
173
|
+
* rejects it and `render` returns early on a registry that failed to load. That
|
|
174
|
+
* is why this is robustness rather than a fix — the property being bought is
|
|
175
|
+
* that the function is correct on its own terms instead of correct because
|
|
176
|
+
* something upstream is.
|
|
92
177
|
*/
|
|
93
178
|
function compareIds(a, b) {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
return [id.slice(0, at), Number(id.slice(at + 1))];
|
|
97
|
-
};
|
|
98
|
-
const [prefixA, numA] = split(a);
|
|
99
|
-
const [prefixB, numB] = split(b);
|
|
179
|
+
const [prefixA, numA] = orderingKey(a);
|
|
180
|
+
const [prefixB, numB] = orderingKey(b);
|
|
100
181
|
// byCodeUnit, not localeCompare: the ordering has to be identical on every
|
|
101
182
|
// machine, or `--check` would flap with the runner's locale.
|
|
102
183
|
if (prefixA !== prefixB)
|
|
103
184
|
return byCodeUnit(prefixA, prefixB);
|
|
104
|
-
|
|
185
|
+
if (numA !== numB)
|
|
186
|
+
return numA - numB;
|
|
187
|
+
// Same prefix and same number is only reachable for ids this reader could not
|
|
188
|
+
// take apart. Falling back to the whole id keeps the order total rather than
|
|
189
|
+
// leaving it to the sort's stability, which is a property of the engine.
|
|
190
|
+
return byCodeUnit(a, b);
|
|
105
191
|
}
|
|
106
192
|
/**
|
|
107
193
|
* An index of the whole registry: id + the promise itself. Deliberately carries
|
|
@@ -166,14 +252,43 @@ function formatValue(value) {
|
|
|
166
252
|
* at either end needs padding spaces.
|
|
167
253
|
*/
|
|
168
254
|
function code(value) {
|
|
169
|
-
|
|
255
|
+
// Accumulated, never `Math.max(0, ...runs)` (ATX-59). The spread puts one
|
|
256
|
+
// argument on the stack per backtick run, so a param holding a few hundred
|
|
257
|
+
// thousand of them exhausted it — `RangeError` out of `attest render` under
|
|
258
|
+
// the static reader, with the stack naming this function. Not backtracking,
|
|
259
|
+
// and found only by sweeping for more of it: same reachable path, same
|
|
260
|
+
// registry-chooses-the-cost shape, different mechanism.
|
|
261
|
+
let longest = 0;
|
|
262
|
+
for (const run of value.matchAll(/`+/g))
|
|
263
|
+
longest = Math.max(longest, run[0].length);
|
|
170
264
|
const fence = '`'.repeat(longest + 1);
|
|
171
265
|
const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
|
|
172
266
|
return `${fence}${pad}${value}${pad}${fence}`;
|
|
173
267
|
}
|
|
174
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* Make prose safe inside a table cell: no row-breaking pipes, no newlines.
|
|
270
|
+
*
|
|
271
|
+
* Each maximal whitespace run is matched once and inspected, rather than
|
|
272
|
+
* matched by the old starred-`\s`, `\n`, starred-`\s` pattern (ATX-59). That
|
|
273
|
+
* spelling puts a required character after a leading quantifier, so a
|
|
274
|
+
* whitespace run with no newline in it was consumed, failed, and re-tried one
|
|
275
|
+
* character shorter from every position in the run — quadratic, and 8.6 seconds
|
|
276
|
+
* of CPU for 120,000 spaces in one statement, reachable through `render
|
|
277
|
+
* --check` in CI without executing a line of the project.
|
|
278
|
+
*
|
|
279
|
+
* The obvious repair does not work and was measured before this one was
|
|
280
|
+
* written: `[^\S\n]*\n[^\S\n]*`, which stops the class matching the newline,
|
|
281
|
+
* came out *slower*. The backtracking was never about which characters the
|
|
282
|
+
* class held — it was about the quantifier having something after it. `\s+`
|
|
283
|
+
* has nothing after it, so there is no failure to backtrack into, and the
|
|
284
|
+
* decision moves to the callback. Byte-identical to the old pattern: a
|
|
285
|
+
* whitespace run containing a newline collapses to one space, and a run
|
|
286
|
+
* without one is left exactly as it was.
|
|
287
|
+
*/
|
|
175
288
|
function cell(text) {
|
|
176
|
-
return text
|
|
289
|
+
return text
|
|
290
|
+
.replace(/\s+/g, (ws) => (ws.includes('\n') ? ' ' : ws))
|
|
291
|
+
.replace(/\|/g, '\\|');
|
|
177
292
|
}
|
|
178
293
|
/** GitHub/GitLab slug for a `## AUTH-3` heading. */
|
|
179
294
|
function anchor(id) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** How `requirement(id)` names its describe block. */
|
|
2
|
+
export declare function requirementSuiteName(id: string): string;
|
|
3
|
+
/** The requirement id a suite name carries, or `undefined` if it carries none. */
|
|
4
|
+
export declare function requirementIdOf(suiteName: string): string | undefined;
|
|
5
|
+
//# sourceMappingURL=req-suite.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The one spelling of the suite name that carries a requirement id.
|
|
2
|
+
//
|
|
3
|
+
// `requirement()` writes it, `runner.ts` reads it back out of the serialized
|
|
4
|
+
// task tree, and the runtime's own guard reads it to find the requirement that
|
|
5
|
+
// owns a scenario. That is three readers of one encoding, across a process
|
|
6
|
+
// boundary — the task tree is the only channel that crosses it (design §5.4) —
|
|
7
|
+
// and two spellings of one encoding are two encodings the moment either is
|
|
8
|
+
// edited.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately free of any `vitest` import, so both sides of the engine ↔
|
|
11
|
+
// runner boundary (`tests/import-boundary.spec.ts`) can depend on it.
|
|
12
|
+
/** How `requirement(id)` names its describe block. */
|
|
13
|
+
export function requirementSuiteName(id) {
|
|
14
|
+
return `[${id}]`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `.+` rather than something narrower: a delta may propose an id the registry's
|
|
18
|
+
* grammar would refuse, and this has to round-trip whatever `requirement()`
|
|
19
|
+
* wrote so the two readers agree about it (the call `readDeltaSource` makes for
|
|
20
|
+
* the same reason).
|
|
21
|
+
*/
|
|
22
|
+
const REQ_SUITE = /^\[(.+)\]$/;
|
|
23
|
+
/** The requirement id a suite name carries, or `undefined` if it carries none. */
|
|
24
|
+
export function requirementIdOf(suiteName) {
|
|
25
|
+
return REQ_SUITE.exec(suiteName)?.[1];
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=req-suite.js.map
|
package/dist/core/runner.js
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
import { startVitest } from 'vitest/node';
|
|
6
6
|
import { configDefaults } from 'vitest/config';
|
|
7
7
|
import { relativePath } from './paths.js';
|
|
8
|
+
import { requirementIdOf } from './req-suite.js';
|
|
8
9
|
import { byCodeUnit } from './order.js';
|
|
9
|
-
/** requirement() names each describe block `[reqId]`; recover the id from that. */
|
|
10
|
-
const REQ_SUITE = /^\[(.+)\]$/;
|
|
11
10
|
/**
|
|
12
11
|
* Vitest's own default exclusions, which every Attest run keeps on top of
|
|
13
12
|
* whatever else it excludes.
|
|
@@ -73,18 +72,15 @@ export async function runAndCollect(options = {}) {
|
|
|
73
72
|
// Reconstruct coverage from the task tree (not an in-process singleton).
|
|
74
73
|
const walk = (task) => {
|
|
75
74
|
if (task.type === 'suite') {
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
const id = m[1];
|
|
75
|
+
const id = requirementIdOf(task.name);
|
|
76
|
+
if (id !== undefined) {
|
|
79
77
|
const set = runtimeCoverage.get(id) ?? new Set();
|
|
80
78
|
const byName = outcomes.get(id) ?? new Map();
|
|
81
|
-
for (const c of task
|
|
79
|
+
for (const c of scenariosUnder(task)) {
|
|
82
80
|
// A scenario counts as covered only if it actually executed —
|
|
83
81
|
// skipped/todo scenarios have no run result (enables §8's
|
|
84
82
|
// declared-not-run check). The same test decides whether there is
|
|
85
83
|
// an outcome to record: a skip is not a red (design §6).
|
|
86
|
-
if (c.type !== 'test')
|
|
87
|
-
continue;
|
|
88
84
|
const outcome = executedOutcome(c);
|
|
89
85
|
if (!outcome)
|
|
90
86
|
continue;
|
|
@@ -126,6 +122,28 @@ export async function runAndCollect(options = {}) {
|
|
|
126
122
|
await vitest.close();
|
|
127
123
|
}
|
|
128
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Every scenario belonging to a requirement suite, at any depth beneath it.
|
|
127
|
+
*
|
|
128
|
+
* Direct children only was the other half of the nested-`describe` defect: a
|
|
129
|
+
* scenario grouped under one is a `test` inside a `suite` inside `[reqId]`, so
|
|
130
|
+
* even once the runtime stopped throwing it would have been invisible here —
|
|
131
|
+
* `declared-not-run`, for a scenario that ran and passed. The static parser has
|
|
132
|
+
* always recursed (`parser.ts` walks the whole subtree), and this is the seam
|
|
133
|
+
* where the two readers of one plan have to agree.
|
|
134
|
+
*
|
|
135
|
+
* Descent stops at a nested requirement suite, so a `requirement()` written
|
|
136
|
+
* inside another one keeps its own scenarios rather than donating them upward.
|
|
137
|
+
*/
|
|
138
|
+
function* scenariosUnder(suite) {
|
|
139
|
+
for (const child of suite.tasks ?? []) {
|
|
140
|
+
if (child.type === 'test')
|
|
141
|
+
yield child;
|
|
142
|
+
else if (child.type === 'suite' && requirementIdOf(child.name) === undefined) {
|
|
143
|
+
yield* scenariosUnder(child);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
129
147
|
/**
|
|
130
148
|
* Did this file fail before any of its tests could exist?
|
|
131
149
|
*
|
package/dist/core/skill.js
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
// workflow to an agent that trusts it. The backstop is that every mistake it
|
|
35
35
|
// could cause is a diagnostic with a fix hint — a registry written the old way
|
|
36
36
|
// is `registry-not-static`, and the agent corrects itself from the report.
|
|
37
|
+
// It has happened once, so: read this file when a release adds a diagnostic.
|
|
38
|
+
// Nothing gates that — `ATX-57` catches a code the engine cannot emit, never one
|
|
39
|
+
// it can.
|
|
37
40
|
/**
|
|
38
41
|
* The one sentence that decides whether the workflow is ever loaded.
|
|
39
42
|
*
|
|
@@ -163,6 +166,18 @@ Writing it at its merged location is what makes merging it a rename. Its relativ
|
|
|
163
166
|
imports resolve now exactly as they will afterwards, so \`../fog\` never becomes
|
|
164
167
|
\`../../../lib/game/fog\` and back again.
|
|
165
168
|
|
|
169
|
+
Two things to get right when you create the file:
|
|
170
|
+
|
|
171
|
+
- **Do not put it under \`changes/\`.** Nothing walks that directory, so a spec
|
|
172
|
+
left there executes in no suite and no gate — \`spec-in-change-dir\` from
|
|
173
|
+
\`attest check\`. The change folder holds \`proposal.md\`,
|
|
174
|
+
\`requirements.delta.ts\` and \`first-run.json\`, and nothing else.
|
|
175
|
+
- **Pick a name whose merged form is free.** Merging renames
|
|
176
|
+
\`fog.proposed.spec.ts\` to \`fog.spec.ts\`, so if that module already has a
|
|
177
|
+
\`fog.spec.ts\` the rename would overwrite it — \`proposed-spec-name-taken\`.
|
|
178
|
+
Name it \`fog.2fa.proposed.spec.ts\` instead. Several spec files may sit beside
|
|
179
|
+
one module; Attest attributes each by the ids it declares, not by its path.
|
|
180
|
+
|
|
166
181
|
\`\`\`ts
|
|
167
182
|
// changes/add-2fa/requirements.delta.ts
|
|
168
183
|
import { delta } from '@am_shork/attest/define';
|
|
@@ -271,13 +286,17 @@ once. Branch on \`issues[].code\`, never on \`message\`:
|
|
|
271
286
|
| \`tests-red\` | a test is failing — the normal state until you are finished |
|
|
272
287
|
| \`never-red\` | an added requirement's scenario has no recorded failing run |
|
|
273
288
|
| \`uncovered-requirement\` | a requirement in the applied registry has no scenario |
|
|
274
|
-
| \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) |
|
|
289
|
+
| \`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 |
|
|
290
|
+
| \`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 |
|
|
275
291
|
| \`proposed-spec-unclaimed\` | a \`*.proposed.spec.ts\` no change's delta claims |
|
|
276
|
-
| \`added-id-unmerged\` |
|
|
292
|
+
| \`added-id-unmerged\` | the same load failure, when this change also adds an id the registry on disk lacks. The specific case, and the only one reported for that file |
|
|
277
293
|
| \`unbound-param\` | a \`{placeholder}\` has no matching \`params\` key |
|
|
278
294
|
| \`registry-not-static\` | a registry file is not a literal the engine can read |
|
|
279
295
|
| \`add-conflict\` | the delta adds an id that already exists with different content |
|
|
280
296
|
| \`change-not-found\` | no \`requirements.delta.ts\` for that name |
|
|
297
|
+
| \`proposed-spec-name-taken\` | a proposed spec's merged name is already held by another spec |
|
|
298
|
+
| \`apply-unsupported-delta\` | \`--apply\` writes back ADDED only, and this delta carries more |
|
|
299
|
+
| \`apply-no-prefix-owner\` | no registry file owns the prefix of an id this change adds |
|
|
281
300
|
|
|
282
301
|
### Four things you must not do
|
|
283
302
|
|
|
@@ -309,15 +328,36 @@ failure this framework exists to make visible:
|
|
|
309
328
|
|
|
310
329
|
Then, and not before:
|
|
311
330
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
331
|
+
\`\`\`
|
|
332
|
+
attest archive <name> --apply
|
|
333
|
+
\`\`\`
|
|
334
|
+
|
|
335
|
+
That finishes the merge the verdict just approved: it splices the change's ADDED
|
|
336
|
+
requirements into the registry file owning their id prefix, repoints each proposed
|
|
337
|
+
spec's import of \`requirements.delta.ts\` at that registry, renames the specs in
|
|
338
|
+
place, and moves \`changes/<name>/\` to \`archive/<date>-<name>/\`. It re-runs the
|
|
339
|
+
gate first and writes nothing if that fails, and it prints every path it touched.
|
|
340
|
+
If it stops partway, run it again — each step is derived from the tree as it is,
|
|
341
|
+
so a second run finishes the job rather than repeating it.
|
|
342
|
+
|
|
343
|
+
Two things it does not do, and both are still yours:
|
|
344
|
+
|
|
345
|
+
1. **Regenerate a committed rendering.** Nothing records where yours lives, so run
|
|
346
|
+
\`attest render --out <file>\` afterwards. A document that no longer matches the
|
|
347
|
+
registry is a \`stale-spec-doc\` ERROR.
|
|
348
|
+
2. **Run the merged suite.** \`attest verify\`, on the result, reported.
|
|
349
|
+
|
|
350
|
+
**It writes back ADDED only.** A delta also carrying RENAMED, REMOVED or MODIFIED
|
|
351
|
+
is refused whole as \`apply-unsupported-delta\`, with nothing written — the gate
|
|
352
|
+
still checked all four, so nothing about the change is unverified, but the rest is
|
|
353
|
+
merged by hand.
|
|
354
|
+
|
|
355
|
+
Merging by hand, when it refuses: splice the delta's entries into the registry
|
|
356
|
+
file that owns their prefix; then for each \`*.proposed.spec.ts\`, **repoint its
|
|
357
|
+
import of \`requirements.delta.ts\` at that registry** and rename it to
|
|
358
|
+
\`*.spec.ts\` in place. That import is the one specifier a merge changes, and it
|
|
359
|
+
changes because the delta moves to \`archive/\` — the spec file itself does not
|
|
360
|
+
move, so nothing else about it does either. Then move the change folder.
|
|
321
361
|
|
|
322
362
|
## The commands
|
|
323
363
|
|
|
@@ -327,7 +367,7 @@ Then, and not before:
|
|
|
327
367
|
| \`attest verify\` | runs the suite, then reports coverage and drift. |
|
|
328
368
|
| \`attest cover\` | which requirements lack a scenario. |
|
|
329
369
|
| \`attest render\` | the registry as Markdown for human readers; \`--check\` gates a committed copy. |
|
|
330
|
-
| \`attest archive <change>\` | the completion gate for a proposed change. |
|
|
370
|
+
| \`attest archive <change>\` | the completion gate for a proposed change; \`--apply\` also performs the merge it approves. |
|
|
331
371
|
| \`attest status <change>\` | what that gate still wants, without running the suite. |
|
|
332
372
|
|
|
333
373
|
\`verify\` starts the child run **isolated** — it does not read \`vitest.config.ts\`,
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Registry, Requirement } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* One registry entry, at `indent`, with no trailing comma.
|
|
4
|
+
*
|
|
5
|
+
* The separator is the caller's because only the caller knows what it is
|
|
6
|
+
* inserting between: continuing a list needs a comma before, opening an empty
|
|
7
|
+
* body needs one after, and putting that decision here would mean this function
|
|
8
|
+
* had to be told which case it was in anyway.
|
|
9
|
+
*
|
|
10
|
+
* `params` and `outOfScope` are omitted when empty rather than written as `{}`
|
|
11
|
+
* and `[]`. The schema defaults both, so the two spellings mean the same thing,
|
|
12
|
+
* and the shorter one is what a person writing this entry by hand would have
|
|
13
|
+
* produced — which is the standard for a file `--apply` is merging into rather
|
|
14
|
+
* than generating.
|
|
15
|
+
*/
|
|
16
|
+
export declare function requirementSource(id: string, req: Requirement, indent: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* `source` with `additions` inserted into its registry literal.
|
|
19
|
+
*
|
|
20
|
+
* `undefined` when the file holds no literal this can be inserted into — the
|
|
21
|
+
* same condition `readRegistrySource` reports, and one the caller has already
|
|
22
|
+
* checked, so it is a refusal to guess rather than a diagnosis.
|
|
23
|
+
*
|
|
24
|
+
* Ids are inserted in code-unit order for the reason they are emitted in it:
|
|
25
|
+
* the same delta must produce the same file twice.
|
|
26
|
+
*/
|
|
27
|
+
export declare function spliceRequirements(file: string, source: string, additions: Registry): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* `source` with every import of `from` repointed at `to`.
|
|
30
|
+
*
|
|
31
|
+
* The second edit `--apply` makes to a file it did not write, and the one the
|
|
32
|
+
* design record missed. Merging a proposed spec was described as a rename in
|
|
33
|
+
* place, which is true of its *location*: the file already sits where it lands,
|
|
34
|
+
* so no relative specifier moves. But a stage-1 scenario reads its proposed
|
|
35
|
+
* params out of the change's delta (ATX-48, and the whole reason a delta reads
|
|
36
|
+
* as the registry it proposes), and the delta is what step 3 moves into
|
|
37
|
+
* `archive/`. Renaming without this leaves a merged spec importing a path that
|
|
38
|
+
* no longer exists — a suite that loads nothing, reported as `declared-not-run`
|
|
39
|
+
* against scenarios that are perfectly good.
|
|
40
|
+
*
|
|
41
|
+
* The expression around the import needs nothing done to it: `reqs['AUTH-7']
|
|
42
|
+
* .params.x` reads the same on both sides, which is exactly what ATX-48 bought.
|
|
43
|
+
* So this replaces one string literal and touches nothing else — the same
|
|
44
|
+
* discipline as the splice, for the same reason.
|
|
45
|
+
*
|
|
46
|
+
* The extension is taken from the specifier being replaced rather than chosen
|
|
47
|
+
* here. Whether a project writes `./x.reqs.js` or `./x.reqs.ts` is a property of
|
|
48
|
+
* its module resolution, uniform across the project, and already answered by the
|
|
49
|
+
* specifier sitting in front of us.
|
|
50
|
+
*/
|
|
51
|
+
export declare function repointImport(file: string, source: string, from: string, to: string): string;
|
|
52
|
+
//# sourceMappingURL=splice.d.ts.map
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Writing a requirement into a registry file as text (design §7).
|
|
2
|
+
//
|
|
3
|
+
// This is the half of `--apply` the `AGENTS.md` rejection was about. That
|
|
4
|
+
// proposal was refused because "each of its failure modes is destructive on a
|
|
5
|
+
// file the user cannot regenerate", and the argument for why merging a delta is
|
|
6
|
+
// different has one load-bearing clause: the result of a splice is checkable by
|
|
7
|
+
// re-reading it. That clause only holds if the splice is a **pure insertion**.
|
|
8
|
+
//
|
|
9
|
+
// So nothing here renders a registry. Rendering one out of a `Registry` object
|
|
10
|
+
// would be far easier and would silently drop every comment, every blank line
|
|
11
|
+
// and every layout choice in a hand-written file — destroying exactly what
|
|
12
|
+
// cannot be regenerated, while passing a re-read with flying colours because the
|
|
13
|
+
// *values* all survived. Instead `registryInsertionPoint` hands back an offset,
|
|
14
|
+
// and the only edit made to the file is text inserted at it. Every other byte is
|
|
15
|
+
// the byte that was already there, which is a property a test can state.
|
|
16
|
+
//
|
|
17
|
+
// The generated text is therefore the one thing here that has to be right on its
|
|
18
|
+
// own, and it is generated conservatively: strings are escaped rather than
|
|
19
|
+
// interpolated, params are emitted in code-unit key order so the same delta
|
|
20
|
+
// produces the same bytes twice, and anything the schema does not permit cannot
|
|
21
|
+
// reach this file because the gate validated the delta before `--apply` ran.
|
|
22
|
+
import ts from 'typescript';
|
|
23
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
24
|
+
import { registryInsertionPoint } from './static-registry.js';
|
|
25
|
+
import { toPosixPath } from './paths.js';
|
|
26
|
+
import { byCodeUnit } from './order.js';
|
|
27
|
+
/**
|
|
28
|
+
* A TypeScript single-quoted string literal holding exactly `value`.
|
|
29
|
+
*
|
|
30
|
+
* Hand-escaped rather than `JSON.stringify`, for one reason that is not style:
|
|
31
|
+
* the registries this writes into are single-quoted throughout, and a merged
|
|
32
|
+
* entry that arrives double-quoted is a diff hunk about quotation marks in the
|
|
33
|
+
* middle of a merge the reviewer is trying to read. Control characters go out as
|
|
34
|
+
* `\uXXXX` rather than raw, so a statement someone pasted a newline into cannot
|
|
35
|
+
* produce a file that no longer parses.
|
|
36
|
+
*/
|
|
37
|
+
function tsString(value) {
|
|
38
|
+
let out = "'";
|
|
39
|
+
for (const ch of value) {
|
|
40
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
41
|
+
if (ch === '\\')
|
|
42
|
+
out += '\\\\';
|
|
43
|
+
else if (ch === "'")
|
|
44
|
+
out += "\\'";
|
|
45
|
+
else if (ch === '\n')
|
|
46
|
+
out += '\\n';
|
|
47
|
+
else if (ch === '\r')
|
|
48
|
+
out += '\\r';
|
|
49
|
+
else if (ch === '\t')
|
|
50
|
+
out += '\\t';
|
|
51
|
+
else if (code < 0x20 || code === 0x7f)
|
|
52
|
+
out += `\\u${code.toString(16).padStart(4, '0')}`;
|
|
53
|
+
else
|
|
54
|
+
out += ch;
|
|
55
|
+
}
|
|
56
|
+
return `${out}'`;
|
|
57
|
+
}
|
|
58
|
+
/** A param key, bare when it is a plain identifier and quoted when it is not. */
|
|
59
|
+
function keySource(key) {
|
|
60
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : tsString(key);
|
|
61
|
+
}
|
|
62
|
+
function paramSource(value) {
|
|
63
|
+
if (Array.isArray(value))
|
|
64
|
+
return `[${value.map((v) => paramSource(v)).join(', ')}]`;
|
|
65
|
+
return typeof value === 'string' ? tsString(value) : String(value);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* One registry entry, at `indent`, with no trailing comma.
|
|
69
|
+
*
|
|
70
|
+
* The separator is the caller's because only the caller knows what it is
|
|
71
|
+
* inserting between: continuing a list needs a comma before, opening an empty
|
|
72
|
+
* body needs one after, and putting that decision here would mean this function
|
|
73
|
+
* had to be told which case it was in anyway.
|
|
74
|
+
*
|
|
75
|
+
* `params` and `outOfScope` are omitted when empty rather than written as `{}`
|
|
76
|
+
* and `[]`. The schema defaults both, so the two spellings mean the same thing,
|
|
77
|
+
* and the shorter one is what a person writing this entry by hand would have
|
|
78
|
+
* produced — which is the standard for a file `--apply` is merging into rather
|
|
79
|
+
* than generating.
|
|
80
|
+
*/
|
|
81
|
+
export function requirementSource(id, req, indent) {
|
|
82
|
+
const inner = `${indent} `;
|
|
83
|
+
const lines = [
|
|
84
|
+
`${indent}${tsString(id)}: {`,
|
|
85
|
+
`${inner}statement: ${tsString(req.statement)},`,
|
|
86
|
+
`${inner}rationale: ${tsString(req.rationale)},`,
|
|
87
|
+
];
|
|
88
|
+
// Code-unit key order, so one delta applied twice writes the same bytes — the
|
|
89
|
+
// property `--apply`'s re-runnability rests on, and the reason `apply.ts`
|
|
90
|
+
// sorts the same way when it canonicalises for `add-conflict`.
|
|
91
|
+
const params = Object.entries(req.params).sort(([a], [b]) => byCodeUnit(a, b));
|
|
92
|
+
if (params.length > 0) {
|
|
93
|
+
const body = params.map(([k, v]) => `${keySource(k)}: ${paramSource(v)}`).join(', ');
|
|
94
|
+
lines.push(`${inner}params: { ${body} },`);
|
|
95
|
+
}
|
|
96
|
+
if (req.outOfScope.length > 0) {
|
|
97
|
+
lines.push(`${inner}outOfScope: [${req.outOfScope.map((s) => tsString(s)).join(', ')}],`);
|
|
98
|
+
}
|
|
99
|
+
lines.push(`${indent}}`);
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* `source` with `additions` inserted into its registry literal.
|
|
104
|
+
*
|
|
105
|
+
* `undefined` when the file holds no literal this can be inserted into — the
|
|
106
|
+
* same condition `readRegistrySource` reports, and one the caller has already
|
|
107
|
+
* checked, so it is a refusal to guess rather than a diagnosis.
|
|
108
|
+
*
|
|
109
|
+
* Ids are inserted in code-unit order for the reason they are emitted in it:
|
|
110
|
+
* the same delta must produce the same file twice.
|
|
111
|
+
*/
|
|
112
|
+
export function spliceRequirements(file, source, additions) {
|
|
113
|
+
const ids = Object.keys(additions).sort(byCodeUnit);
|
|
114
|
+
if (ids.length === 0)
|
|
115
|
+
return source;
|
|
116
|
+
const point = registryInsertionPoint(file, source);
|
|
117
|
+
if (!point)
|
|
118
|
+
return undefined;
|
|
119
|
+
const entries = ids
|
|
120
|
+
.map((id) => requirementSource(id, additions[id], point.indent))
|
|
121
|
+
.join(',\n');
|
|
122
|
+
const text = point.leadingComma ? `,\n${entries}` : `\n${entries},\n`;
|
|
123
|
+
return source.slice(0, point.offset) + text + source.slice(point.offset);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `source` with every import of `from` repointed at `to`.
|
|
127
|
+
*
|
|
128
|
+
* The second edit `--apply` makes to a file it did not write, and the one the
|
|
129
|
+
* design record missed. Merging a proposed spec was described as a rename in
|
|
130
|
+
* place, which is true of its *location*: the file already sits where it lands,
|
|
131
|
+
* so no relative specifier moves. But a stage-1 scenario reads its proposed
|
|
132
|
+
* params out of the change's delta (ATX-48, and the whole reason a delta reads
|
|
133
|
+
* as the registry it proposes), and the delta is what step 3 moves into
|
|
134
|
+
* `archive/`. Renaming without this leaves a merged spec importing a path that
|
|
135
|
+
* no longer exists — a suite that loads nothing, reported as `declared-not-run`
|
|
136
|
+
* against scenarios that are perfectly good.
|
|
137
|
+
*
|
|
138
|
+
* The expression around the import needs nothing done to it: `reqs['AUTH-7']
|
|
139
|
+
* .params.x` reads the same on both sides, which is exactly what ATX-48 bought.
|
|
140
|
+
* So this replaces one string literal and touches nothing else — the same
|
|
141
|
+
* discipline as the splice, for the same reason.
|
|
142
|
+
*
|
|
143
|
+
* The extension is taken from the specifier being replaced rather than chosen
|
|
144
|
+
* here. Whether a project writes `./x.reqs.js` or `./x.reqs.ts` is a property of
|
|
145
|
+
* its module resolution, uniform across the project, and already answered by the
|
|
146
|
+
* specifier sitting in front of us.
|
|
147
|
+
*/
|
|
148
|
+
export function repointImport(file, source, from, to) {
|
|
149
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
|
|
150
|
+
const dir = dirname(file);
|
|
151
|
+
const edits = [];
|
|
152
|
+
for (const statement of sf.statements) {
|
|
153
|
+
if (!ts.isImportDeclaration(statement))
|
|
154
|
+
continue;
|
|
155
|
+
const spec = statement.moduleSpecifier;
|
|
156
|
+
if (!ts.isStringLiteral(spec))
|
|
157
|
+
continue;
|
|
158
|
+
if (!resolvesTo(dir, spec.text, from))
|
|
159
|
+
continue;
|
|
160
|
+
const ext = spec.text.endsWith('.js') ? '.js' : spec.text.endsWith('.ts') ? '.ts' : '';
|
|
161
|
+
let target = toPosixPath(relative(dir, to));
|
|
162
|
+
if (ext)
|
|
163
|
+
target = target.replace(/\.[^./]+$/, ext);
|
|
164
|
+
// A bare `x.reqs.js` is a package specifier, not a sibling file.
|
|
165
|
+
if (!target.startsWith('.'))
|
|
166
|
+
target = `./${target}`;
|
|
167
|
+
// Inside the quotes: the file's own quote style is left exactly as it was.
|
|
168
|
+
edits.push({ start: spec.getStart(sf) + 1, end: spec.getEnd() - 1, text: target });
|
|
169
|
+
}
|
|
170
|
+
let out = source;
|
|
171
|
+
for (const edit of edits.reverse()) {
|
|
172
|
+
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Whether `specifier`, written in a file under `dir`, names `target`.
|
|
178
|
+
*
|
|
179
|
+
* The `.js`-for-`.ts` spelling is accepted because NodeNext resolution requires
|
|
180
|
+
* it, and a project using it writes every specifier that way — including the one
|
|
181
|
+
* this is looking for.
|
|
182
|
+
*/
|
|
183
|
+
function resolvesTo(dir, specifier, target) {
|
|
184
|
+
if (!specifier.startsWith('.'))
|
|
185
|
+
return false;
|
|
186
|
+
const resolved = resolve(dir, specifier);
|
|
187
|
+
return resolved === target || resolved.replace(/\.js$/, '.ts') === target;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=splice.js.map
|