@am_shork/attest 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1053 -172
- package/README.md +1 -1
- package/dist/cli/index.js +11 -8
- package/dist/cli/json.d.ts +26 -1
- package/dist/cli/json.js +28 -2
- package/dist/core/compiler.d.ts +32 -0
- package/dist/core/compiler.js +78 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/gate.js +3 -3
- package/dist/core/locate.d.ts +22 -2
- package/dist/core/locate.js +33 -8
- package/dist/core/parser.d.ts +12 -0
- package/dist/core/parser.js +18 -2
- package/dist/core/pipeline.d.ts +17 -1
- package/dist/core/pipeline.js +109 -17
- package/dist/core/red-record.d.ts +14 -5
- package/dist/core/red-record.js +82 -24
- package/dist/core/registry-issues.d.ts +30 -0
- package/dist/core/registry-issues.js +26 -0
- package/dist/core/registry.d.ts +12 -5
- package/dist/core/registry.js +10 -8
- package/dist/core/runner.js +21 -9
- package/dist/core/schema.d.ts +32 -27
- package/dist/core/schema.js +33 -5
- package/dist/core/skill.js +13 -0
- package/dist/core/splice.js +45 -14
- package/dist/core/static-registry.d.ts +34 -2
- package/dist/core/static-registry.js +138 -12
- package/dist/core/status.js +2 -2
- package/dist/core/terminal.js +5 -2
- package/dist/core/types.d.ts +39 -10
- package/dist/core/validator.d.ts +9 -4
- package/dist/core/validator.js +74 -21
- package/package.json +2 -2
package/dist/core/schema.d.ts
CHANGED
|
@@ -1,27 +1,17 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
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]>;
|
|
3
|
+
declare const scalar: z.ZodUnion<readonly [z.ZodNumber, z.ZodString, z.ZodBoolean, z.ZodNull]>;
|
|
4
4
|
/** Any JSON value — what a param may be. */
|
|
5
5
|
export type ParamValue = z.infer<typeof scalar> | ParamValue[] | {
|
|
6
6
|
[key: string]: ParamValue;
|
|
7
7
|
};
|
|
8
8
|
/** A single behavioural contract (design §2). */
|
|
9
9
|
export declare const RequirementSchema: z.ZodObject<{
|
|
10
|
-
statement: z.
|
|
10
|
+
statement: z.ZodString;
|
|
11
11
|
rationale: z.ZodString;
|
|
12
|
-
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, z.
|
|
13
|
-
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString
|
|
14
|
-
},
|
|
15
|
-
params: Record<string, ParamValue>;
|
|
16
|
-
statement: string;
|
|
17
|
-
rationale: string;
|
|
18
|
-
outOfScope: string[];
|
|
19
|
-
}, {
|
|
20
|
-
statement: string;
|
|
21
|
-
rationale: string;
|
|
22
|
-
params?: Record<string, ParamValue> | undefined;
|
|
23
|
-
outOfScope?: string[] | undefined;
|
|
24
|
-
}>;
|
|
12
|
+
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
|
|
13
|
+
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
14
|
+
}, z.core.$strip>;
|
|
25
15
|
/**
|
|
26
16
|
* The id grammar, on its own.
|
|
27
17
|
*
|
|
@@ -34,23 +24,38 @@ export declare const RequirementSchema: z.ZodObject<{
|
|
|
34
24
|
* two grammars as soon as one of them is edited.
|
|
35
25
|
*/
|
|
36
26
|
export declare const RequirementIdSchema: z.ZodString;
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The requirement registry: stable ID -> requirement (design §2, §5.1).
|
|
29
|
+
*
|
|
30
|
+
* Guarded by `isPlainObject` for the same reason `jsonObject` is, and it is the
|
|
31
|
+
* *evaluating* reader this protects: `{ '__proto__': { … } }` swaps the
|
|
32
|
+
* prototype rather than creating a key, so the registry a loader builds from
|
|
33
|
+
* that file comes from the prototype and not from anything the file declares.
|
|
34
|
+
* The static reader refuses such a source outright (`registry-not-static`), and
|
|
35
|
+
* the two readers agreeing is a security property (design §5.2), not a
|
|
36
|
+
* convenience — so the schema has to refuse it rather than enumerate it.
|
|
37
|
+
*
|
|
38
|
+
* The guard is written over `RegistryRecord`'s input so `z.input` still
|
|
39
|
+
* describes the authoring shape: `defineRequirements` constrains its `const`
|
|
40
|
+
* type parameter to `RegistryInput`, and a guard typed as a bare record would
|
|
41
|
+
* erase every literal that constraint exists to capture.
|
|
42
|
+
*/
|
|
43
|
+
export declare const RegistrySchema: z.ZodPipe<z.ZodCustom<Record<string, {
|
|
45
44
|
statement: string;
|
|
46
45
|
rationale: string;
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
params?: Record<string, unknown> | undefined;
|
|
47
|
+
outOfScope?: string[] | undefined;
|
|
48
|
+
}>, Record<string, {
|
|
49
49
|
statement: string;
|
|
50
50
|
rationale: string;
|
|
51
|
-
params?: Record<string,
|
|
51
|
+
params?: Record<string, unknown> | undefined;
|
|
52
52
|
outOfScope?: string[] | undefined;
|
|
53
|
-
}
|
|
53
|
+
}>>, z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
54
|
+
statement: z.ZodString;
|
|
55
|
+
rationale: z.ZodString;
|
|
56
|
+
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
|
|
57
|
+
outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
58
|
+
}, z.core.$strip>>>;
|
|
54
59
|
/** Parsed (output) shapes — defaults applied. */
|
|
55
60
|
export type Requirement = z.infer<typeof RequirementSchema>;
|
|
56
61
|
export type Registry = z.infer<typeof RegistrySchema>;
|
package/dist/core/schema.js
CHANGED
|
@@ -35,9 +35,7 @@ const jsonObject = z
|
|
|
35
35
|
.custom(isPlainObject)
|
|
36
36
|
.pipe(z.record(z.string(), z.lazy(() => paramValue)));
|
|
37
37
|
const paramValue = z.lazy(() => z.union([scalar, z.array(paramValue), jsonObject], {
|
|
38
|
-
|
|
39
|
-
message: 'expected JSON data (no functions, dates, or class instances)',
|
|
40
|
-
}),
|
|
38
|
+
error: 'expected JSON data (no functions, dates, or class instances)',
|
|
41
39
|
}));
|
|
42
40
|
/** A single behavioural contract (design §2). */
|
|
43
41
|
export const RequirementSchema = z.object({
|
|
@@ -80,6 +78,36 @@ export const RequirementSchema = z.object({
|
|
|
80
78
|
export const RequirementIdSchema = z
|
|
81
79
|
.string()
|
|
82
80
|
.regex(/^[A-Z]+-\d+$/, 'id must look like AUTH-3');
|
|
83
|
-
/**
|
|
84
|
-
|
|
81
|
+
/**
|
|
82
|
+
* The registry's own record, before the plain-object guard below.
|
|
83
|
+
*
|
|
84
|
+
* `error` is carried explicitly because a record reports a key failure as
|
|
85
|
+
* "Invalid key in record" and does not surface the key schema's own message —
|
|
86
|
+
* and a mistyped id is the commonest way to fail this schema, so the grammar is
|
|
87
|
+
* the one thing the author needs to be told.
|
|
88
|
+
*/
|
|
89
|
+
const RegistryRecord = z.record(RequirementIdSchema, RequirementSchema, {
|
|
90
|
+
error: 'id must look like AUTH-3',
|
|
91
|
+
});
|
|
92
|
+
/**
|
|
93
|
+
* The requirement registry: stable ID -> requirement (design §2, §5.1).
|
|
94
|
+
*
|
|
95
|
+
* Guarded by `isPlainObject` for the same reason `jsonObject` is, and it is the
|
|
96
|
+
* *evaluating* reader this protects: `{ '__proto__': { … } }` swaps the
|
|
97
|
+
* prototype rather than creating a key, so the registry a loader builds from
|
|
98
|
+
* that file comes from the prototype and not from anything the file declares.
|
|
99
|
+
* The static reader refuses such a source outright (`registry-not-static`), and
|
|
100
|
+
* the two readers agreeing is a security property (design §5.2), not a
|
|
101
|
+
* convenience — so the schema has to refuse it rather than enumerate it.
|
|
102
|
+
*
|
|
103
|
+
* The guard is written over `RegistryRecord`'s input so `z.input` still
|
|
104
|
+
* describes the authoring shape: `defineRequirements` constrains its `const`
|
|
105
|
+
* type parameter to `RegistryInput`, and a guard typed as a bare record would
|
|
106
|
+
* erase every literal that constraint exists to capture.
|
|
107
|
+
*/
|
|
108
|
+
export const RegistrySchema = z
|
|
109
|
+
.custom(isPlainObject, {
|
|
110
|
+
error: 'a registry must be an object literal',
|
|
111
|
+
})
|
|
112
|
+
.pipe(RegistryRecord);
|
|
85
113
|
//# sourceMappingURL=schema.js.map
|
package/dist/core/skill.js
CHANGED
|
@@ -102,6 +102,19 @@ promises is a two-stage workflow, and the stages are separate on purpose.
|
|
|
102
102
|
rule forbids, and the distinction is the whole of it — that rule is about the
|
|
103
103
|
**expectation** the system is measured against; this pin asserts what the
|
|
104
104
|
**intent** is.
|
|
105
|
+
- **Prefer a quantifier a scenario can iterate.** A statement that says *every*,
|
|
106
|
+
*all* or *any* has a finite set of scenarios under it and nothing holding the
|
|
107
|
+
two together — the id is covered, \`check\` is green, and whether those
|
|
108
|
+
scenarios span what the sentence claims is the part no gate looks at. What
|
|
109
|
+
decides the risk is *what* the quantifier ranges over. Over the inputs one
|
|
110
|
+
place processes, representative inputs settle it. Over a list the code itself
|
|
111
|
+
enumerates, a scenario looping that same list covers a new member by
|
|
112
|
+
construction — **this is the shape to write**, and when that list lives in
|
|
113
|
+
\`params\` rather than in the code, the pin above is what keeps it honest, since
|
|
114
|
+
an author can shorten it. Over *places in the implementation*, each needing its
|
|
115
|
+
own hand-written scenario, a new place is a new obligation and nothing
|
|
116
|
+
enumerates them: that is the one that silently stops being true. When you cannot avoid it, name the places in the statement instead
|
|
117
|
+
of quantifying over them, so a reader can count what is missing.
|
|
105
118
|
|
|
106
119
|
## Rules the engine enforces
|
|
107
120
|
|
package/dist/core/splice.js
CHANGED
|
@@ -25,23 +25,26 @@ import { registryInsertionPoint } from './static-registry.js';
|
|
|
25
25
|
import { toPosixPath } from './paths.js';
|
|
26
26
|
import { byCodeUnit, sortDeep } from './order.js';
|
|
27
27
|
/**
|
|
28
|
-
*
|
|
28
|
+
* The *inside* of a TypeScript string literal quoted with `quote`, holding
|
|
29
|
+
* exactly `value`.
|
|
29
30
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
31
|
+
* Split out from `tsString` because the other site that writes into a string
|
|
32
|
+
* literal — `repointImport` — writes between quotes that are already in the
|
|
33
|
+
* file, whose character is the file's choice and not this module's. Escaping
|
|
34
|
+
* has to answer to that character: `'` needs no escape inside `"…"`, and
|
|
35
|
+
* escaping it there would be a wrong byte rather than a safe one.
|
|
36
|
+
*
|
|
37
|
+
* Control characters go out as `\uXXXX` rather than raw, so a value someone
|
|
38
|
+
* pasted a newline into cannot produce a file that no longer parses.
|
|
36
39
|
*/
|
|
37
|
-
function
|
|
38
|
-
let out =
|
|
40
|
+
function tsStringBody(value, quote) {
|
|
41
|
+
let out = '';
|
|
39
42
|
for (const ch of value) {
|
|
40
43
|
const code = ch.codePointAt(0) ?? 0;
|
|
41
44
|
if (ch === '\\')
|
|
42
45
|
out += '\\\\';
|
|
43
|
-
else if (ch ===
|
|
44
|
-
out +=
|
|
46
|
+
else if (ch === quote)
|
|
47
|
+
out += `\\${ch}`;
|
|
45
48
|
else if (ch === '\n')
|
|
46
49
|
out += '\\n';
|
|
47
50
|
else if (ch === '\r')
|
|
@@ -53,7 +56,18 @@ function tsString(value) {
|
|
|
53
56
|
else
|
|
54
57
|
out += ch;
|
|
55
58
|
}
|
|
56
|
-
return
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A TypeScript single-quoted string literal holding exactly `value`.
|
|
63
|
+
*
|
|
64
|
+
* Hand-escaped rather than `JSON.stringify`, for one reason that is not style:
|
|
65
|
+
* the registries this writes into are single-quoted throughout, and a merged
|
|
66
|
+
* entry that arrives double-quoted is a diff hunk about quotation marks in the
|
|
67
|
+
* middle of a merge the reviewer is trying to read.
|
|
68
|
+
*/
|
|
69
|
+
function tsString(value) {
|
|
70
|
+
return `'${tsStringBody(value, "'")}'`;
|
|
57
71
|
}
|
|
58
72
|
/**
|
|
59
73
|
* Thrown when a value reaches the emitter that cannot be written as source
|
|
@@ -205,10 +219,27 @@ export function repointImport(file, source, from, to) {
|
|
|
205
219
|
// A bare `x.reqs.js` is a package specifier, not a sibling file.
|
|
206
220
|
if (!target.startsWith('.'))
|
|
207
221
|
target = `./${target}`;
|
|
208
|
-
// Inside the quotes
|
|
209
|
-
|
|
222
|
+
// Inside the quotes, so the file's own quote style is left exactly as it
|
|
223
|
+
// was — which is why the escaping is told which quote it writes between.
|
|
224
|
+
// `to` is the registry file `--apply` chose, making this the one value in
|
|
225
|
+
// the emitter the checked repository names, and a name carrying that quote
|
|
226
|
+
// would otherwise close the literal and put what follows into a committed
|
|
227
|
+
// `*.spec.ts` as code (§7). Escaped rather than refused, unlike the
|
|
228
|
+
// `__proto__` key above: a specifier is a string, and every string has a
|
|
229
|
+
// correct spelling as one.
|
|
230
|
+
const quote = source[spec.getStart(sf)];
|
|
231
|
+
edits.push({
|
|
232
|
+
start: spec.getStart(sf) + 1,
|
|
233
|
+
end: spec.getEnd() - 1,
|
|
234
|
+
text: tsStringBody(target, quote),
|
|
235
|
+
});
|
|
210
236
|
}
|
|
211
237
|
let out = source;
|
|
238
|
+
// Back to front, which is what makes a correction term unnecessary: an edit
|
|
239
|
+
// cannot move an offset that lies before it, so every `start`/`end` above
|
|
240
|
+
// stays valid as the string is rewritten under them. Applying these in source
|
|
241
|
+
// order works only by carrying a running delta and adding it to each
|
|
242
|
+
// subsequent pair — the same result, one more thing to get wrong.
|
|
212
243
|
for (const edit of edits.reverse()) {
|
|
213
244
|
out = out.slice(0, edit.start) + edit.text + out.slice(edit.end);
|
|
214
245
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { RegistryDelta } from './registry.js';
|
|
2
2
|
import type { Registry } from './types.js';
|
|
3
3
|
/** Codes this reader can produce; each is also produced by the evaluating path. */
|
|
4
|
-
export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static';
|
|
4
|
+
export type StaticReadCode = 'registry-invalid' | 'registry-no-default' | 'registry-not-static' | 'unreadable-file';
|
|
5
5
|
export type StaticReadResult = {
|
|
6
6
|
ok: true;
|
|
7
7
|
registry: Registry;
|
|
@@ -13,7 +13,7 @@ export type StaticReadResult = {
|
|
|
13
13
|
};
|
|
14
14
|
export declare function readRegistrySource(file: string, source: string): StaticReadResult;
|
|
15
15
|
/** Codes the delta reader can produce; both are codes `archive` already emits. */
|
|
16
|
-
export type DeltaReadCode = 'change-not-found' | 'registry-not-static';
|
|
16
|
+
export type DeltaReadCode = 'change-not-found' | 'registry-not-static' | 'unreadable-file';
|
|
17
17
|
export type DeltaReadResult = {
|
|
18
18
|
ok: true;
|
|
19
19
|
delta: RegistryDelta;
|
|
@@ -39,6 +39,38 @@ export type DeltaReadResult = {
|
|
|
39
39
|
* (write the value inline, or `--eval`) is the same sentence either way.
|
|
40
40
|
*/
|
|
41
41
|
export declare function readDeltaSource(file: string, source: string): DeltaReadResult;
|
|
42
|
+
/**
|
|
43
|
+
* The requirement ids a source *declares*, read from a file that could not be
|
|
44
|
+
* read as a registry (design §5.3).
|
|
45
|
+
*
|
|
46
|
+
* This is not a second registry reader and cannot become one: it returns ids and
|
|
47
|
+
* nothing else, it validates none of them, and no command builds a `Registry`
|
|
48
|
+
* from what it finds. What it exists for is the question `orphan-test` cannot
|
|
49
|
+
* otherwise answer — is this scenario attesting an id that a *broken* file
|
|
50
|
+
* declares, or one that genuinely does not exist — and the answer decides
|
|
51
|
+
* whether a finding is a fact or fallout.
|
|
52
|
+
*
|
|
53
|
+
* It reads the ids rather than the file's id *prefix*, which is what this was
|
|
54
|
+
* first framed as needing. The prefix cannot be recovered from a path: this
|
|
55
|
+
* repository's own registry is `attest.reqs.ts` holding `ATX-*`, which is the
|
|
56
|
+
* evidence that killed the prefix-matches-filename rule (see CHANGELOG,
|
|
57
|
+
* `Considered and rejected`). The ids are in the source, so nothing has to be
|
|
58
|
+
* inferred from a naming convention that nothing enforces.
|
|
59
|
+
*
|
|
60
|
+
* Recall is partial by construction, and the direction of the miss is the point:
|
|
61
|
+
* an id it does not find keeps its `orphan-test`, so the report stays noisy —
|
|
62
|
+
* never wrong. Measured over the failure modes that produce an unreadable
|
|
63
|
+
* registry: a truncated file, a missing default export, a non-literal *value*, a
|
|
64
|
+
* schema-invalid entry, a module that throws at import, and a spread of ids from
|
|
65
|
+
* another module all yield the ids written in this file; only a registry built
|
|
66
|
+
* by a call — `export default buildReqs()` — yields none, and that file contains
|
|
67
|
+
* no id to find.
|
|
68
|
+
*
|
|
69
|
+
* `ts.createSourceFile` is deliberately error-tolerant, which is what lets the
|
|
70
|
+
* first of those cases work at all: the parser recovers an object literal from a
|
|
71
|
+
* file that does not compile.
|
|
72
|
+
*/
|
|
73
|
+
export declare function declaredIdsFromSource(file: string, source: string): string[];
|
|
42
74
|
/**
|
|
43
75
|
* Where a new entry may be written into a registry file's literal, as an offset
|
|
44
76
|
* into its source (design §7).
|
|
@@ -20,7 +20,9 @@
|
|
|
20
20
|
// value is fixed by the source text — a second walker would be a second answer
|
|
21
21
|
// to "is this a literal", and those two answers must not be able to disagree.
|
|
22
22
|
import ts from 'typescript';
|
|
23
|
-
import {
|
|
23
|
+
import { parseSource } from './compiler.js';
|
|
24
|
+
import { withProposedRequirements } from './registry.js';
|
|
25
|
+
import { registryValidationMessage } from './registry-issues.js';
|
|
24
26
|
import { RegistrySchema } from './schema.js';
|
|
25
27
|
/** The authoring function a registry file must default-export the result of. */
|
|
26
28
|
const DEFINE = 'defineRequirements';
|
|
@@ -45,7 +47,20 @@ class NotStatic extends Error {
|
|
|
45
47
|
* has to be run — and only the prose around it differs.
|
|
46
48
|
*/
|
|
47
49
|
function extractLiteralExport(file, source, fn, noun) {
|
|
48
|
-
|
|
50
|
+
// Strict: a source that does not compile is refused here rather than read out
|
|
51
|
+
// of whatever the parser recovered from it. Before this, a registry truncated
|
|
52
|
+
// before its closing `});` came back as a complete registry.
|
|
53
|
+
const parsed = parseSource(file, source);
|
|
54
|
+
if ('error' in parsed) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
reason: 'syntax',
|
|
58
|
+
message: `This ${noun} does not compile (${parsed.error.message}), so nothing in it was checked. ` +
|
|
59
|
+
`Fix the syntax error and run again.`,
|
|
60
|
+
...(parsed.error.line === undefined ? {} : { line: parsed.error.line }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const sf = parsed.sf;
|
|
49
64
|
const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
50
65
|
const exported = defaultExportExpression(sf);
|
|
51
66
|
if (!exported) {
|
|
@@ -91,24 +106,45 @@ function failure(extracted, code) {
|
|
|
91
106
|
...(extracted.line === undefined ? {} : { line: extracted.line }),
|
|
92
107
|
};
|
|
93
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* What this reader calls the three ways extraction fails; `DELTA_CODE` below is
|
|
111
|
+
* the other half.
|
|
112
|
+
*
|
|
113
|
+
* A table per reader rather than a ternary in each, because only one row
|
|
114
|
+
* differs in kind: `syntax` is the same answer for both — a file that does not
|
|
115
|
+
* compile is not a registry problem or a delta problem, it is not a file — and
|
|
116
|
+
* two readers spelling that differently is exactly the disagreement this module
|
|
117
|
+
* opens by refusing to allow.
|
|
118
|
+
*/
|
|
119
|
+
const REGISTRY_CODE = {
|
|
120
|
+
syntax: 'unreadable-file',
|
|
121
|
+
'no-default': 'registry-no-default',
|
|
122
|
+
'not-static': 'registry-not-static',
|
|
123
|
+
};
|
|
94
124
|
export function readRegistrySource(file, source) {
|
|
95
125
|
const extracted = extractLiteralExport(file, source, DEFINE, 'registry');
|
|
96
126
|
if (!extracted.ok) {
|
|
97
|
-
return failure(extracted, extracted.reason
|
|
127
|
+
return failure(extracted, REGISTRY_CODE[extracted.reason]);
|
|
98
128
|
}
|
|
99
129
|
const result = RegistrySchema.safeParse(extracted.value);
|
|
100
130
|
if (!result.success) {
|
|
101
131
|
// defineRequirements throws RegistryValidationError and loadRegistry wraps
|
|
102
|
-
// it;
|
|
103
|
-
//
|
|
132
|
+
// it; sharing one formatter keeps the message identical rather than merely
|
|
133
|
+
// similar.
|
|
104
134
|
return {
|
|
105
135
|
ok: false,
|
|
106
136
|
code: 'registry-invalid',
|
|
107
|
-
message: `Failed to load registry: ${
|
|
137
|
+
message: `Failed to load registry: ${registryValidationMessage(result.error.issues)}`,
|
|
108
138
|
};
|
|
109
139
|
}
|
|
110
140
|
return { ok: true, registry: result.data };
|
|
111
141
|
}
|
|
142
|
+
/** The delta reader's half of the table above `readRegistrySource`. */
|
|
143
|
+
const DELTA_CODE = {
|
|
144
|
+
syntax: 'unreadable-file',
|
|
145
|
+
'no-default': 'change-not-found',
|
|
146
|
+
'not-static': 'registry-not-static',
|
|
147
|
+
};
|
|
112
148
|
/**
|
|
113
149
|
* Read a change's `delta({ … })` from its source (design §7).
|
|
114
150
|
*
|
|
@@ -127,7 +163,7 @@ export function readRegistrySource(file, source) {
|
|
|
127
163
|
export function readDeltaSource(file, source) {
|
|
128
164
|
const extracted = extractLiteralExport(file, source, DELTA, 'delta');
|
|
129
165
|
if (!extracted.ok) {
|
|
130
|
-
return failure(extracted, extracted.reason
|
|
166
|
+
return failure(extracted, DELTA_CODE[extracted.reason]);
|
|
131
167
|
}
|
|
132
168
|
const value = extracted.value;
|
|
133
169
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
@@ -148,6 +184,78 @@ export function readDeltaSource(file, source) {
|
|
|
148
184
|
// thing that would notice, and it should have nothing to notice.
|
|
149
185
|
return { ok: true, delta: withProposedRequirements(value) };
|
|
150
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* The requirement ids a source *declares*, read from a file that could not be
|
|
189
|
+
* read as a registry (design §5.3).
|
|
190
|
+
*
|
|
191
|
+
* This is not a second registry reader and cannot become one: it returns ids and
|
|
192
|
+
* nothing else, it validates none of them, and no command builds a `Registry`
|
|
193
|
+
* from what it finds. What it exists for is the question `orphan-test` cannot
|
|
194
|
+
* otherwise answer — is this scenario attesting an id that a *broken* file
|
|
195
|
+
* declares, or one that genuinely does not exist — and the answer decides
|
|
196
|
+
* whether a finding is a fact or fallout.
|
|
197
|
+
*
|
|
198
|
+
* It reads the ids rather than the file's id *prefix*, which is what this was
|
|
199
|
+
* first framed as needing. The prefix cannot be recovered from a path: this
|
|
200
|
+
* repository's own registry is `attest.reqs.ts` holding `ATX-*`, which is the
|
|
201
|
+
* evidence that killed the prefix-matches-filename rule (see CHANGELOG,
|
|
202
|
+
* `Considered and rejected`). The ids are in the source, so nothing has to be
|
|
203
|
+
* inferred from a naming convention that nothing enforces.
|
|
204
|
+
*
|
|
205
|
+
* Recall is partial by construction, and the direction of the miss is the point:
|
|
206
|
+
* an id it does not find keeps its `orphan-test`, so the report stays noisy —
|
|
207
|
+
* never wrong. Measured over the failure modes that produce an unreadable
|
|
208
|
+
* registry: a truncated file, a missing default export, a non-literal *value*, a
|
|
209
|
+
* schema-invalid entry, a module that throws at import, and a spread of ids from
|
|
210
|
+
* another module all yield the ids written in this file; only a registry built
|
|
211
|
+
* by a call — `export default buildReqs()` — yields none, and that file contains
|
|
212
|
+
* no id to find.
|
|
213
|
+
*
|
|
214
|
+
* `ts.createSourceFile` is deliberately error-tolerant, which is what lets the
|
|
215
|
+
* first of those cases work at all: the parser recovers an object literal from a
|
|
216
|
+
* file that does not compile.
|
|
217
|
+
*/
|
|
218
|
+
export function declaredIdsFromSource(file, source) {
|
|
219
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
|
|
220
|
+
const names = localNames(sf, DEFINE);
|
|
221
|
+
const ids = new Set();
|
|
222
|
+
// Any `defineRequirements({ … })` in the file, not only the exported one: the
|
|
223
|
+
// failure being diagnosed is often that the call is not where it should be —
|
|
224
|
+
// `export const registry = …` is a whole failure mode — and the ids are no
|
|
225
|
+
// less declared for it. Scoped to that call's argument all the same, so an
|
|
226
|
+
// id-shaped key in unrelated data is not mistaken for a requirement.
|
|
227
|
+
const visit = (node) => {
|
|
228
|
+
// `arguments.length > 0` rather than `=== 1`, which is what the reader
|
|
229
|
+
// demands: this runs on files the reader has already refused, so being
|
|
230
|
+
// stricter than it here could only lose ids it is not deciding anything by.
|
|
231
|
+
if (ts.isCallExpression(node) && node.arguments.length > 0 && callsAuthoringFn(node, names, DEFINE)) {
|
|
232
|
+
const arg = unwrap(node.arguments[0]);
|
|
233
|
+
if (ts.isObjectLiteralExpression(arg)) {
|
|
234
|
+
for (const prop of arg.properties) {
|
|
235
|
+
const name = prop.name;
|
|
236
|
+
if (!name)
|
|
237
|
+
continue;
|
|
238
|
+
if (!ts.isStringLiteral(name) && !ts.isIdentifier(name))
|
|
239
|
+
continue;
|
|
240
|
+
if (REQUIREMENT_ID.test(name.text))
|
|
241
|
+
ids.add(name.text);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
ts.forEachChild(node, visit);
|
|
246
|
+
};
|
|
247
|
+
visit(sf);
|
|
248
|
+
return [...ids];
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* The id shape, as a bare regex rather than through `RequirementIdSchema`.
|
|
252
|
+
*
|
|
253
|
+
* Nothing is being validated here — a key that does not look like an id is
|
|
254
|
+
* simply not one, and there is no author to tell. Kept identical to the schema's
|
|
255
|
+
* pattern by `tests/static-registry.spec.ts`, which is the only place the two
|
|
256
|
+
* spellings can be held together.
|
|
257
|
+
*/
|
|
258
|
+
const REQUIREMENT_ID = /^[A-Z]+-\d+$/;
|
|
151
259
|
/**
|
|
152
260
|
* The expression a file default-exports, following one level of local binding.
|
|
153
261
|
*
|
|
@@ -191,10 +299,20 @@ function constInitializer(sf, name) {
|
|
|
191
299
|
function authoringCall(expr, sf, fn) {
|
|
192
300
|
if (!ts.isCallExpression(expr) || expr.arguments.length !== 1)
|
|
193
301
|
return undefined;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
302
|
+
return callsAuthoringFn(expr, localNames(sf, fn), fn) ? expr.arguments[0] : undefined;
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Whether a call is a call of `fn`, by the local names the imports bound to it.
|
|
306
|
+
*
|
|
307
|
+
* One predicate for both readers of this question — the extraction above and the
|
|
308
|
+
* id recovery below — because they must not be able to disagree about what
|
|
309
|
+
* counts as the authoring call. An alias rule taught to one and not the other
|
|
310
|
+
* would make recovery silently miss exactly the files the reader refuses.
|
|
311
|
+
*/
|
|
312
|
+
function callsAuthoringFn(call, names, fn) {
|
|
313
|
+
const callee = call.expression;
|
|
314
|
+
return ((ts.isIdentifier(callee) && names.has(callee.text)) ||
|
|
315
|
+
(ts.isPropertyAccessExpression(callee) && callee.name.text === fn));
|
|
198
316
|
}
|
|
199
317
|
/** Local names bound to the imported `fn`, including aliases. */
|
|
200
318
|
function localNames(sf, fn) {
|
|
@@ -329,7 +447,15 @@ function numericValue(node) {
|
|
|
329
447
|
return value;
|
|
330
448
|
}
|
|
331
449
|
export function registryInsertionPoint(file, source) {
|
|
332
|
-
|
|
450
|
+
// The write site, and the one where a recovered AST does damage rather than
|
|
451
|
+
// merely misreports: an offset taken from a file that does not compile would
|
|
452
|
+
// splice a new requirement into it. `undefined` is this function's documented
|
|
453
|
+
// refusal to guess, and a source that will not parse is the clearest case of
|
|
454
|
+
// one there is.
|
|
455
|
+
const parsed = parseSource(file, source);
|
|
456
|
+
if ('error' in parsed)
|
|
457
|
+
return undefined;
|
|
458
|
+
const sf = parsed.sf;
|
|
333
459
|
const exported = defaultExportExpression(sf);
|
|
334
460
|
if (!exported)
|
|
335
461
|
return undefined;
|
package/dist/core/status.js
CHANGED
|
@@ -49,7 +49,7 @@ export function statusRows(addedIds, plan, firstRun) {
|
|
|
49
49
|
name: s.name,
|
|
50
50
|
file: s.file,
|
|
51
51
|
line: s.line,
|
|
52
|
-
firstRun: recordedOutcome(firstRun, reqId, s.name) ?? null,
|
|
52
|
+
firstRun: recordedOutcome(firstRun, { reqId, file: s.file, name: s.name }) ?? null,
|
|
53
53
|
}));
|
|
54
54
|
return { reqId, state: obligationState(reqId, scenarios, firstRun), scenarios };
|
|
55
55
|
});
|
|
@@ -59,7 +59,7 @@ function obligationState(reqId, scenarios, firstRun) {
|
|
|
59
59
|
return 'no-scenario';
|
|
60
60
|
// Every scenario, not any: the gate raises never-red per scenario, so one
|
|
61
61
|
// proven scenario beside an unproven one is still a blocked change.
|
|
62
|
-
return scenarios.every((s) => hasRecordedRed(firstRun, reqId, s.name))
|
|
62
|
+
return scenarios.every((s) => hasRecordedRed(firstRun, { reqId, file: s.file, name: s.name }))
|
|
63
63
|
? 'proven'
|
|
64
64
|
: 'unproven';
|
|
65
65
|
}
|
package/dist/core/terminal.js
CHANGED
|
@@ -12,8 +12,11 @@
|
|
|
12
12
|
// write to the terminal of whoever is checking it. On a fork MR the author is
|
|
13
13
|
// not the reviewer, and the payload is a scenario name or an exception: erase
|
|
14
14
|
// the lines above, repaint a red verdict green, rewrite the window title. The
|
|
15
|
-
// `--json` path was never exposed
|
|
16
|
-
//
|
|
15
|
+
// `--json` path was recorded here as never exposed, on the grounds that
|
|
16
|
+
// `JSON.stringify` escapes every C0 character — true, and narrower than the
|
|
17
|
+
// class below, which also holds DEL and C1. `cli/json.ts` now applies the same
|
|
18
|
+
// rule at the point that document is serialised (ATX-74); this still has to
|
|
19
|
+
// hold independently, because a stream has no such point.
|
|
17
20
|
//
|
|
18
21
|
// **In `core/` rather than in the CLI**, though the CLI is its main caller: the
|
|
19
22
|
// loader has to sanitise Vite's log output for the same reason and cannot
|
package/dist/core/types.d.ts
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
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
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* What identifies one scenario, everywhere the engine compares one (design §5.4).
|
|
6
|
+
*
|
|
7
|
+
* All three, and as a bundle rather than three positional strings: the file was
|
|
8
|
+
* missing from the key that `declared-not-run` and the first-run record are
|
|
9
|
+
* decided by, and adding it as a fourth `string` argument beside `reqId` and
|
|
10
|
+
* `name` would have made two adjacent parameters of one type that a caller can
|
|
11
|
+
* silently transpose. The compiler cannot catch that; a field name can.
|
|
12
|
+
*/
|
|
13
|
+
export interface ScenarioRef {
|
|
6
14
|
reqId: string;
|
|
7
|
-
name: string;
|
|
8
15
|
file: string;
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
/** A scenario extracted statically from a spec file (design §5.2). */
|
|
19
|
+
export interface ParsedScenario extends ScenarioRef {
|
|
9
20
|
line: number;
|
|
10
21
|
}
|
|
11
22
|
/**
|
|
@@ -31,15 +42,33 @@ export type Outcome = 'pass' | 'fail';
|
|
|
31
42
|
export interface RunResult {
|
|
32
43
|
/** True when no test failed. */
|
|
33
44
|
passed: boolean;
|
|
34
|
-
/** reqId -> set of scenario names that actually executed (from the task tree). */
|
|
35
|
-
runtimeCoverage: Map<string, Set<string>>;
|
|
36
45
|
/**
|
|
37
|
-
* reqId ->
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
46
|
+
* reqId -> spec file -> the scenario names that actually executed (from the
|
|
47
|
+
* task tree).
|
|
48
|
+
*
|
|
49
|
+
* **The file is part of the key, and its absence was a false green.** Keyed by
|
|
50
|
+
* `(reqId, name)` alone, two spec files declaring the same scenario name under
|
|
51
|
+
* the same `requirement()` are one entry, so whichever of them executed
|
|
52
|
+
* vouched for the other and `declared-not-run` stayed silent about a scenario
|
|
53
|
+
* that never ran — the one report this tool exists to refuse. The static plan
|
|
54
|
+
* carries `file` on every `ParsedScenario` and the task tree carries it on
|
|
55
|
+
* every file task, so only this map ever forgot it.
|
|
56
|
+
*
|
|
57
|
+
* Project-relative POSIX, like `unloadedFiles` and for the same reason: it is
|
|
58
|
+
* compared against a plan built that way (`paths.ts`).
|
|
59
|
+
*/
|
|
60
|
+
runtimeCoverage: Map<string, Map<string, Set<string>>>;
|
|
61
|
+
/**
|
|
62
|
+
* reqId -> spec file -> scenario name -> how it ended. The same task-tree walk
|
|
63
|
+
* that fills `runtimeCoverage` already had to tell `pass` from `fail` to
|
|
64
|
+
* decide whether a scenario executed at all; this keeps that distinction
|
|
65
|
+
* instead of discarding it, which is what §6's mechanism 2 records.
|
|
66
|
+
*
|
|
67
|
+
* Keyed identically, and for the identical reason: the same collapse reaches
|
|
68
|
+
* `mergeRedRecord`, where a base scenario's recorded `fail` satisfied a
|
|
69
|
+
* same-named proposed scenario's first-red obligation.
|
|
41
70
|
*/
|
|
42
|
-
outcomes: Map<string, Map<string, Outcome
|
|
71
|
+
outcomes: Map<string, Map<string, Map<string, Outcome>>>;
|
|
43
72
|
/**
|
|
44
73
|
* Spec files that failed to *load*, as project-relative POSIX paths.
|
|
45
74
|
*
|
package/dist/core/validator.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { UnreadableRegistry } from './locate.js';
|
|
1
2
|
import type { AttestPlan, Issue, ParamRef, Registry } from './types.js';
|
|
2
3
|
/**
|
|
3
4
|
* Uncovered requirements: intent exists but no scenario attests it (design §5.3).
|
|
@@ -20,11 +21,15 @@ export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): I
|
|
|
20
21
|
* and one WARNING:
|
|
21
22
|
* - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* and one more WARNING, which exists only when a registry file failed to load:
|
|
25
|
+
* - orphan-from-failed-registry: the scenarios attesting ids that file declares
|
|
26
|
+
*
|
|
27
|
+
* `unreadable` is the `*.reqs.ts` files that failed to load, each with the ids
|
|
28
|
+
* its source still names (see `loadRegistry`). It changes no verdict — only
|
|
29
|
+
* which findings are stated per scenario and which are stated once, for the
|
|
30
|
+
* reason below.
|
|
26
31
|
*/
|
|
27
|
-
export declare function validateStructure(registry: Registry, plan: AttestPlan,
|
|
32
|
+
export declare function validateStructure(registry: Registry, plan: AttestPlan, unreadable?: readonly UnreadableRegistry[]): Issue[];
|
|
28
33
|
/**
|
|
29
34
|
* Weak anti-drift heuristic (design §6, mechanism 3). Evaluated per requirement,
|
|
30
35
|
* not per scenario: a requirement that owns params is quiet as soon as *any* one
|