@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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The one spelling of the suite name that carries a requirement id.
|
|
2
|
+
//
|
|
3
|
+
// `requirement()` writes it, `runner.ts` reads it back out of the serialized
|
|
4
|
+
// task tree, and the runtime's own guard reads it to find the requirement that
|
|
5
|
+
// owns a scenario. That is three readers of one encoding, across a process
|
|
6
|
+
// boundary — the task tree is the only channel that crosses it (design §5.4) —
|
|
7
|
+
// and two spellings of one encoding are two encodings the moment either is
|
|
8
|
+
// edited.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately free of any `vitest` import, so both sides of the engine ↔
|
|
11
|
+
// runner boundary (`tests/import-boundary.spec.ts`) can depend on it.
|
|
12
|
+
/** How `requirement(id)` names its describe block. */
|
|
13
|
+
export function requirementSuiteName(id) {
|
|
14
|
+
return `[${id}]`;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* `.+` rather than something narrower: a delta may propose an id the registry's
|
|
18
|
+
* grammar would refuse, and this has to round-trip whatever `requirement()`
|
|
19
|
+
* wrote so the two readers agree about it (the call `readDeltaSource` makes for
|
|
20
|
+
* the same reason).
|
|
21
|
+
*/
|
|
22
|
+
const REQ_SUITE = /^\[(.+)\]$/;
|
|
23
|
+
/** The requirement id a suite name carries, or `undefined` if it carries none. */
|
|
24
|
+
export function requirementIdOf(suiteName) {
|
|
25
|
+
return REQ_SUITE.exec(suiteName)?.[1];
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=req-suite.js.map
|
package/dist/core/runner.js
CHANGED
|
@@ -5,9 +5,8 @@
|
|
|
5
5
|
import { startVitest } from 'vitest/node';
|
|
6
6
|
import { configDefaults } from 'vitest/config';
|
|
7
7
|
import { relativePath } from './paths.js';
|
|
8
|
+
import { requirementIdOf } from './req-suite.js';
|
|
8
9
|
import { byCodeUnit } from './order.js';
|
|
9
|
-
/** requirement() names each describe block `[reqId]`; recover the id from that. */
|
|
10
|
-
const REQ_SUITE = /^\[(.+)\]$/;
|
|
11
10
|
/**
|
|
12
11
|
* Vitest's own default exclusions, which every Attest run keeps on top of
|
|
13
12
|
* whatever else it excludes.
|
|
@@ -73,18 +72,15 @@ export async function runAndCollect(options = {}) {
|
|
|
73
72
|
// Reconstruct coverage from the task tree (not an in-process singleton).
|
|
74
73
|
const walk = (task) => {
|
|
75
74
|
if (task.type === 'suite') {
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
const id = m[1];
|
|
75
|
+
const id = requirementIdOf(task.name);
|
|
76
|
+
if (id !== undefined) {
|
|
79
77
|
const set = runtimeCoverage.get(id) ?? new Set();
|
|
80
78
|
const byName = outcomes.get(id) ?? new Map();
|
|
81
|
-
for (const c of task
|
|
79
|
+
for (const c of scenariosUnder(task)) {
|
|
82
80
|
// A scenario counts as covered only if it actually executed —
|
|
83
81
|
// skipped/todo scenarios have no run result (enables §8's
|
|
84
82
|
// declared-not-run check). The same test decides whether there is
|
|
85
83
|
// an outcome to record: a skip is not a red (design §6).
|
|
86
|
-
if (c.type !== 'test')
|
|
87
|
-
continue;
|
|
88
84
|
const outcome = executedOutcome(c);
|
|
89
85
|
if (!outcome)
|
|
90
86
|
continue;
|
|
@@ -126,6 +122,26 @@ export async function runAndCollect(options = {}) {
|
|
|
126
122
|
await vitest.close();
|
|
127
123
|
}
|
|
128
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Every scenario belonging to a requirement suite, at any depth beneath it.
|
|
127
|
+
*
|
|
128
|
+
* 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.
|
|
132
|
+
*
|
|
133
|
+
* Descent stops at a nested requirement suite, so a `requirement()` written
|
|
134
|
+
* inside another one keeps its own scenarios rather than donating them upward.
|
|
135
|
+
*/
|
|
136
|
+
function* scenariosUnder(suite) {
|
|
137
|
+
for (const child of suite.tasks ?? []) {
|
|
138
|
+
if (child.type === 'test')
|
|
139
|
+
yield child;
|
|
140
|
+
else if (child.type === 'suite' && requirementIdOf(child.name) === undefined) {
|
|
141
|
+
yield* scenariosUnder(child);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
129
145
|
/**
|
|
130
146
|
* Did this file fail before any of its tests could exist?
|
|
131
147
|
*
|
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/skill.js
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
// workflow to an agent that trusts it. The backstop is that every mistake it
|
|
35
35
|
// could cause is a diagnostic with a fix hint — a registry written the old way
|
|
36
36
|
// is `registry-not-static`, and the agent corrects itself from the report.
|
|
37
|
+
// It has happened once, so: read this file when a release adds a diagnostic.
|
|
38
|
+
// Nothing gates that — `ATX-57` catches a code the engine cannot emit, never one
|
|
39
|
+
// it can.
|
|
37
40
|
/**
|
|
38
41
|
* The one sentence that decides whether the workflow is ever loaded.
|
|
39
42
|
*
|
|
@@ -283,9 +286,10 @@ once. Branch on \`issues[].code\`, never on \`message\`:
|
|
|
283
286
|
| \`tests-red\` | a test is failing — the normal state until you are finished |
|
|
284
287
|
| \`never-red\` | an added requirement's scenario has no recorded failing run |
|
|
285
288
|
| \`uncovered-requirement\` | a requirement in the applied registry has no scenario |
|
|
286
|
-
| \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) |
|
|
289
|
+
| \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) — withdrawn when one of the two rows below already explains its file, so it never stands in for a load failure |
|
|
290
|
+
| \`spec-load-failed\` | a spec file could not be imported, so nothing in it ran. The run output carries the import error itself; this names which file it stopped |
|
|
287
291
|
| \`proposed-spec-unclaimed\` | a \`*.proposed.spec.ts\` no change's delta claims |
|
|
288
|
-
| \`added-id-unmerged\` |
|
|
292
|
+
| \`added-id-unmerged\` | the same load failure, when this change also adds an id the registry on disk lacks. The specific case, and the only one reported for that file |
|
|
289
293
|
| \`unbound-param\` | a \`{placeholder}\` has no matching \`params\` key |
|
|
290
294
|
| \`registry-not-static\` | a registry file is not a literal the engine can read |
|
|
291
295
|
| \`add-conflict\` | the delta adds an id that already exists with different content |
|
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
|
@@ -29,10 +29,23 @@ import { hasRecordedRed, recordedOutcome } from './red-record.js';
|
|
|
29
29
|
* author reads them in.
|
|
30
30
|
*/
|
|
31
31
|
export function statusRows(addedIds, plan, firstRun) {
|
|
32
|
+
// Index the scenarios once, the way `validator.ts`'s `detectPotentialDrift`
|
|
33
|
+
// does and for the reason recorded there: filtering the whole plan per
|
|
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.
|
|
36
|
+
//
|
|
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.
|
|
39
|
+
const byReqId = new Map();
|
|
40
|
+
for (const s of plan.scenarios) {
|
|
41
|
+
const group = byReqId.get(s.reqId);
|
|
42
|
+
if (group)
|
|
43
|
+
group.push(s);
|
|
44
|
+
else
|
|
45
|
+
byReqId.set(s.reqId, [s]);
|
|
46
|
+
}
|
|
32
47
|
return [...addedIds].sort(byCodeUnit).map((reqId) => {
|
|
33
|
-
const scenarios =
|
|
34
|
-
.filter((s) => s.reqId === reqId)
|
|
35
|
-
.map((s) => ({
|
|
48
|
+
const scenarios = (byReqId.get(reqId) ?? []).map((s) => ({
|
|
36
49
|
name: s.name,
|
|
37
50
|
file: s.file,
|
|
38
51
|
line: s.line,
|
package/dist/core/terminal.d.ts
CHANGED
|
@@ -16,9 +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
|
+
*
|
|
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.
|
|
26
|
+
*
|
|
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
|
|
29
|
+
* this once per statement, rationale, param key, param value and out-of-scope
|
|
30
|
+
* entry, on the `render --check` path ATX-59 came off. Measured in `[0.7.0]`.
|
|
22
31
|
*/
|
|
23
32
|
export declare function control(text: string): string;
|
|
24
33
|
//# sourceMappingURL=terminal.d.ts.map
|
package/dist/core/terminal.js
CHANGED
|
@@ -19,8 +19,6 @@
|
|
|
19
19
|
// loader has to sanitise Vite's log output for the same reason and cannot
|
|
20
20
|
// import from a layer above it. Two copies of this decision is how the crash
|
|
21
21
|
// path came to be missed once already.
|
|
22
|
-
/** The one control character that survives: see `block`. */
|
|
23
|
-
const NEWLINE = 0x0a;
|
|
24
22
|
/** How far a message is indented under its header line. */
|
|
25
23
|
export const INDENT = ' ';
|
|
26
24
|
/**
|
|
@@ -43,17 +41,21 @@ export function inline(text) {
|
|
|
43
41
|
/**
|
|
44
42
|
* Every C0 control except the newline, plus DEL and the C1 range, as a space.
|
|
45
43
|
*
|
|
46
|
-
* A space rather than deletion
|
|
47
|
-
*
|
|
48
|
-
*
|
|
44
|
+
* A space rather than deletion, and the newline exempt by not being in the
|
|
45
|
+
* class: both are design §9.1, stated there because they hold for every artifact
|
|
46
|
+
* Attest writes rather than only for this one.
|
|
47
|
+
*
|
|
48
|
+
* The one thing §9.1 does not reach: surrogates `D800`–`DFFF` fall outside every
|
|
49
|
+
* range below, so a pair is never touched and never split — the case that makes
|
|
50
|
+
* a per-code-point rewrite look necessary when it is not.
|
|
51
|
+
*
|
|
52
|
+
* Written out rather than computed per character so the scan allocates nothing
|
|
53
|
+
* when nothing matches, which is what every real registry is; `render` calls
|
|
54
|
+
* this once per statement, rationale, param key, param value and out-of-scope
|
|
55
|
+
* entry, on the `render --check` path ATX-59 came off. Measured in `[0.7.0]`.
|
|
49
56
|
*/
|
|
50
57
|
export function control(text) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const c = ch.codePointAt(0);
|
|
54
|
-
const isControl = (c < 0x20 && c !== NEWLINE) || (c >= 0x7f && c <= 0x9f);
|
|
55
|
-
return isControl ? ' ' : ch;
|
|
56
|
-
})
|
|
57
|
-
.join('');
|
|
58
|
+
// eslint-disable-next-line no-control-regex -- matching control characters is the whole function.
|
|
59
|
+
return text.replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, ' ');
|
|
58
60
|
}
|
|
59
61
|
//# sourceMappingURL=terminal.js.map
|
package/dist/core/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { Requirement, Registry } from './schema.js';
|
|
1
|
+
export type { Requirement, Registry, ParamValue } from './schema.js';
|
|
2
2
|
export type { IssueCode } from './docs.js';
|
|
3
3
|
import type { IssueCode } from './docs.js';
|
|
4
4
|
/** A scenario extracted statically from a spec file (design §5.2). */
|
package/dist/core/validator.d.ts
CHANGED
|
@@ -15,11 +15,16 @@ export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): I
|
|
|
15
15
|
* - orphan-test: a scenario covers an unknown requirement id
|
|
16
16
|
* - uncovered-requirement: a requirement has no scenario
|
|
17
17
|
* - unbound-param: a statement placeholder has no matching param
|
|
18
|
+
* - non-scalar-interpolation: a statement placeholder names a structured param
|
|
18
19
|
*
|
|
19
20
|
* and one WARNING:
|
|
20
21
|
* - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
|
|
22
|
+
*
|
|
23
|
+
* `registryIncomplete` says that at least one `*.reqs.ts` failed to load, so
|
|
24
|
+
* `registry` is known to be missing whatever was in it. It changes no verdict —
|
|
25
|
+
* only what `orphan-test` advises, for the reason below.
|
|
21
26
|
*/
|
|
22
|
-
export declare function validateStructure(registry: Registry, plan: AttestPlan): Issue[];
|
|
27
|
+
export declare function validateStructure(registry: Registry, plan: AttestPlan, registryIncomplete?: boolean): Issue[];
|
|
23
28
|
/**
|
|
24
29
|
* Weak anti-drift heuristic (design §6, mechanism 3). Evaluated per requirement,
|
|
25
30
|
* not per scenario: a requirement that owns params is quiet as soon as *any* one
|
package/dist/core/validator.js
CHANGED
|
@@ -30,14 +30,36 @@ export function uncoveredIssues(registry, plan) {
|
|
|
30
30
|
* - orphan-test: a scenario covers an unknown requirement id
|
|
31
31
|
* - uncovered-requirement: a requirement has no scenario
|
|
32
32
|
* - unbound-param: a statement placeholder has no matching param
|
|
33
|
+
* - non-scalar-interpolation: a statement placeholder names a structured param
|
|
33
34
|
*
|
|
34
35
|
* and one WARNING:
|
|
35
36
|
* - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
|
|
37
|
+
*
|
|
38
|
+
* `registryIncomplete` says that at least one `*.reqs.ts` failed to load, so
|
|
39
|
+
* `registry` is known to be missing whatever was in it. It changes no verdict —
|
|
40
|
+
* only what `orphan-test` advises, for the reason below.
|
|
36
41
|
*/
|
|
37
|
-
export function validateStructure(registry, plan) {
|
|
42
|
+
export function validateStructure(registry, plan, registryIncomplete = false) {
|
|
38
43
|
const issues = [];
|
|
39
44
|
const knownIds = new Set(Object.keys(registry));
|
|
40
45
|
// orphan-test: covers a requirement that does not exist.
|
|
46
|
+
//
|
|
47
|
+
// "Add it to the registry, or fix the id" is the right advice for an id that
|
|
48
|
+
// is genuinely absent, and **wrong** for one already sitting in a registry
|
|
49
|
+
// file that failed to load: the id is there, the fix is the load error, and
|
|
50
|
+
// following the hint would add a duplicate. That case is not rare when it
|
|
51
|
+
// happens — one unreadable `*.reqs.ts` orphans every scenario of every
|
|
52
|
+
// requirement it declared, so the wrong advice is also the loudest thing in
|
|
53
|
+
// the report.
|
|
54
|
+
//
|
|
55
|
+
// Which of the two this is cannot be decided here: the ids of a file that
|
|
56
|
+
// never parsed are exactly what is unavailable. So the finding stands and the
|
|
57
|
+
// *advice* names the uncertainty and the order to work in — a report that is
|
|
58
|
+
// quieter about problems it can still see would be the worse trade for a
|
|
59
|
+
// command whose contract is breadth.
|
|
60
|
+
const orphanFix = (id) => registryIncomplete
|
|
61
|
+
? `A registry file failed to load, so ids it declares are missing here — fix that first, and add "${id}" only if it is still unknown afterwards.`
|
|
62
|
+
: `Add it to the registry, or fix the id.`;
|
|
41
63
|
for (const s of plan.scenarios) {
|
|
42
64
|
if (!knownIds.has(s.reqId)) {
|
|
43
65
|
issues.push({
|
|
@@ -46,7 +68,7 @@ export function validateStructure(registry, plan) {
|
|
|
46
68
|
reqId: s.reqId,
|
|
47
69
|
file: s.file,
|
|
48
70
|
line: s.line,
|
|
49
|
-
message: `scenario "${s.name}" attests unknown requirement "${s.reqId}".
|
|
71
|
+
message: `scenario "${s.name}" attests unknown requirement "${s.reqId}". ${orphanFix(s.reqId)}`,
|
|
50
72
|
});
|
|
51
73
|
}
|
|
52
74
|
}
|
|
@@ -57,6 +79,14 @@ export function validateStructure(registry, plan) {
|
|
|
57
79
|
// `Object.hasOwn`, never `in`: `'toString' in {}` is true, so an `in` probe
|
|
58
80
|
// silently accepts placeholders no author declared — and render.ts would then
|
|
59
81
|
// interpolate the prototype method into the document.
|
|
82
|
+
//
|
|
83
|
+
// non-scalar-interpolation: the placeholder resolves, but to a value that has
|
|
84
|
+
// no reading as a sentence. This is what the schema's old scalar-or-list union
|
|
85
|
+
// was actually defending — `[object Object]` in the rendered requirement
|
|
86
|
+
// document — and it is stated here because that is where the damage is. A
|
|
87
|
+
// param the statement never names can be any shape it likes: a kind -> payload
|
|
88
|
+
// table the scenario reads is not a rendering problem, and refusing it in the
|
|
89
|
+
// schema refused the drift-prone value along with the display bug.
|
|
60
90
|
for (const [id, req] of Object.entries(registry)) {
|
|
61
91
|
for (const m of req.statement.matchAll(/\{(\w+)\}/g)) {
|
|
62
92
|
const name = m[1];
|
|
@@ -67,6 +97,16 @@ export function validateStructure(registry, plan) {
|
|
|
67
97
|
reqId: id,
|
|
68
98
|
message: `Requirement "${id}" uses {${name}} in its statement, but params does not define it.`,
|
|
69
99
|
});
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (!interpolatable(req.params[name])) {
|
|
103
|
+
issues.push({
|
|
104
|
+
level: 'ERROR',
|
|
105
|
+
code: 'non-scalar-interpolation',
|
|
106
|
+
reqId: id,
|
|
107
|
+
message: `Requirement "${id}" uses {${name}} in its statement, but that param is ${shapeOf(req.params[name])}. ` +
|
|
108
|
+
`A statement placeholder must be a scalar or a list of scalars.`,
|
|
109
|
+
});
|
|
70
110
|
}
|
|
71
111
|
}
|
|
72
112
|
}
|
|
@@ -135,4 +175,22 @@ export function detectPotentialDrift(registry, plan, refs) {
|
|
|
135
175
|
}
|
|
136
176
|
return issues;
|
|
137
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Whether a param has a reading as a run of words inside a sentence.
|
|
180
|
+
*
|
|
181
|
+
* A scalar does; a list of scalars does, as a comma-separated series. Anything
|
|
182
|
+
* deeper does not — `render` would have to choose a punctuation for structure,
|
|
183
|
+
* and every choice is a way for the document to say something the registry did
|
|
184
|
+
* not. `null` is a scalar here: it renders as `null`, which is the value.
|
|
185
|
+
*/
|
|
186
|
+
function interpolatable(value) {
|
|
187
|
+
const isScalar = (v) => v === null || typeof v !== 'object';
|
|
188
|
+
return Array.isArray(value) ? value.every(isScalar) : isScalar(value);
|
|
189
|
+
}
|
|
190
|
+
/** How to name the offending shape in the diagnostic, in the author's terms. */
|
|
191
|
+
function shapeOf(value) {
|
|
192
|
+
if (Array.isArray(value))
|
|
193
|
+
return 'a list with a structured element';
|
|
194
|
+
return 'an object';
|
|
195
|
+
}
|
|
138
196
|
//# sourceMappingURL=validator.js.map
|