@am_shork/attest 0.6.0 → 0.7.1
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 +1084 -87
- package/README.md +4 -4
- package/dist/cli/index.js +2 -18
- 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/apply.js +7 -10
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +2 -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 +23 -0
- package/dist/core/locate.js +35 -6
- package/dist/core/merge.js +48 -10
- package/dist/core/order.d.ts +17 -0
- package/dist/core/order.js +25 -0
- package/dist/core/pipeline.js +144 -35
- package/dist/core/render.js +174 -19
- package/dist/core/req-suite.d.ts +5 -0
- package/dist/core/req-suite.js +27 -0
- package/dist/core/runner.js +24 -8
- package/dist/core/schema.d.ts +13 -6
- package/dist/core/schema.js +54 -18
- package/dist/core/skill.js +6 -2
- package/dist/core/splice.d.ts +13 -12
- package/dist/core/splice.js +59 -18
- package/dist/core/static-registry.js +6 -0
- package/dist/core/status.js +16 -3
- package/dist/core/terminal.d.ts +12 -3
- package/dist/core/terminal.js +14 -12
- package/dist/core/types.d.ts +1 -1
- package/dist/core/validator.d.ts +6 -1
- package/dist/core/validator.js +60 -2
- package/dist/runtime.d.ts +20 -0
- package/dist/runtime.js +43 -15
- package/package.json +1 -1
package/dist/core/order.d.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
1
|
/** Compare by UTF-16 code unit — the same order as a bare `Array#sort()`. */
|
|
2
2
|
export declare function byCodeUnit(a: string, b: string): number;
|
|
3
|
+
/**
|
|
4
|
+
* A JSON value with every object's keys in code-unit order, at every depth.
|
|
5
|
+
*
|
|
6
|
+
* `JSON.stringify` writes object keys in insertion order, which is *how the
|
|
7
|
+
* source happened to be written* — the thing this module exists to keep out of
|
|
8
|
+
* anything compared or committed. Two callers need the same guarantee for the
|
|
9
|
+
* same reason, one depth apart:
|
|
10
|
+
*
|
|
11
|
+
* - `apply.ts` canonicalises a requirement to decide `add-conflict`, so an
|
|
12
|
+
* identical copy written with its keys in another order must not read as a
|
|
13
|
+
* conflict with itself.
|
|
14
|
+
* - `render.ts` emits a structured param as JSON, and ATX-10 holds the same
|
|
15
|
+
* registry to the same bytes.
|
|
16
|
+
*
|
|
17
|
+
* Arrays keep their order: an array is data whose order is part of the value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sortDeep<T>(value: T): T;
|
|
3
20
|
//# sourceMappingURL=order.d.ts.map
|
package/dist/core/order.js
CHANGED
|
@@ -10,4 +10,29 @@
|
|
|
10
10
|
export function byCodeUnit(a, b) {
|
|
11
11
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* A JSON value with every object's keys in code-unit order, at every depth.
|
|
15
|
+
*
|
|
16
|
+
* `JSON.stringify` writes object keys in insertion order, which is *how the
|
|
17
|
+
* source happened to be written* — the thing this module exists to keep out of
|
|
18
|
+
* anything compared or committed. Two callers need the same guarantee for the
|
|
19
|
+
* same reason, one depth apart:
|
|
20
|
+
*
|
|
21
|
+
* - `apply.ts` canonicalises a requirement to decide `add-conflict`, so an
|
|
22
|
+
* identical copy written with its keys in another order must not read as a
|
|
23
|
+
* conflict with itself.
|
|
24
|
+
* - `render.ts` emits a structured param as JSON, and ATX-10 holds the same
|
|
25
|
+
* registry to the same bytes.
|
|
26
|
+
*
|
|
27
|
+
* Arrays keep their order: an array is data whose order is part of the value.
|
|
28
|
+
*/
|
|
29
|
+
export function sortDeep(value) {
|
|
30
|
+
if (Array.isArray(value))
|
|
31
|
+
return value.map(sortDeep);
|
|
32
|
+
// `typeof null === 'object'`, and a null param is a value like any other.
|
|
33
|
+
if (typeof value !== 'object' || value === null)
|
|
34
|
+
return value;
|
|
35
|
+
const entries = Object.entries(value).sort(([a], [b]) => byCodeUnit(a, b));
|
|
36
|
+
return Object.fromEntries(entries.map(([k, v]) => [k, sortDeep(v)]));
|
|
37
|
+
}
|
|
13
38
|
//# sourceMappingURL=order.js.map
|
package/dist/core/pipeline.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
// High-level operations composing the core layers (design §9). These are
|
|
2
2
|
// tool-agnostic; the CLI is a thin shell that calls them and renders the result.
|
|
3
|
-
import { createLoader } from './loader.js';
|
|
4
3
|
import { evalReader, findChangeDirSpecs, loadRegistry, listChangeNames, parseSpecs, scanProject, staticReader, } from './locate.js';
|
|
5
4
|
import { validateStructure, detectPotentialDrift, uncoveredIssues } from './validator.js';
|
|
6
5
|
import { byCodeUnit } from './order.js';
|
|
7
|
-
import { runAndCollect, BASE_EXCLUDE } from './runner.js';
|
|
8
6
|
import { applyDelta, addedIds, claimedIds } from './apply.js';
|
|
9
7
|
import { readDeltaSource } from './static-registry.js';
|
|
10
8
|
import { statusRows, statusCounts } from './status.js';
|
|
11
|
-
import { evaluateGate,
|
|
9
|
+
import { evaluateGate, notRunIssues } from './gate.js';
|
|
12
10
|
import { renderMarkdown, staleIssue } from './render.js';
|
|
13
11
|
import { mergeRedRecord, readRedRecord, redRecordPath, serialiseRedRecord, } from './red-record.js';
|
|
14
12
|
import { DEFAULT_TARGET, resolveTargets } from './targets.js';
|
|
@@ -19,13 +17,52 @@ import { mkdir, readFile } from 'node:fs/promises';
|
|
|
19
17
|
import { basename, dirname, join } from 'node:path';
|
|
20
18
|
import { relativePath } from './paths.js';
|
|
21
19
|
import { hasError } from './types.js';
|
|
20
|
+
// The runner half of the engine, reached only when a command actually needs it.
|
|
21
|
+
//
|
|
22
|
+
// These two are deliberately not top-level imports. `cli/index.ts` imports this
|
|
23
|
+
// module whole, so a static import here resolves and evaluates `vite`,
|
|
24
|
+
// `vitest/node` and `vitest/config` before `commander` has parsed an argument —
|
|
25
|
+
// measured at ~900 ms, paid by `check`, `cover`, `render`, `status` without
|
|
26
|
+
// `--eval` and `init`, none of which can start a run. §11's cost model is a
|
|
27
|
+
// short-lived process where cold start is the whole bill, so a fixed toll on
|
|
28
|
+
// every invocation outweighs anything scaling with requirement count.
|
|
29
|
+
//
|
|
30
|
+
// This is *not* the core/adapter package split §11 defers, and does not
|
|
31
|
+
// substitute for it: `pipeline.ts` still holds both halves, so the reachability
|
|
32
|
+
// `tests/import-boundary.spec.ts` cannot assert is still unassertable. What
|
|
33
|
+
// moves is when the cost is paid. The specifiers stay relative and the gate
|
|
34
|
+
// reads `import(...)` from the AST alongside static imports, so the boundary is
|
|
35
|
+
// checked exactly as before — that is the one property this change could have
|
|
36
|
+
// silently dropped, and the reason that test looks for dynamic imports at all.
|
|
37
|
+
/** {@link import('./loader.js').createLoader}, resolved on first use. */
|
|
38
|
+
async function createLoader() {
|
|
39
|
+
const { createLoader: create } = await import('./loader.js');
|
|
40
|
+
return create();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* {@link import('./runner.js').runAndCollect}, resolved on first use.
|
|
44
|
+
*
|
|
45
|
+
* `BASE_EXCLUDE` gets no such wrapper: it has one caller, which is inside the
|
|
46
|
+
* function that starts the run it belongs to, so it is destructured there
|
|
47
|
+
* alongside nothing.
|
|
48
|
+
*/
|
|
49
|
+
async function runAndCollect(options = {}) {
|
|
50
|
+
const { runAndCollect: run } = await import('./runner.js');
|
|
51
|
+
return run(options);
|
|
52
|
+
}
|
|
22
53
|
/**
|
|
23
54
|
* Read every registry under root with the reader `options` asks for, and close
|
|
24
55
|
* whatever that reader needed. Static reading starts no Vite server at all.
|
|
56
|
+
*
|
|
57
|
+
* `borrowed` is a loader the caller is already holding open for this command.
|
|
58
|
+
* See `withLoader` for why a command with more than one thing to evaluate owns
|
|
59
|
+
* one rather than letting each read start its own.
|
|
25
60
|
*/
|
|
26
|
-
async function readRegistry(root, options, files) {
|
|
61
|
+
async function readRegistry(root, options, files, borrowed) {
|
|
27
62
|
if (!options.evaluate)
|
|
28
63
|
return loadRegistry(root, staticReader(), files);
|
|
64
|
+
if (borrowed)
|
|
65
|
+
return loadRegistry(root, evalReader(borrowed), files);
|
|
29
66
|
const loader = await createLoader();
|
|
30
67
|
try {
|
|
31
68
|
return await loadRegistry(root, evalReader(loader), files);
|
|
@@ -34,22 +71,59 @@ async function readRegistry(root, options, files) {
|
|
|
34
71
|
await loader.close();
|
|
35
72
|
}
|
|
36
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Run `fn` with one loader for the whole command, or with none when nothing is
|
|
76
|
+
* to be evaluated.
|
|
77
|
+
*
|
|
78
|
+
* A loader is a Vite dev server: starting one is the expensive thing on this
|
|
79
|
+
* path, and `check --eval` used to start **one per change** — `readDelta`
|
|
80
|
+
* created and closed its own on every iteration of the loop over
|
|
81
|
+
* `listChangeNames`, on top of the one `readRegistry` had already opened and
|
|
82
|
+
* closed for the same command. So the cost was 1 + N servers for a command that
|
|
83
|
+
* needs one. Ownership sits here rather than in either reader because neither
|
|
84
|
+
* of them knows how many times it is about to be called.
|
|
85
|
+
*
|
|
86
|
+
* Static commands pass `undefined` all the way down and start nothing at all,
|
|
87
|
+
* which is the property `check` without `--eval` is documented on (design §5.1)
|
|
88
|
+
* and the reason this returns an optional rather than always creating one.
|
|
89
|
+
*/
|
|
90
|
+
async function withLoader(options, fn) {
|
|
91
|
+
if (!options.evaluate)
|
|
92
|
+
return fn(undefined);
|
|
93
|
+
const loader = await createLoader();
|
|
94
|
+
try {
|
|
95
|
+
return await fn(loader);
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
await loader.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
37
101
|
/** Static structural check (design §9: `attest check`). */
|
|
38
102
|
export async function runCheck(root, options = {}) {
|
|
39
103
|
const unusable = compilerIssue();
|
|
40
104
|
if (unusable)
|
|
41
105
|
return [unusable];
|
|
42
106
|
const scan = await scanProject(root);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
107
|
+
// One loader for the command, not one per thing evaluated: this is the only
|
|
108
|
+
// command that reads both the registry and every change's delta, so it is the
|
|
109
|
+
// only one where that distinction is worth anything.
|
|
110
|
+
return withLoader(options, async (loader) => {
|
|
111
|
+
const { registry, issues, unreadableFiles } = await readRegistry(root, options, scan.reqsFiles, loader);
|
|
112
|
+
const plan = await parseSpecs(scan.specFiles, root);
|
|
113
|
+
return [
|
|
114
|
+
...issues,
|
|
115
|
+
// `check` keeps reporting on a registry that only half-loaded,
|
|
116
|
+
// deliberately: its contract is breadth, and the findings from the files
|
|
117
|
+
// that *did* load are all still true. What it must not do is advise work
|
|
118
|
+
// that the load failure makes wrong — see `orphan-test` in
|
|
119
|
+
// `validateStructure`.
|
|
120
|
+
...validateStructure(registry, plan, unreadableFiles.length > 0),
|
|
121
|
+
...detectPotentialDrift(registry, plan, plan.paramRefs),
|
|
122
|
+
...(await unclaimedProposedSpecIssues(root, scan, options, loader)),
|
|
123
|
+
...(await changeDirSpecIssues(root)),
|
|
124
|
+
...proposedNameTakenIssues(root, scan),
|
|
125
|
+
];
|
|
126
|
+
});
|
|
53
127
|
}
|
|
54
128
|
/**
|
|
55
129
|
* A proposed spec whose merged name is already taken (design §7).
|
|
@@ -131,13 +205,15 @@ async function changeDirSpecIssues(root) {
|
|
|
131
205
|
* — a change delta is intent, and the commands that only report read it from
|
|
132
206
|
* source (design §5.1).
|
|
133
207
|
*/
|
|
134
|
-
async function unclaimedProposedSpecIssues(root, scan, options) {
|
|
208
|
+
async function unclaimedProposedSpecIssues(root, scan, options, loader) {
|
|
135
209
|
if (scan.proposedSpecFiles.length === 0)
|
|
136
210
|
return [];
|
|
137
211
|
const claimed = new Set();
|
|
138
212
|
const issues = [];
|
|
213
|
+
// The loop `loader` exists for: under `--eval` this used to start and stop a
|
|
214
|
+
// Vite dev server per change.
|
|
139
215
|
for (const name of await listChangeNames(root)) {
|
|
140
|
-
const read = await readDelta(root, name, options);
|
|
216
|
+
const read = await readDelta(root, name, options, loader);
|
|
141
217
|
if ('issue' in read)
|
|
142
218
|
issues.push(read.issue);
|
|
143
219
|
else
|
|
@@ -204,17 +280,21 @@ export async function runVerify(root, options = {}) {
|
|
|
204
280
|
const loader = await createLoader();
|
|
205
281
|
let registry;
|
|
206
282
|
let plan;
|
|
283
|
+
let unreadableFiles;
|
|
207
284
|
const issues = [];
|
|
208
285
|
try {
|
|
209
286
|
const loaded = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
210
287
|
registry = loaded.registry;
|
|
211
288
|
issues.push(...loaded.issues);
|
|
289
|
+
unreadableFiles = loaded.unreadableFiles;
|
|
212
290
|
plan = await parseSpecs(scan.specFiles, root);
|
|
213
291
|
}
|
|
214
292
|
finally {
|
|
215
293
|
await loader.close();
|
|
216
294
|
}
|
|
217
|
-
|
|
295
|
+
// Same as `check`: everything the loaded half supports is still reported, and
|
|
296
|
+
// only the advice that a load failure would make wrong is withdrawn.
|
|
297
|
+
issues.push(...validateStructure(registry, plan, unreadableFiles.length > 0));
|
|
218
298
|
issues.push(...detectPotentialDrift(registry, plan, plan.paramRefs));
|
|
219
299
|
const attesting = attestingFiles(plan);
|
|
220
300
|
const counts = {
|
|
@@ -257,7 +337,13 @@ export async function runVerify(root, options = {}) {
|
|
|
257
337
|
if (!run.passed) {
|
|
258
338
|
issues.push({ level: 'ERROR', code: 'tests-red', message: 'Some tests are failing.' });
|
|
259
339
|
}
|
|
260
|
-
|
|
340
|
+
// The load failures first, then the absences they caused — by the same
|
|
341
|
+
// function the gate calls, which is the point. `verify` used to ask only for
|
|
342
|
+
// the absences, so an ordinary broken import surfaced as a `declared-not-run`
|
|
343
|
+
// per scenario blaming a skip, with nothing anywhere naming the file that did
|
|
344
|
+
// not load. No more specific diagnosis to pass: `added-id-unmerged` is a
|
|
345
|
+
// statement about a change's delta, and `verify` has no change.
|
|
346
|
+
issues.push(...notRunIssues(plan, run));
|
|
261
347
|
return { issues, passed: run.passed, ok: run.passed && !hasError(issues), counts };
|
|
262
348
|
}
|
|
263
349
|
/**
|
|
@@ -284,8 +370,12 @@ export async function runCover(root, options = {}) {
|
|
|
284
370
|
for (const s of plan.scenarios) {
|
|
285
371
|
counts.set(s.reqId, (counts.get(s.reqId) ?? 0) + 1);
|
|
286
372
|
}
|
|
373
|
+
// `byCodeUnit`, not a bare `.sort()`. The two give an identical result on ids
|
|
374
|
+
// that match the grammar, and that is exactly why this is worth spelling:
|
|
375
|
+
// `order.ts` exists so ordering has one spelling across the engine, which is
|
|
376
|
+
// a property that survives an edit — an identical result is not.
|
|
287
377
|
const rows = Object.keys(registry)
|
|
288
|
-
.sort()
|
|
378
|
+
.sort(byCodeUnit)
|
|
289
379
|
.map((reqId) => ({
|
|
290
380
|
reqId,
|
|
291
381
|
covered: (counts.get(reqId) ?? 0) > 0,
|
|
@@ -426,10 +516,14 @@ function changeNotFoundIssue(root, deltaPath, changeName, err) {
|
|
|
426
516
|
* under review. `archive` evaluates unconditionally because it runs the suite
|
|
427
517
|
* anyway, so declining to evaluate one more module would buy it nothing.
|
|
428
518
|
*/
|
|
429
|
-
async function readDelta(root, changeName, options) {
|
|
519
|
+
async function readDelta(root, changeName, options, borrowed) {
|
|
430
520
|
const deltaPath = changeDeltaPath(root, changeName);
|
|
431
521
|
if (options.evaluate) {
|
|
432
|
-
|
|
522
|
+
// Borrow the caller's loader when it has one, own one otherwise. The
|
|
523
|
+
// difference matters only to the caller that reads several deltas — see
|
|
524
|
+
// `withLoader` — and a lone reader should not have to build scaffolding to
|
|
525
|
+
// ask for one delta.
|
|
526
|
+
const loader = borrowed ?? (await createLoader());
|
|
433
527
|
try {
|
|
434
528
|
const mod = await loader.load(deltaPath);
|
|
435
529
|
if (!mod.default || typeof mod.default !== 'object') {
|
|
@@ -443,7 +537,10 @@ async function readDelta(root, changeName, options) {
|
|
|
443
537
|
return { issue: changeNotFoundIssue(root, deltaPath, changeName, err) };
|
|
444
538
|
}
|
|
445
539
|
finally {
|
|
446
|
-
|
|
540
|
+
// Only what this call created. Closing a borrowed loader would take it
|
|
541
|
+
// out from under the caller's next iteration.
|
|
542
|
+
if (!borrowed)
|
|
543
|
+
await loader.close();
|
|
447
544
|
}
|
|
448
545
|
}
|
|
449
546
|
let source;
|
|
@@ -512,10 +609,20 @@ function claimedByDelta(proposed, delta) {
|
|
|
512
609
|
async function changeMergedPlan(root, delta, scan) {
|
|
513
610
|
const basePlan = await parseSpecs(scan.specFiles, root);
|
|
514
611
|
const proposed = await parseSpecs(scan.proposedSpecFiles, root);
|
|
515
|
-
const
|
|
612
|
+
const claimed = claimedByDelta(proposed, delta);
|
|
516
613
|
return {
|
|
517
|
-
|
|
518
|
-
|
|
614
|
+
merged: {
|
|
615
|
+
scenarios: [...basePlan.scenarios, ...claimed.scenarios],
|
|
616
|
+
paramRefs: [...basePlan.paramRefs, ...claimed.paramRefs],
|
|
617
|
+
},
|
|
618
|
+
// Returned rather than recomputed by the caller that needs it. `--apply`
|
|
619
|
+
// renames exactly the proposed specs this change claims, and deriving that
|
|
620
|
+
// set a second time — a second `parseSpecs` and a second `claimedByDelta`
|
|
621
|
+
// over the same files — is agreement by transcription: the two answers
|
|
622
|
+
// match because the same two functions ran twice, which is the thing this
|
|
623
|
+
// codebase extracts `declaredNotRunIssues` and `uncoveredIssues` to stop
|
|
624
|
+
// relying on. One parse, one split, one answer.
|
|
625
|
+
claimed,
|
|
519
626
|
};
|
|
520
627
|
}
|
|
521
628
|
/** Per-change progress (design §9: `attest status <change>`). */
|
|
@@ -537,7 +644,7 @@ export async function runStatus(root, changeName, options = {}) {
|
|
|
537
644
|
if ('issue' in read)
|
|
538
645
|
return nothing([read.issue]);
|
|
539
646
|
const scan = await scanProject(root);
|
|
540
|
-
const plan = await changeMergedPlan(root, read.delta, scan);
|
|
647
|
+
const { merged: plan } = await changeMergedPlan(root, read.delta, scan);
|
|
541
648
|
const firstRun = await readRedRecord(root, changeName);
|
|
542
649
|
const rows = statusRows(addedIds(read.delta), plan, firstRun);
|
|
543
650
|
return { change: changeName, rows, counts: statusCounts(rows), issues: [] };
|
|
@@ -557,7 +664,10 @@ export async function runArchive(root, changeName, options = {}) {
|
|
|
557
664
|
*/
|
|
558
665
|
export async function runArchiveApply(root, changeName, options = {}) {
|
|
559
666
|
const { issues, merge } = await archiveRun(root, changeName, options);
|
|
560
|
-
|
|
667
|
+
// One predicate for the verdict, everywhere (ATX-60): the gate's WARNINGs
|
|
668
|
+
// survive `--apply` by design, so `some(level === 'ERROR')` spelled here by
|
|
669
|
+
// hand was a second definition of failure sitting next to the one that counts.
|
|
670
|
+
if (hasError(issues) || !merge)
|
|
561
671
|
return { issues, written: [] };
|
|
562
672
|
const result = await applyMerge({ root, changeName, ...merge });
|
|
563
673
|
// The gate's non-blocking output is kept: a WARNING the gate raised is still
|
|
@@ -569,10 +679,7 @@ export async function runArchiveApply(root, changeName, options = {}) {
|
|
|
569
679
|
* The gate, plus what finishing the merge would need.
|
|
570
680
|
*
|
|
571
681
|
* One function rather than a gate and a separate `--apply` path, because the
|
|
572
|
-
* merge must act on **this** run's verdict
|
|
573
|
-
* different answer than the one just printed — and a command able to file a
|
|
574
|
-
* change as done against a stale verdict removes the hard definition of "done"
|
|
575
|
-
* that is this tool's whole claim.
|
|
682
|
+
* merge must act on **this** run's verdict (design §8).
|
|
576
683
|
*
|
|
577
684
|
* `merge` is absent exactly when there is nothing to act on: a rejected name, an
|
|
578
685
|
* unreadable delta, or a registry that would not load.
|
|
@@ -589,7 +696,7 @@ async function archiveRun(root, changeName, options = {}) {
|
|
|
589
696
|
const loader = await createLoader();
|
|
590
697
|
try {
|
|
591
698
|
const { registry: base, issues, prefixOwners } = await loadRegistry(root, evalReader(loader), scan.reqsFiles);
|
|
592
|
-
if (issues
|
|
699
|
+
if (hasError(issues))
|
|
593
700
|
return { issues };
|
|
594
701
|
const deltaPath = changeDeltaPath(root, changeName);
|
|
595
702
|
let delta;
|
|
@@ -615,13 +722,14 @@ async function archiveRun(root, changeName, options = {}) {
|
|
|
615
722
|
// Static plan = merged base suite + this change's specs (design §8), by the
|
|
616
723
|
// same function `status` reports against — a progress report computed over a
|
|
617
724
|
// different spec set than the gate uses would be a report about nothing.
|
|
618
|
-
const plan = await changeMergedPlan(root, delta, scan);
|
|
725
|
+
const { merged: plan, claimed } = await changeMergedPlan(root, delta, scan);
|
|
619
726
|
// Other proposals need no exclude glob of their own: the include list below
|
|
620
727
|
// is the plan's own files, and the plan holds only the proposed specs this
|
|
621
728
|
// delta claims. That is what replaced `**/changes/<sibling>/**` — with the
|
|
622
729
|
// specs no longer living under `changes/`, a directory glob could not have
|
|
623
730
|
// told two proposals apart, and one kept alongside the claim check would be
|
|
624
731
|
// a second scoping rule able to disagree with it.
|
|
732
|
+
const { BASE_EXCLUDE } = await import('./runner.js');
|
|
625
733
|
const exclude = [...BASE_EXCLUDE, '**/archive/**'];
|
|
626
734
|
// Same run scope as `verify`: only the files that declare a requirement().
|
|
627
735
|
// The gate must not go red because a repo's incumbent suite happens to sit
|
|
@@ -668,8 +776,9 @@ async function archiveRun(root, changeName, options = {}) {
|
|
|
668
776
|
prefixOwners,
|
|
669
777
|
// The plan's proposed files, not every proposed file on disk: `--apply`
|
|
670
778
|
// renames what *this* change claims, and a sibling proposal's spec is
|
|
671
|
-
// not this change's to move.
|
|
672
|
-
|
|
779
|
+
// not this change's to move. `claimed` is the half of the plan the gate
|
|
780
|
+
// above ran, handed back by the function that built it.
|
|
781
|
+
claimedSpecs: claimedSpecsOf(root, claimed),
|
|
673
782
|
mergedSpecs: scan.specFiles,
|
|
674
783
|
},
|
|
675
784
|
};
|
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,18 @@
|
|
|
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
|
-
|
|
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.
|
|
28
|
+
import { byCodeUnit, sortDeep } from './order.js';
|
|
29
|
+
import { control } from './terminal.js';
|
|
30
|
+
/** Whether a value reads as words in a sentence — a scalar, or a list of them. */
|
|
31
|
+
function isFlat(value) {
|
|
32
|
+
const scalar = (v) => v === null || typeof v !== 'object';
|
|
33
|
+
return Array.isArray(value) ? value.every(scalar) : scalar(value);
|
|
34
|
+
}
|
|
24
35
|
const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.reqs.ts` registry and regenerate. -->';
|
|
25
36
|
/**
|
|
26
37
|
* Render the registry as a standalone Markdown document.
|
|
@@ -31,18 +42,67 @@ const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.r
|
|
|
31
42
|
* worse than no gate.
|
|
32
43
|
*/
|
|
33
44
|
export function renderMarkdown(registry) {
|
|
34
|
-
const
|
|
45
|
+
const clean = sanitised(registry);
|
|
46
|
+
const ids = Object.keys(clean).sort(compareIds);
|
|
35
47
|
const out = [BANNER, '', '# Requirements', ''];
|
|
36
48
|
if (ids.length === 0) {
|
|
37
49
|
out.push('_No requirements are defined yet._', '');
|
|
38
50
|
return out.join('\n');
|
|
39
51
|
}
|
|
40
|
-
out.push(...overviewTable(
|
|
52
|
+
out.push(...overviewTable(clean, ids), '');
|
|
41
53
|
for (const id of ids) {
|
|
42
|
-
out.push(...section(id,
|
|
54
|
+
out.push(...section(id, clean[id]), '');
|
|
43
55
|
}
|
|
44
56
|
return out.join('\n');
|
|
45
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* The registry with every string its author controls stripped of control
|
|
60
|
+
* characters (ATX-58, design §9.1).
|
|
61
|
+
*
|
|
62
|
+
* This is the entry §9.1 names: sanitising here rather than at each emitter is
|
|
63
|
+
* what makes a field added to `Requirement` later covered by having been added,
|
|
64
|
+
* and what keeps the obligation over the document rather than over stdout.
|
|
65
|
+
*
|
|
66
|
+
* Ids are not sanitised and need not be: `RegistrySchema` holds every key to
|
|
67
|
+
* `^[A-Z]+-\d+$` on **both** reader paths — the static one by construction, the
|
|
68
|
+
* evaluating one since ATX-38 — so no id can carry a control character to begin
|
|
69
|
+
* with. The container is still built without a prototype, for the reason
|
|
70
|
+
* `red-record.ts` builds its own that way: that grammar is held somewhere else,
|
|
71
|
+
* and a defence that reads an inherited key when the other one lapses is not a
|
|
72
|
+
* defence. `Object.entries` and the sort below see own keys either way.
|
|
73
|
+
*/
|
|
74
|
+
function sanitised(registry) {
|
|
75
|
+
const out = Object.create(null);
|
|
76
|
+
for (const [id, req] of Object.entries(registry)) {
|
|
77
|
+
out[id] = {
|
|
78
|
+
statement: control(req.statement),
|
|
79
|
+
rationale: control(req.rationale),
|
|
80
|
+
params: Object.fromEntries(Object.entries(req.params).map(([k, v]) => [control(k), sanitisedValue(v)])),
|
|
81
|
+
outOfScope: req.outOfScope.map(control),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A param value with every string in it sanitised; numbers, booleans and `null`
|
|
88
|
+
* have none.
|
|
89
|
+
*
|
|
90
|
+
* Recursive, over keys as well as values. A param is a JSON value, so the
|
|
91
|
+
* author-controlled strings inside one are at arbitrary depth — and a walk that
|
|
92
|
+
* stops at the first level would leave exactly the nested ones unsanitised,
|
|
93
|
+
* which is the shape the params of a `kind -> payload` table have. §9.1 puts the
|
|
94
|
+
* defence over the whole document; a depth limit is a hole in it.
|
|
95
|
+
*/
|
|
96
|
+
function sanitisedValue(value) {
|
|
97
|
+
if (typeof value === 'string')
|
|
98
|
+
return control(value);
|
|
99
|
+
if (Array.isArray(value))
|
|
100
|
+
return value.map(sanitisedValue);
|
|
101
|
+
if (value !== null && typeof value === 'object') {
|
|
102
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [control(k), sanitisedValue(v)]));
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
46
106
|
/**
|
|
47
107
|
* Line endings are a checkout artifact, not content.
|
|
48
108
|
*
|
|
@@ -85,23 +145,51 @@ export function staleIssue(target, current, fresh) {
|
|
|
85
145
|
message: `${why} Regenerate it with: ${target.command}`,
|
|
86
146
|
};
|
|
87
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* `AUTH-3` as its two ordering keys. An id this reader cannot take apart is its
|
|
150
|
+
* own prefix with no number, which is the reading `locate.ts`'s `idPrefix`
|
|
151
|
+
* gives — the two used to disagree, and one of them was wrong.
|
|
152
|
+
*
|
|
153
|
+
* `lastIndexOf('-')` with no guard was the old spelling, and on an id with no
|
|
154
|
+
* dash it silently dropped the last character (`AUTH` → prefix `AUT`) and
|
|
155
|
+
* produced `NaN` for the number. A comparator that answers `NaN` is not merely
|
|
156
|
+
* imprecise, it is **non-transitive**: measured on
|
|
157
|
+
* `['AUTH-3','AUTH-abc','AUTH-2']` the result was `AUTH-3, AUTH-abc, AUTH-2`,
|
|
158
|
+
* which is not a sorted permutation of anything. Both cases are handled here so
|
|
159
|
+
* the comparator below is total by construction rather than by the input
|
|
160
|
+
* happening to be well-formed.
|
|
161
|
+
*/
|
|
162
|
+
function orderingKey(id) {
|
|
163
|
+
const at = id.indexOf('-');
|
|
164
|
+
const tail = at === -1 ? '' : id.slice(at + 1);
|
|
165
|
+
if (at === -1 || !/^\d+$/.test(tail))
|
|
166
|
+
return [id, -1];
|
|
167
|
+
return [id.slice(0, at), Number(tail)];
|
|
168
|
+
}
|
|
88
169
|
/**
|
|
89
170
|
* Order ids the way a reader expects: prefix alphabetically, then the number
|
|
90
171
|
* numerically. A plain string sort puts ATX-10 between ATX-1 and ATX-2, which
|
|
91
172
|
* scrambles the document as soon as a registry reaches ten requirements.
|
|
173
|
+
*
|
|
174
|
+
* Total, and a function of the ids alone (design §9.1). A malformed id cannot
|
|
175
|
+
* reach here through any command — `RequirementIdSchema` rejects it and `render`
|
|
176
|
+
* returns early on a registry that failed to load — so this is the robustness
|
|
177
|
+
* §9.1 asks for rather than a fix: the function is correct on its own terms
|
|
178
|
+
* instead of correct because something upstream is.
|
|
92
179
|
*/
|
|
93
180
|
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);
|
|
181
|
+
const [prefixA, numA] = orderingKey(a);
|
|
182
|
+
const [prefixB, numB] = orderingKey(b);
|
|
100
183
|
// byCodeUnit, not localeCompare: the ordering has to be identical on every
|
|
101
184
|
// machine, or `--check` would flap with the runner's locale.
|
|
102
185
|
if (prefixA !== prefixB)
|
|
103
186
|
return byCodeUnit(prefixA, prefixB);
|
|
104
|
-
|
|
187
|
+
if (numA !== numB)
|
|
188
|
+
return numA - numB;
|
|
189
|
+
// Same prefix and same number is only reachable for ids this reader could not
|
|
190
|
+
// take apart. Falling back to the whole id keeps the order total rather than
|
|
191
|
+
// leaving it to the sort's stability, which is a property of the engine.
|
|
192
|
+
return byCodeUnit(a, b);
|
|
105
193
|
}
|
|
106
194
|
/**
|
|
107
195
|
* An index of the whole registry: id + the promise itself. Deliberately carries
|
|
@@ -126,6 +214,15 @@ function section(id, req) {
|
|
|
126
214
|
const params = Object.entries(req.params);
|
|
127
215
|
if (params.length > 0) {
|
|
128
216
|
out.push('', '| Param | Value |', '| --- | --- |', ...params.map(([name, value]) => `| ${code(name)} | ${cell(formatValue(value))} |`));
|
|
217
|
+
// A structured param goes below the table, not in it: a fenced block cannot
|
|
218
|
+
// live in a table cell — `cell` strips the newlines that make it a fence —
|
|
219
|
+
// and a nested object squeezed onto one line is the unreadable case, which
|
|
220
|
+
// is precisely the shape a `kind -> payload` table has.
|
|
221
|
+
for (const [name, value] of params) {
|
|
222
|
+
if (isFlat(value))
|
|
223
|
+
continue;
|
|
224
|
+
out.push('', `${code(name)}:`, '', ...jsonBlock(value));
|
|
225
|
+
}
|
|
129
226
|
}
|
|
130
227
|
if (req.outOfScope.length > 0) {
|
|
131
228
|
out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${s}`));
|
|
@@ -152,13 +249,50 @@ function interpolate(statement, params) {
|
|
|
152
249
|
*/
|
|
153
250
|
function plain(value) {
|
|
154
251
|
const one = (v) => v.replace(/([*_`[\]\\])/g, '\\$1');
|
|
155
|
-
return Array.isArray(value)
|
|
252
|
+
return Array.isArray(value)
|
|
253
|
+
? value.map((v) => one(inlineText(v))).join(', ')
|
|
254
|
+
: one(inlineText(value));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* One param value as a run of text.
|
|
258
|
+
*
|
|
259
|
+
* `String(v)` on an object is `[object Object]`, and this function is the last
|
|
260
|
+
* place that can be stopped. It is not stopped by `non-scalar-interpolation`:
|
|
261
|
+
* `render` reads the registry and nothing else — no spec parse, so no
|
|
262
|
+
* `AttestPlan`, so no `validateStructure` — and `attest render` therefore runs
|
|
263
|
+
* happily on a registry `check` would refuse. Compact sorted JSON is not a good
|
|
264
|
+
* sentence, but it is the value, and `check` says what to do about it. A
|
|
265
|
+
* rendering that reports the shape wrongly is worse than one that reads oddly.
|
|
266
|
+
*/
|
|
267
|
+
function inlineText(value) {
|
|
268
|
+
return value === null || typeof value !== 'object'
|
|
269
|
+
? String(value)
|
|
270
|
+
: JSON.stringify(sortDeep(value));
|
|
156
271
|
}
|
|
157
|
-
/** A param value as code, for the params table. */
|
|
272
|
+
/** A param value as code, for the params table. Structured values go below it. */
|
|
158
273
|
function formatValue(value) {
|
|
274
|
+
if (!isFlat(value))
|
|
275
|
+
return '_see below_';
|
|
159
276
|
return Array.isArray(value)
|
|
160
|
-
? value.map((v) => code(
|
|
161
|
-
: code(
|
|
277
|
+
? value.map((v) => code(inlineText(v))).join(', ')
|
|
278
|
+
: code(inlineText(value));
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* A structured param as a fenced JSON block.
|
|
282
|
+
*
|
|
283
|
+
* Keys sorted at every depth, because `JSON.stringify` writes them in insertion
|
|
284
|
+
* order and ATX-10 holds the same registry to the same bytes. The fence is
|
|
285
|
+
* measured rather than fixed at three for the reason `code` measures its own: the
|
|
286
|
+
* value is author-controlled, and a JSON string may contain a run of backticks
|
|
287
|
+
* that closes a fence written blind.
|
|
288
|
+
*/
|
|
289
|
+
function jsonBlock(value) {
|
|
290
|
+
const text = JSON.stringify(sortDeep(value), null, 2);
|
|
291
|
+
let longest = 0;
|
|
292
|
+
for (const run of text.matchAll(/`+/g))
|
|
293
|
+
longest = Math.max(longest, run[0].length);
|
|
294
|
+
const fence = '`'.repeat(Math.max(3, longest + 1));
|
|
295
|
+
return [`${fence}json`, text, fence];
|
|
162
296
|
}
|
|
163
297
|
/**
|
|
164
298
|
* Wrap text in a code span that survives backticks in the value: the fence has
|
|
@@ -166,14 +300,35 @@ function formatValue(value) {
|
|
|
166
300
|
* at either end needs padding spaces.
|
|
167
301
|
*/
|
|
168
302
|
function code(value) {
|
|
169
|
-
|
|
303
|
+
// Accumulated, never `Math.max(0, ...runs)` (ATX-59). The spread puts one
|
|
304
|
+
// argument on the stack per backtick run, so a param holding a few hundred
|
|
305
|
+
// thousand of them exhausted it — `RangeError` out of `attest render` under
|
|
306
|
+
// the static reader, with the stack naming this function. Not backtracking,
|
|
307
|
+
// and found only by sweeping for more of it: same reachable path, same
|
|
308
|
+
// registry-chooses-the-cost shape, different mechanism.
|
|
309
|
+
let longest = 0;
|
|
310
|
+
for (const run of value.matchAll(/`+/g))
|
|
311
|
+
longest = Math.max(longest, run[0].length);
|
|
170
312
|
const fence = '`'.repeat(longest + 1);
|
|
171
313
|
const pad = value.startsWith('`') || value.endsWith('`') ? ' ' : '';
|
|
172
314
|
return `${fence}${pad}${value}${pad}${fence}`;
|
|
173
315
|
}
|
|
174
|
-
/**
|
|
316
|
+
/**
|
|
317
|
+
* Make prose safe inside a table cell: no row-breaking pipes, no newlines.
|
|
318
|
+
*
|
|
319
|
+
* Each maximal whitespace run is matched once and inspected, rather than split
|
|
320
|
+
* across a pattern that puts a required character after a leading quantifier
|
|
321
|
+
* (ATX-59). `\s+` has nothing after it to fail against, so there is no
|
|
322
|
+
* backtracking to be quadratic in, and the newline decision moves to the
|
|
323
|
+
* callback. That is the property to preserve: any rewrite that puts a literal
|
|
324
|
+
* behind a quantifier here reintroduces it, including the narrower character
|
|
325
|
+
* class that looks like the obvious repair, which came out slower. Measured in
|
|
326
|
+
* `[0.7.0]`.
|
|
327
|
+
*/
|
|
175
328
|
function cell(text) {
|
|
176
|
-
return text
|
|
329
|
+
return text
|
|
330
|
+
.replace(/\s+/g, (ws) => (ws.includes('\n') ? ' ' : ws))
|
|
331
|
+
.replace(/\|/g, '\\|');
|
|
177
332
|
}
|
|
178
333
|
/** GitHub/GitLab slug for a `## AUTH-3` heading. */
|
|
179
334
|
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
|