@am_shork/attest 0.7.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 +285 -112
- package/README.md +4 -4
- package/dist/core/apply.js +7 -10
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/locate.d.ts +5 -7
- package/dist/core/locate.js +5 -7
- 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 +1 -4
- 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 +1 -1
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
|
@@ -679,10 +679,7 @@ export async function runArchiveApply(root, changeName, options = {}) {
|
|
|
679
679
|
* The gate, plus what finishing the merge would need.
|
|
680
680
|
*
|
|
681
681
|
* 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.
|
|
682
|
+
* merge must act on **this** run's verdict (design §8).
|
|
686
683
|
*
|
|
687
684
|
* `merge` is absent exactly when there is nothing to act on: a rejected name, an
|
|
688
685
|
* unreadable delta, or a registry that would not load.
|
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)) {
|
package/dist/core/status.js
CHANGED
|
@@ -31,16 +31,11 @@ import { hasRecordedRed, recordedOutcome } from './red-record.js';
|
|
|
31
31
|
export function statusRows(addedIds, plan, firstRun) {
|
|
32
32
|
// Index the scenarios once, the way `validator.ts`'s `detectPotentialDrift`
|
|
33
33
|
// does and for the reason recorded there: filtering the whole plan per
|
|
34
|
-
// requirement is O(requirements x scenarios)
|
|
35
|
-
// one, so the inner term is the whole repository's scenario count
|
|
36
|
-
// outer is only what this change adds.
|
|
34
|
+
// requirement is O(requirements x scenarios), and the plan here is the
|
|
35
|
+
// *merged* one, so the inner term is the whole repository's scenario count.
|
|
37
36
|
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// inconsistency it is rather than as a slow path. Two functions over one
|
|
41
|
-
// shape held two beliefs about whether it is worth indexing; they hold one
|
|
42
|
-
// now. Insertion order is plan order, which is file then line, so the
|
|
43
|
-
// scenarios a row carries are still in the order their author reads them.
|
|
37
|
+
// Insertion order is plan order, which is file then line, so the scenarios a
|
|
38
|
+
// row carries stay in the order their author reads them.
|
|
44
39
|
const byReqId = new Map();
|
|
45
40
|
for (const s of plan.scenarios) {
|
|
46
41
|
const group = byReqId.get(s.reqId);
|
package/dist/core/terminal.d.ts
CHANGED
|
@@ -16,32 +16,18 @@ export declare function inline(text: string): string;
|
|
|
16
16
|
/**
|
|
17
17
|
* Every C0 control except the newline, plus DEL and the C1 range, as a space.
|
|
18
18
|
*
|
|
19
|
-
* A space rather than deletion
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* A space rather than deletion, and the newline exempt by not being in the
|
|
20
|
+
* class: both are design §9.1, stated there because they hold for every artifact
|
|
21
|
+
* Attest writes rather than only for this one.
|
|
22
22
|
*
|
|
23
|
-
* The
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* none when nothing matches, which is the case every real registry is. Measured
|
|
27
|
-
* over 120,000 characters: **27x** faster on plain ASCII, **54x** with
|
|
28
|
-
* newlines, **84x** on CJK. A payload that is *entirely* control bytes is a
|
|
29
|
-
* wash (0.9x), because then there is nothing to fast-path and both spellings
|
|
30
|
-
* build a new string; both are linear either way, so this is a constant factor
|
|
31
|
-
* rather than a second ATX-59.
|
|
23
|
+
* The one thing §9.1 does not reach: surrogates `D800`–`DFFF` fall outside every
|
|
24
|
+
* range below, so a pair is never touched and never split — the case that makes
|
|
25
|
+
* a per-code-point rewrite look necessary when it is not.
|
|
32
26
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
27
|
+
* Written out rather than computed per character so the scan allocates nothing
|
|
28
|
+
* when nothing matches, which is what every real registry is; `render` calls
|
|
35
29
|
* this once per statement, rationale, param key, param value and out-of-scope
|
|
36
|
-
* entry, on the
|
|
37
|
-
*
|
|
38
|
-
* **Byte-identical to the spelling it replaces**, which is the only thing that
|
|
39
|
-
* mattered: checked exhaustively over every code unit in the BMP, and over
|
|
40
|
-
* 200,000 randomised strings mixing control bytes, CJK, astral characters and
|
|
41
|
-
* lone surrogates. Surrogates are the case the spread existed to get right —
|
|
42
|
-
* `D800`–`DFFF` fall outside every range below, so neither spelling touches a
|
|
43
|
-
* pair or splits one — and newlines survive here as they always did, by not
|
|
44
|
-
* being in the class.
|
|
30
|
+
* entry, on the `render --check` path ATX-59 came off. Measured in `[0.7.0]`.
|
|
45
31
|
*/
|
|
46
32
|
export declare function control(text: string): string;
|
|
47
33
|
//# sourceMappingURL=terminal.d.ts.map
|