@am_shork/attest 0.7.0 → 0.7.2
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 +562 -112
- package/README.md +4 -4
- package/bin/attest.js +0 -0
- package/dist/cli/action.d.ts +48 -0
- package/dist/cli/action.js +100 -0
- package/dist/cli/index.js +11 -32
- package/dist/cli/report.js +9 -1
- 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/locate.d.ts +9 -10
- package/dist/core/locate.js +58 -15
- package/dist/core/merge.js +27 -2
- package/dist/core/order.d.ts +17 -0
- package/dist/core/order.js +25 -0
- package/dist/core/pipeline.js +34 -14
- package/dist/core/render.js +90 -50
- package/dist/core/runner.js +4 -6
- package/dist/core/schema.d.ts +13 -6
- package/dist/core/schema.js +54 -18
- 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 +4 -9
- package/dist/core/terminal.d.ts +9 -23
- package/dist/core/terminal.js +9 -23
- package/dist/core/types.d.ts +1 -1
- package/dist/core/validator.d.ts +1 -0
- package/dist/core/validator.js +37 -0
- package/package.json +29 -17
package/dist/core/pipeline.js
CHANGED
|
@@ -109,9 +109,13 @@ export async function runCheck(root, options = {}) {
|
|
|
109
109
|
// only one where that distinction is worth anything.
|
|
110
110
|
return withLoader(options, async (loader) => {
|
|
111
111
|
const { registry, issues, unreadableFiles } = await readRegistry(root, options, scan.reqsFiles, loader);
|
|
112
|
-
const plan = await parseSpecs(scan.specFiles, root);
|
|
112
|
+
const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
|
|
113
113
|
return [
|
|
114
114
|
...issues,
|
|
115
|
+
// A spec the parser could not read is reported here rather than dropped:
|
|
116
|
+
// `check`'s contract is breadth, and a file silently contributing no
|
|
117
|
+
// scenarios reads as a file with no scenarios (ATX-65).
|
|
118
|
+
...unreadableSpecs,
|
|
115
119
|
// `check` keeps reporting on a registry that only half-loaded,
|
|
116
120
|
// deliberately: its contract is breadth, and the findings from the files
|
|
117
121
|
// that *did* load are all still true. What it must not do is advise work
|
|
@@ -220,7 +224,14 @@ async function unclaimedProposedSpecIssues(root, scan, options, loader) {
|
|
|
220
224
|
for (const id of claimedIds(read.delta))
|
|
221
225
|
claimed.add(id);
|
|
222
226
|
}
|
|
223
|
-
const proposed = await parseSpecs(scan.proposedSpecFiles, root);
|
|
227
|
+
const { plan: proposed, issues: unreadable } = await parseSpecs(scan.proposedSpecFiles, root);
|
|
228
|
+
issues.push(...unreadable);
|
|
229
|
+
// A file that would not parse declares no scenarios, so the loop below would
|
|
230
|
+
// find it claimed by nothing and call it unclaimed — a true sentence about a
|
|
231
|
+
// file whose real problem is that it could not be read, and a fix hint
|
|
232
|
+
// pointing at work that must not be done. Same shape as `orphan-test` after a
|
|
233
|
+
// registry fails to load (ATX-62), and refused here for the same reason.
|
|
234
|
+
const unreadableFiles = new Set(unreadable.map((i) => i.file));
|
|
224
235
|
const claimedFiles = new Set(proposed.scenarios.filter((s) => claimed.has(s.reqId)).map((s) => s.file));
|
|
225
236
|
// Reported per file, not per scenario: the file is the unit a run includes,
|
|
226
237
|
// so it is the unit that did or did not execute, and one line per scenario
|
|
@@ -230,7 +241,7 @@ async function unclaimedProposedSpecIssues(root, scan, options, loader) {
|
|
|
230
241
|
// nothing for a second reason, and reading the plan alone cannot see it.
|
|
231
242
|
for (const abs of scan.proposedSpecFiles) {
|
|
232
243
|
const file = relativePath(root, abs);
|
|
233
|
-
if (claimedFiles.has(file))
|
|
244
|
+
if (claimedFiles.has(file) || unreadableFiles.has(file))
|
|
234
245
|
continue;
|
|
235
246
|
issues.push({
|
|
236
247
|
level: 'ERROR',
|
|
@@ -287,7 +298,9 @@ export async function runVerify(root, options = {}) {
|
|
|
287
298
|
registry = loaded.registry;
|
|
288
299
|
issues.push(...loaded.issues);
|
|
289
300
|
unreadableFiles = loaded.unreadableFiles;
|
|
290
|
-
|
|
301
|
+
const parsedSpecs = await parseSpecs(scan.specFiles, root);
|
|
302
|
+
plan = parsedSpecs.plan;
|
|
303
|
+
issues.push(...parsedSpecs.issues);
|
|
291
304
|
}
|
|
292
305
|
finally {
|
|
293
306
|
await loader.close();
|
|
@@ -365,7 +378,7 @@ export async function runCover(root, options = {}) {
|
|
|
365
378
|
const { registry, issues: loadIssues } = await readRegistry(root, options, scan.reqsFiles);
|
|
366
379
|
if (hasError(loadIssues))
|
|
367
380
|
return { rows: [], issues: loadIssues };
|
|
368
|
-
const plan = await parseSpecs(scan.specFiles, root);
|
|
381
|
+
const { plan, issues: unreadableSpecs } = await parseSpecs(scan.specFiles, root);
|
|
369
382
|
const counts = new Map();
|
|
370
383
|
for (const s of plan.scenarios) {
|
|
371
384
|
counts.set(s.reqId, (counts.get(s.reqId) ?? 0) + 1);
|
|
@@ -381,7 +394,7 @@ export async function runCover(root, options = {}) {
|
|
|
381
394
|
covered: (counts.get(reqId) ?? 0) > 0,
|
|
382
395
|
scenarioCount: counts.get(reqId) ?? 0,
|
|
383
396
|
}));
|
|
384
|
-
return { rows, issues: [...loadIssues, ...uncoveredIssues(registry, plan)] };
|
|
397
|
+
return { rows, issues: [...loadIssues, ...unreadableSpecs, ...uncoveredIssues(registry, plan)] };
|
|
385
398
|
}
|
|
386
399
|
/**
|
|
387
400
|
* Markdown projection of the intent layer (design §9: `attest render`).
|
|
@@ -607,10 +620,15 @@ function claimedByDelta(proposed, delta) {
|
|
|
607
620
|
* walking the tree again, so a caller that already scanned does not repeat it.
|
|
608
621
|
*/
|
|
609
622
|
async function changeMergedPlan(root, delta, scan) {
|
|
610
|
-
const
|
|
611
|
-
const
|
|
612
|
-
const
|
|
623
|
+
const base = await parseSpecs(scan.specFiles, root);
|
|
624
|
+
const proposedSpecs = await parseSpecs(scan.proposedSpecFiles, root);
|
|
625
|
+
const basePlan = base.plan;
|
|
626
|
+
const claimed = claimedByDelta(proposedSpecs.plan, delta);
|
|
613
627
|
return {
|
|
628
|
+
// A spec neither parse could read blocks the gate rather than vanishing
|
|
629
|
+
// from it: an unreadable proposed spec is a scenario the gate would
|
|
630
|
+
// otherwise report as absent (ATX-65).
|
|
631
|
+
issues: [...base.issues, ...proposedSpecs.issues],
|
|
614
632
|
merged: {
|
|
615
633
|
scenarios: [...basePlan.scenarios, ...claimed.scenarios],
|
|
616
634
|
paramRefs: [...basePlan.paramRefs, ...claimed.paramRefs],
|
|
@@ -679,10 +697,7 @@ export async function runArchiveApply(root, changeName, options = {}) {
|
|
|
679
697
|
* The gate, plus what finishing the merge would need.
|
|
680
698
|
*
|
|
681
699
|
* One function rather than a gate and a separate `--apply` path, because the
|
|
682
|
-
* merge must act on **this** run's verdict
|
|
683
|
-
* different answer than the one just printed — and a command able to file a
|
|
684
|
-
* change as done against a stale verdict removes the hard definition of "done"
|
|
685
|
-
* that is this tool's whole claim.
|
|
700
|
+
* merge must act on **this** run's verdict (design §8).
|
|
686
701
|
*
|
|
687
702
|
* `merge` is absent exactly when there is nothing to act on: a rejected name, an
|
|
688
703
|
* unreadable delta, or a registry that would not load.
|
|
@@ -725,7 +740,12 @@ async function archiveRun(root, changeName, options = {}) {
|
|
|
725
740
|
// Static plan = merged base suite + this change's specs (design §8), by the
|
|
726
741
|
// same function `status` reports against — a progress report computed over a
|
|
727
742
|
// different spec set than the gate uses would be a report about nothing.
|
|
728
|
-
const { merged: plan, claimed } = await changeMergedPlan(root, delta, scan);
|
|
743
|
+
const { merged: plan, claimed, issues: unreadableSpecs } = await changeMergedPlan(root, delta, scan);
|
|
744
|
+
// Before the run, not after: an unreadable spec means the gate cannot see
|
|
745
|
+
// what that file declared, so letting the suite start would grade the
|
|
746
|
+
// change against a plan known to be short (ATX-65).
|
|
747
|
+
if (unreadableSpecs.length > 0)
|
|
748
|
+
return { issues: unreadableSpecs };
|
|
729
749
|
// Other proposals need no exclude glob of their own: the include list below
|
|
730
750
|
// is the plan's own files, and the plan holds only the proposed specs this
|
|
731
751
|
// delta claims. That is what replaced `**/changes/<sibling>/**` — with the
|
package/dist/core/render.js
CHANGED
|
@@ -25,8 +25,13 @@
|
|
|
25
25
|
// and it is a *file* — committed, served, and read again long after the run
|
|
26
26
|
// that wrote it. See `sanitised` for why the defence sits here rather than
|
|
27
27
|
// at the terminal write.
|
|
28
|
-
import { byCodeUnit } from './order.js';
|
|
28
|
+
import { byCodeUnit, sortDeep } from './order.js';
|
|
29
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
|
+
}
|
|
30
35
|
const BANNER = '<!-- Generated by `attest render` — do not edit. Edit the `*.reqs.ts` registry and regenerate. -->';
|
|
31
36
|
/**
|
|
32
37
|
* Render the registry as a standalone Markdown document.
|
|
@@ -52,23 +57,11 @@ export function renderMarkdown(registry) {
|
|
|
52
57
|
}
|
|
53
58
|
/**
|
|
54
59
|
* 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.
|
|
60
|
+
* characters (ATX-58, design §9.1).
|
|
65
61
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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.
|
|
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.
|
|
72
65
|
*
|
|
73
66
|
* Ids are not sanitised and need not be: `RegistrySchema` holds every key to
|
|
74
67
|
* `^[A-Z]+-\d+$` on **both** reader paths — the static one by construction, the
|
|
@@ -90,11 +83,25 @@ function sanitised(registry) {
|
|
|
90
83
|
}
|
|
91
84
|
return out;
|
|
92
85
|
}
|
|
93
|
-
/**
|
|
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
|
+
*/
|
|
94
96
|
function sanitisedValue(value) {
|
|
97
|
+
if (typeof value === 'string')
|
|
98
|
+
return control(value);
|
|
95
99
|
if (Array.isArray(value))
|
|
96
|
-
return value.map(
|
|
97
|
-
|
|
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;
|
|
98
105
|
}
|
|
99
106
|
/**
|
|
100
107
|
* Line endings are a checkout artifact, not content.
|
|
@@ -164,16 +171,11 @@ function orderingKey(id) {
|
|
|
164
171
|
* numerically. A plain string sort puts ATX-10 between ATX-1 and ATX-2, which
|
|
165
172
|
* scrambles the document as soon as a registry reaches ten requirements.
|
|
166
173
|
*
|
|
167
|
-
* Total, and a function of the ids alone.
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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.
|
|
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.
|
|
177
179
|
*/
|
|
178
180
|
function compareIds(a, b) {
|
|
179
181
|
const [prefixA, numA] = orderingKey(a);
|
|
@@ -212,6 +214,15 @@ function section(id, req) {
|
|
|
212
214
|
const params = Object.entries(req.params);
|
|
213
215
|
if (params.length > 0) {
|
|
214
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
|
+
}
|
|
215
226
|
}
|
|
216
227
|
if (req.outOfScope.length > 0) {
|
|
217
228
|
out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${s}`));
|
|
@@ -238,13 +249,50 @@ function interpolate(statement, params) {
|
|
|
238
249
|
*/
|
|
239
250
|
function plain(value) {
|
|
240
251
|
const one = (v) => v.replace(/([*_`[\]\\])/g, '\\$1');
|
|
241
|
-
return Array.isArray(value)
|
|
252
|
+
return Array.isArray(value)
|
|
253
|
+
? value.map((v) => one(inlineText(v))).join(', ')
|
|
254
|
+
: one(inlineText(value));
|
|
242
255
|
}
|
|
243
|
-
/**
|
|
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));
|
|
271
|
+
}
|
|
272
|
+
/** A param value as code, for the params table. Structured values go below it. */
|
|
244
273
|
function formatValue(value) {
|
|
274
|
+
if (!isFlat(value))
|
|
275
|
+
return '_see below_';
|
|
245
276
|
return Array.isArray(value)
|
|
246
|
-
? value.map((v) => code(
|
|
247
|
-
: 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];
|
|
248
296
|
}
|
|
249
297
|
/**
|
|
250
298
|
* Wrap text in a code span that survives backticks in the value: the fence has
|
|
@@ -268,22 +316,14 @@ function code(value) {
|
|
|
268
316
|
/**
|
|
269
317
|
* Make prose safe inside a table cell: no row-breaking pipes, no newlines.
|
|
270
318
|
*
|
|
271
|
-
* Each maximal whitespace run is matched once and inspected, rather than
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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.
|
|
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]`.
|
|
287
327
|
*/
|
|
288
328
|
function cell(text) {
|
|
289
329
|
return text
|
package/dist/core/runner.js
CHANGED
|
@@ -125,12 +125,10 @@ export async function runAndCollect(options = {}) {
|
|
|
125
125
|
/**
|
|
126
126
|
* Every scenario belonging to a requirement suite, at any depth beneath it.
|
|
127
127
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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.
|
|
128
|
+
* Recursive, not direct children only: this is the seam design §5.4 names, where
|
|
129
|
+
* the two readers of one plan have to descend the same way. A scenario grouped
|
|
130
|
+
* under a nested `describe` is a `test` inside a `suite` inside `[reqId]`, and
|
|
131
|
+
* taking direct children makes it invisible here while `parser.ts` still sees it.
|
|
134
132
|
*
|
|
135
133
|
* Descent stops at a nested requirement suite, so a `requirement()` written
|
|
136
134
|
* inside another one keeps its own scenarios rather than donating them upward.
|
package/dist/core/schema.d.ts
CHANGED
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
+
/** A scalar param value. `null` is included: it is how an author writes "empty". */
|
|
3
|
+
declare const scalar: z.ZodUnion<[z.ZodNumber, z.ZodString, z.ZodBoolean, z.ZodNull]>;
|
|
4
|
+
/** Any JSON value — what a param may be. */
|
|
5
|
+
export type ParamValue = z.infer<typeof scalar> | ParamValue[] | {
|
|
6
|
+
[key: string]: ParamValue;
|
|
7
|
+
};
|
|
2
8
|
/** A single behavioural contract (design §2). */
|
|
3
9
|
export declare const RequirementSchema: z.ZodObject<{
|
|
4
10
|
statement: z.ZodEffects<z.ZodString, string, string>;
|
|
5
11
|
rationale: z.ZodString;
|
|
6
|
-
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.
|
|
12
|
+
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.ZodTypeDef, ParamValue>>>;
|
|
7
13
|
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
8
14
|
}, "strip", z.ZodTypeAny, {
|
|
9
|
-
params: Record<string,
|
|
15
|
+
params: Record<string, ParamValue>;
|
|
10
16
|
statement: string;
|
|
11
17
|
rationale: string;
|
|
12
18
|
outOfScope: string[];
|
|
13
19
|
}, {
|
|
14
20
|
statement: string;
|
|
15
21
|
rationale: string;
|
|
16
|
-
params?: Record<string,
|
|
22
|
+
params?: Record<string, ParamValue> | undefined;
|
|
17
23
|
outOfScope?: string[] | undefined;
|
|
18
24
|
}>;
|
|
19
25
|
/**
|
|
@@ -32,17 +38,17 @@ export declare const RequirementIdSchema: z.ZodString;
|
|
|
32
38
|
export declare const RegistrySchema: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
33
39
|
statement: z.ZodEffects<z.ZodString, string, string>;
|
|
34
40
|
rationale: z.ZodString;
|
|
35
|
-
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.
|
|
41
|
+
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.ZodTypeDef, ParamValue>>>;
|
|
36
42
|
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
37
43
|
}, "strip", z.ZodTypeAny, {
|
|
38
|
-
params: Record<string,
|
|
44
|
+
params: Record<string, ParamValue>;
|
|
39
45
|
statement: string;
|
|
40
46
|
rationale: string;
|
|
41
47
|
outOfScope: string[];
|
|
42
48
|
}, {
|
|
43
49
|
statement: string;
|
|
44
50
|
rationale: string;
|
|
45
|
-
params?: Record<string,
|
|
51
|
+
params?: Record<string, ParamValue> | undefined;
|
|
46
52
|
outOfScope?: string[] | undefined;
|
|
47
53
|
}>>;
|
|
48
54
|
/** Parsed (output) shapes — defaults applied. */
|
|
@@ -51,4 +57,5 @@ export type Registry = z.infer<typeof RegistrySchema>;
|
|
|
51
57
|
/** Authoring (input) shapes — params / outOfScope optional. */
|
|
52
58
|
export type RequirementInput = z.input<typeof RequirementSchema>;
|
|
53
59
|
export type RegistryInput = z.input<typeof RegistrySchema>;
|
|
60
|
+
export {};
|
|
54
61
|
//# sourceMappingURL=schema.d.ts.map
|
package/dist/core/schema.js
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
// This is the single source of truth for the shape of the intent layer; the
|
|
3
3
|
// Requirement / Registry TypeScript types are inferred from it.
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
+
/** A scalar param value. `null` is included: it is how an author writes "empty". */
|
|
6
|
+
const scalar = z.union([z.number(), z.string(), z.boolean(), z.null()]);
|
|
7
|
+
/**
|
|
8
|
+
* An object literal and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* The guard runs on the *input*, before `z.record` copies own keys into a fresh
|
|
11
|
+
* object, because that copy is exactly what hides the case it is here for:
|
|
12
|
+
* `{ __proto__: { … } }` swaps the prototype rather than creating a key, so the
|
|
13
|
+
* parsed value is `{}` and the taint is invisible one step later. The static
|
|
14
|
+
* reader refuses that source outright (`registry-not-static`); without this the
|
|
15
|
+
* evaluating reader would call the same file green, and the two readers agreeing
|
|
16
|
+
* is the property `tests/static-registry.spec.ts` exists to hold.
|
|
17
|
+
*
|
|
18
|
+
* A class instance and a `Date` fail here too, which is the other half of what
|
|
19
|
+
* "JSON data" means — both survive `typeof v === 'object'` and neither has a
|
|
20
|
+
* meaningful rendering.
|
|
21
|
+
*/
|
|
22
|
+
function isPlainObject(input) {
|
|
23
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input))
|
|
24
|
+
return false;
|
|
25
|
+
// Both spellings, because they are not the same thing and the static reader
|
|
26
|
+
// refuses both: a literal `__proto__:` swaps the prototype, while the key
|
|
27
|
+
// arriving through `JSON.parse` is an own property that survives into the
|
|
28
|
+
// registry and means something else to every later reader of it.
|
|
29
|
+
if (Object.hasOwn(input, '__proto__'))
|
|
30
|
+
return false;
|
|
31
|
+
const proto = Object.getPrototypeOf(input);
|
|
32
|
+
return proto === Object.prototype || proto === null;
|
|
33
|
+
}
|
|
34
|
+
const jsonObject = z
|
|
35
|
+
.custom(isPlainObject)
|
|
36
|
+
.pipe(z.record(z.string(), z.lazy(() => paramValue)));
|
|
37
|
+
const paramValue = z.lazy(() => z.union([scalar, z.array(paramValue), jsonObject], {
|
|
38
|
+
errorMap: () => ({
|
|
39
|
+
message: 'expected JSON data (no functions, dates, or class instances)',
|
|
40
|
+
}),
|
|
41
|
+
}));
|
|
5
42
|
/** A single behavioural contract (design §2). */
|
|
6
43
|
export const RequirementSchema = z.object({
|
|
7
44
|
statement: z
|
|
@@ -10,24 +47,23 @@ export const RequirementSchema = z.object({
|
|
|
10
47
|
message: 'statement must contain the RFC-2119 keyword SHALL or MUST',
|
|
11
48
|
}),
|
|
12
49
|
rationale: z.string().min(10, 'rationale must not be empty (the intent layer has to say why)'),
|
|
13
|
-
// A param is
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
.default({}),
|
|
50
|
+
// A param is any JSON value. Lists (vendor blacklists, id sets) are the most
|
|
51
|
+
// drift-prone constants, so keeping them out of params left the highest-risk
|
|
52
|
+
// values unguarded; the same argument runs one step further, because a
|
|
53
|
+
// kind -> payload table drifts harder than a list and was the one shape left
|
|
54
|
+
// outside. A param still has exactly one owner (the spec) read by exactly one
|
|
55
|
+
// place (the scenario), whatever its depth.
|
|
56
|
+
//
|
|
57
|
+
// What the old scalar-or-list union was really protecting was the *rendering*:
|
|
58
|
+
// `{payloadKinds}` interpolated into a statement as `[object Object]`. That is
|
|
59
|
+
// a property of the interpolation point, not of the value, so it is enforced
|
|
60
|
+
// there — `non-scalar-interpolation` in `validator.ts`, and defensively in
|
|
61
|
+
// `render.ts`, which runs without the validator. This union keeps its own
|
|
62
|
+
// message anyway, because a union's default one is the word "Invalid input",
|
|
63
|
+
// which names neither what was given nor what is accepted, and the values that
|
|
64
|
+
// now reach it are the ones JSON has no place for: a function, a Date, a class
|
|
65
|
+
// instance.
|
|
66
|
+
params: z.record(z.string(), paramValue).default({}),
|
|
31
67
|
outOfScope: z.array(z.string()).default([]),
|
|
32
68
|
});
|
|
33
69
|
/**
|
package/dist/core/splice.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { Registry, Requirement } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Thrown when a value reaches the emitter that cannot be written as source
|
|
4
|
+
* without the written form meaning something else than the value.
|
|
5
|
+
*
|
|
6
|
+
* A separate class rather than a bare `Error` because `merge.ts` has to tell it
|
|
7
|
+
* from an I/O failure: this one is an invariant of *this* module, and the
|
|
8
|
+
* caller's job on catching it is to report an internal inconsistency while
|
|
9
|
+
* keeping the account of what it had already written.
|
|
10
|
+
*/
|
|
11
|
+
export declare class UnwritableValue extends Error {
|
|
12
|
+
}
|
|
2
13
|
/**
|
|
3
14
|
* One registry entry, at `indent`, with no trailing comma.
|
|
4
15
|
*
|
|
@@ -28,18 +39,8 @@ export declare function spliceRequirements(file: string, source: string, additio
|
|
|
28
39
|
/**
|
|
29
40
|
* `source` with every import of `from` repointed at `to`.
|
|
30
41
|
*
|
|
31
|
-
* The second edit `--apply` makes to a file it did not write
|
|
32
|
-
*
|
|
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.
|
|
42
|
+
* The second edit `--apply` makes to a file it did not write: "renamed in place"
|
|
43
|
+
* is true of the spec's location and not of its imports (design §8, ATX-48).
|
|
43
44
|
* So this replaces one string literal and touches nothing else — the same
|
|
44
45
|
* discipline as the splice, for the same reason.
|
|
45
46
|
*
|
package/dist/core/splice.js
CHANGED
|
@@ -23,7 +23,7 @@ import ts from 'typescript';
|
|
|
23
23
|
import { dirname, relative, resolve } from 'node:path';
|
|
24
24
|
import { registryInsertionPoint } from './static-registry.js';
|
|
25
25
|
import { toPosixPath } from './paths.js';
|
|
26
|
-
import { byCodeUnit } from './order.js';
|
|
26
|
+
import { byCodeUnit, sortDeep } from './order.js';
|
|
27
27
|
/**
|
|
28
28
|
* A TypeScript single-quoted string literal holding exactly `value`.
|
|
29
29
|
*
|
|
@@ -55,13 +55,62 @@ function tsString(value) {
|
|
|
55
55
|
}
|
|
56
56
|
return `${out}'`;
|
|
57
57
|
}
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Thrown when a value reaches the emitter that cannot be written as source
|
|
60
|
+
* without the written form meaning something else than the value.
|
|
61
|
+
*
|
|
62
|
+
* A separate class rather than a bare `Error` because `merge.ts` has to tell it
|
|
63
|
+
* from an I/O failure: this one is an invariant of *this* module, and the
|
|
64
|
+
* caller's job on catching it is to report an internal inconsistency while
|
|
65
|
+
* keeping the account of what it had already written.
|
|
66
|
+
*/
|
|
67
|
+
export class UnwritableValue extends Error {
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A param key, bare when it is a plain identifier and quoted when it is not.
|
|
71
|
+
*
|
|
72
|
+
* `__proto__` is neither, and quoting is not the repair — `{'__proto__': x}`
|
|
73
|
+
* swaps the prototype in a literal exactly as the bare form does, and the one
|
|
74
|
+
* spelling that would create an own property, `{['__proto__']: x}`, is a
|
|
75
|
+
* computed key the static reader refuses. So there is no text this function
|
|
76
|
+
* could emit whose evaluation is the value it was handed, and the only correct
|
|
77
|
+
* move is to refuse.
|
|
78
|
+
*
|
|
79
|
+
* Unreachable through any command today: `RequirementSchema` rejects a nested
|
|
80
|
+
* `__proto__` and `z.record` drops a top-level one, so `--apply` validates the
|
|
81
|
+
* delta before a value gets here. It is checked anyway because this is the one
|
|
82
|
+
* site that *writes* a registry, and the guard on the reading side
|
|
83
|
+
* (`static-registry.ts`, "refuse, so a file the evaluator also rejects stays
|
|
84
|
+
* rejected") is worth nothing if the writer can produce the file the reader
|
|
85
|
+
* exists to refuse. A defence that holds only because something upstream holds
|
|
86
|
+
* is not a defence — the same standard `compareIds` is written to.
|
|
87
|
+
*/
|
|
59
88
|
function keySource(key) {
|
|
89
|
+
if (key === '__proto__') {
|
|
90
|
+
throw new UnwritableValue('a param key named __proto__ cannot be written as source');
|
|
91
|
+
}
|
|
60
92
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : tsString(key);
|
|
61
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* One param value as TypeScript source.
|
|
96
|
+
*
|
|
97
|
+
* Recursive over objects as well as arrays: a param is a JSON value, and the
|
|
98
|
+
* fallback here is `String(value)` — which writes `[object Object]` into a
|
|
99
|
+
* `*.reqs.ts` that `--apply` then merges and commits. Key order is the caller's
|
|
100
|
+
* job: `requirementSource` runs the whole params record through `sortDeep`, so
|
|
101
|
+
* emitting in iteration order here *is* code-unit order at every depth.
|
|
102
|
+
*/
|
|
62
103
|
function paramSource(value) {
|
|
63
104
|
if (Array.isArray(value))
|
|
64
105
|
return `[${value.map((v) => paramSource(v)).join(', ')}]`;
|
|
106
|
+
if (value !== null && typeof value === 'object') {
|
|
107
|
+
const body = Object.entries(value)
|
|
108
|
+
.map(([k, v]) => `${keySource(k)}: ${paramSource(v)}`)
|
|
109
|
+
.join(', ');
|
|
110
|
+
return body === '' ? '{}' : `{ ${body} }`;
|
|
111
|
+
}
|
|
112
|
+
// `String` is right for the rest and only for the rest: number, boolean, and
|
|
113
|
+
// `null` — whose spelling is `null`, which is also the literal that reads back.
|
|
65
114
|
return typeof value === 'string' ? tsString(value) : String(value);
|
|
66
115
|
}
|
|
67
116
|
/**
|
|
@@ -85,10 +134,12 @@ export function requirementSource(id, req, indent) {
|
|
|
85
134
|
`${inner}statement: ${tsString(req.statement)},`,
|
|
86
135
|
`${inner}rationale: ${tsString(req.rationale)},`,
|
|
87
136
|
];
|
|
88
|
-
// Code-unit key order, so one delta applied twice writes the
|
|
89
|
-
// property `--apply`'s re-runnability rests on, and the
|
|
90
|
-
//
|
|
91
|
-
|
|
137
|
+
// Code-unit key order at every depth, so one delta applied twice writes the
|
|
138
|
+
// same bytes — the property `--apply`'s re-runnability rests on, and the
|
|
139
|
+
// reason `apply.ts` canonicalises through the same `sortDeep` for
|
|
140
|
+
// `add-conflict`. Depth matters because a param is a JSON value: nested keys
|
|
141
|
+
// are as much of the emitted text as the outer ones.
|
|
142
|
+
const params = Object.entries(sortDeep(req.params));
|
|
92
143
|
if (params.length > 0) {
|
|
93
144
|
const body = params.map(([k, v]) => `${keySource(k)}: ${paramSource(v)}`).join(', ');
|
|
94
145
|
lines.push(`${inner}params: { ${body} },`);
|
|
@@ -125,18 +176,8 @@ export function spliceRequirements(file, source, additions) {
|
|
|
125
176
|
/**
|
|
126
177
|
* `source` with every import of `from` repointed at `to`.
|
|
127
178
|
*
|
|
128
|
-
* The second edit `--apply` makes to a file it did not write
|
|
129
|
-
*
|
|
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.
|
|
179
|
+
* The second edit `--apply` makes to a file it did not write: "renamed in place"
|
|
180
|
+
* is true of the spec's location and not of its imports (design §8, ATX-48).
|
|
140
181
|
* So this replaces one string literal and touches nothing else — the same
|
|
141
182
|
* discipline as the splice, for the same reason.
|
|
142
183
|
*
|
|
@@ -251,6 +251,12 @@ function literalValue(node) {
|
|
|
251
251
|
return true;
|
|
252
252
|
if (expr.kind === ts.SyntaxKind.FalseKeyword)
|
|
253
253
|
return false;
|
|
254
|
+
// `null` is a keyword, not a literal node, so it falls off the end of this
|
|
255
|
+
// function unless it is named here — and falling off means `registry-not-static`
|
|
256
|
+
// for the whole file, not a rejected param. The schema accepts `null`; a reader
|
|
257
|
+
// that does not is the two of them disagreeing about what a registry is.
|
|
258
|
+
if (expr.kind === ts.SyntaxKind.NullKeyword)
|
|
259
|
+
return null;
|
|
254
260
|
if (ts.isPrefixUnaryExpression(expr)) {
|
|
255
261
|
const operand = unwrap(expr.operand);
|
|
256
262
|
if (ts.isNumericLiteral(operand)) {
|