@am_shork/attest 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +835 -225
- package/README.md +9 -1
- package/dist/cli/index.js +8 -4
- package/dist/cli/report.d.ts +20 -0
- package/dist/cli/report.js +25 -51
- package/dist/core/apply.js +49 -1
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/loader.d.ts +22 -0
- package/dist/core/loader.js +35 -1
- package/dist/core/pipeline.js +2 -3
- package/dist/core/red-record.d.ts +9 -1
- package/dist/core/red-record.js +67 -9
- package/dist/core/runner.d.ts +11 -0
- package/dist/core/runner.js +12 -1
- package/dist/core/schema.d.ts +12 -0
- package/dist/core/schema.js +15 -1
- package/dist/core/skill.js +13 -2
- package/dist/core/terminal.d.ts +24 -0
- package/dist/core/terminal.js +59 -0
- package/dist/core/write.js +37 -5
- package/package.json +5 -3
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/json.d.ts.map +0 -1
- package/dist/cli/json.js.map +0 -1
- package/dist/cli/report.d.ts.map +0 -1
- package/dist/cli/report.js.map +0 -1
- package/dist/core/apply.d.ts.map +0 -1
- package/dist/core/apply.js.map +0 -1
- package/dist/core/docs.d.ts.map +0 -1
- package/dist/core/docs.js.map +0 -1
- package/dist/core/gate.d.ts.map +0 -1
- package/dist/core/gate.js.map +0 -1
- package/dist/core/loader.d.ts.map +0 -1
- package/dist/core/loader.js.map +0 -1
- package/dist/core/locate.d.ts.map +0 -1
- package/dist/core/locate.js.map +0 -1
- package/dist/core/order.d.ts.map +0 -1
- package/dist/core/order.js.map +0 -1
- package/dist/core/parser.d.ts.map +0 -1
- package/dist/core/parser.js.map +0 -1
- package/dist/core/paths.d.ts.map +0 -1
- package/dist/core/paths.js.map +0 -1
- package/dist/core/pipeline.d.ts.map +0 -1
- package/dist/core/pipeline.js.map +0 -1
- package/dist/core/red-record.d.ts.map +0 -1
- package/dist/core/red-record.js.map +0 -1
- package/dist/core/registry.d.ts.map +0 -1
- package/dist/core/registry.js.map +0 -1
- package/dist/core/render.d.ts.map +0 -1
- package/dist/core/render.js.map +0 -1
- package/dist/core/runner.d.ts.map +0 -1
- package/dist/core/runner.js.map +0 -1
- package/dist/core/schema.d.ts.map +0 -1
- package/dist/core/schema.js.map +0 -1
- package/dist/core/skill.d.ts.map +0 -1
- package/dist/core/skill.js.map +0 -1
- package/dist/core/static-registry.d.ts.map +0 -1
- package/dist/core/static-registry.js.map +0 -1
- package/dist/core/status.d.ts.map +0 -1
- package/dist/core/status.js.map +0 -1
- package/dist/core/targets.d.ts.map +0 -1
- package/dist/core/targets.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/validator.d.ts.map +0 -1
- package/dist/core/validator.js.map +0 -1
- package/dist/core/version.d.ts.map +0 -1
- package/dist/core/version.js.map +0 -1
- package/dist/core/write.d.ts.map +0 -1
- package/dist/core/write.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/runtime.d.ts.map +0 -1
- package/dist/runtime.js.map +0 -1
package/README.md
CHANGED
|
@@ -32,6 +32,14 @@ narrowed by 4, and ATX-36 — reading a registry opens no listening socket — i
|
|
|
32
32
|
asserted against the Vite that is installed, so on any other major it is a claim
|
|
33
33
|
rather than a measurement. If you are on Vitest 2 or 3, stay on `0.3.x`.
|
|
34
34
|
|
|
35
|
+
The TypeScript compiler is a **bundled dependency**, not a peer — Attest reads
|
|
36
|
+
your registries and specs through the compiler API, and `typescript@7` no longer
|
|
37
|
+
exposes one (its AST moved behind `typescript/unstable/*`). So the supported
|
|
38
|
+
range is `^5.5.0 || ^6.0.0`, both ends run in CI, and your own compiler is not
|
|
39
|
+
involved: on a project already using TypeScript 5 or 6 the two resolve to one
|
|
40
|
+
copy, and on TypeScript 7 you will simply have a second one that only Attest
|
|
41
|
+
uses. Your project's TypeScript version is yours to choose either way.
|
|
42
|
+
|
|
35
43
|
## Getting started
|
|
36
44
|
|
|
37
45
|
**Install** (the framework plus its `vitest` + `vite` peers):
|
|
@@ -128,7 +136,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
128
136
|
```
|
|
129
137
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
130
138
|
Value is not a literal.
|
|
131
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.4.
|
|
139
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.4.2/docs/en/troubleshooting.md#registry-not-static
|
|
132
140
|
```
|
|
133
141
|
|
|
134
142
|
The anchor **is** the code, so the link cannot point somewhere the section
|
package/dist/cli/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { resolve } from 'node:path';
|
|
|
13
13
|
import { writeAtomic } from '../core/write.js';
|
|
14
14
|
import { runCheck, runVerify, runCover, runArchive, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
|
|
15
15
|
import { hasError } from '../core/types.js';
|
|
16
|
-
import { formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
|
|
16
|
+
import { formatCrash, formatIssues, formatScope, summarize, formatCoverage, formatStatus, } from './report.js';
|
|
17
17
|
import { archiveReport, checkReport, coverReport, errorReport, initReport, renderJson, renderReport, statusReport, verifyReport, } from './json.js';
|
|
18
18
|
import { packageVersion } from '../core/version.js';
|
|
19
19
|
const VERSION = packageVersion();
|
|
@@ -60,10 +60,12 @@ async function runAction(command, opts, action) {
|
|
|
60
60
|
}
|
|
61
61
|
catch (err) {
|
|
62
62
|
if (opts.json) {
|
|
63
|
+
// Unsanitised on purpose: `JSON.stringify` escapes every C0 character, so
|
|
64
|
+
// the machine surface was never the exposed one (see report.ts).
|
|
63
65
|
console.log(renderJson(errorReport(VERSION, command, err)));
|
|
64
66
|
}
|
|
65
67
|
else {
|
|
66
|
-
console.error(
|
|
68
|
+
console.error(formatCrash(err));
|
|
67
69
|
}
|
|
68
70
|
process.exitCode = 1;
|
|
69
71
|
}
|
|
@@ -284,8 +286,10 @@ program
|
|
|
284
286
|
}));
|
|
285
287
|
program.parseAsync().catch((err) => {
|
|
286
288
|
// Last resort for failures outside a command action (e.g. arg parsing), which
|
|
287
|
-
// are not part of the per-command `--json` contract.
|
|
288
|
-
|
|
289
|
+
// are not part of the per-command `--json` contract. Through the same
|
|
290
|
+
// formatter as the per-command path: this one is reached with a project root
|
|
291
|
+
// already resolved, so it is no less able to carry the repository's text.
|
|
292
|
+
console.error(formatCrash(err));
|
|
289
293
|
process.exitCode = 1;
|
|
290
294
|
});
|
|
291
295
|
//# sourceMappingURL=index.js.map
|
package/dist/cli/report.d.ts
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import type { CoverageRow, StatusResult, VerifyCounts } from '../core/pipeline.js';
|
|
2
2
|
import type { Issue } from '../core/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* A caught error, as it may be written to a terminal.
|
|
5
|
+
*
|
|
6
|
+
* The crash path was the hole in the guarantee above, and the least obvious
|
|
7
|
+
* place for one: every diagnostic went through `block`, and then the two
|
|
8
|
+
* `catch` blocks in the CLI printed `err.stack` raw. Under `verify`, `archive`
|
|
9
|
+
* or `--eval` that stack is not Attest's prose — a module from the repository
|
|
10
|
+
* under test threw it, so its message is that repository's text, arriving at
|
|
11
|
+
* the reviewer's terminal with nothing removed. The path a hostile registry
|
|
12
|
+
* reaches most easily is the one where it throws.
|
|
13
|
+
*
|
|
14
|
+
* `block`, not a rule of its own: a stack is exactly what that function is for
|
|
15
|
+
* — a multi-line message whose continuations must not be able to occupy the
|
|
16
|
+
* column a real header sits in. Frames indent by four more spaces than they
|
|
17
|
+
* already do, which is the whole cost.
|
|
18
|
+
*
|
|
19
|
+
* Here rather than in the CLI because this file is where the decision lives.
|
|
20
|
+
* Two `catch` blocks with a copy each is how the first one came to be missed.
|
|
21
|
+
*/
|
|
22
|
+
export declare function formatCrash(err: unknown): string;
|
|
3
23
|
export declare function formatIssues(issues: Issue[]): string;
|
|
4
24
|
/**
|
|
5
25
|
* A code repeating across files is one rule and many sites, and a reader looking
|
package/dist/cli/report.js
CHANGED
|
@@ -3,63 +3,15 @@
|
|
|
3
3
|
import chalk from 'chalk';
|
|
4
4
|
import { docsUrl } from '../core/docs.js';
|
|
5
5
|
import { byCodeUnit } from '../core/order.js';
|
|
6
|
+
import { INDENT, block, inline } from '../core/terminal.js';
|
|
6
7
|
const LEVEL_TAG = {
|
|
7
8
|
ERROR: (s) => chalk.red.bold(s),
|
|
8
9
|
WARNING: (s) => chalk.yellow.bold(s),
|
|
9
10
|
INFO: (s) => chalk.blueBright(s),
|
|
10
11
|
};
|
|
11
12
|
const ORDER = ['ERROR', 'WARNING', 'INFO'];
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/** How far a message is indented under its header line. */
|
|
15
|
-
const INDENT = ' ';
|
|
16
|
-
/**
|
|
17
|
-
* Text from the project, on its way to a terminal.
|
|
18
|
-
*
|
|
19
|
-
* Most of what a report prints is Attest's own prose, but the parts a reader
|
|
20
|
-
* actually navigates by are not: a scenario name and a file path are quoted
|
|
21
|
-
* verbatim out of the repository under test (`orphan-test`, `declared-not-run`,
|
|
22
|
-
* `never-red`), and the parser hands back the *cooked* string, so an ESC
|
|
23
|
-
* written as an escape in a spec file is a real control byte by the time it
|
|
24
|
-
* arrives here.
|
|
25
|
-
*
|
|
26
|
-
* That makes the human report a place where the repository being checked can
|
|
27
|
-
* write to the terminal of whoever is checking it. On a fork MR the author is
|
|
28
|
-
* not the reviewer, and the payload is a scenario name: erase the lines above,
|
|
29
|
-
* repaint a red verdict green, rewrite the window title. The `--json` path was
|
|
30
|
-
* never exposed — `JSON.stringify` escapes every C0 character — which is
|
|
31
|
-
* precisely why this had to be fixed on the side people read.
|
|
32
|
-
*
|
|
33
|
-
* A newline survives, because one diagnostic is legitimately multi-line:
|
|
34
|
-
* `registry-invalid` carries Zod's list of field errors, and flattening it
|
|
35
|
-
* would cost a real reader a real thing to save a hypothetical one. It is
|
|
36
|
-
* re-indented instead, so injected text lands inside the message block rather
|
|
37
|
-
* than at column 0 where a forged `ERROR ...` header would be indistinguishable
|
|
38
|
-
* from a real one.
|
|
39
|
-
*/
|
|
40
|
-
function block(text) {
|
|
41
|
-
return control(text).replaceAll('\n', `\n${INDENT}`);
|
|
42
|
-
}
|
|
43
|
-
/** The same, for text that sits inline in a header and must stay one line. */
|
|
44
|
-
function inline(text) {
|
|
45
|
-
return control(text).replaceAll('\n', ' ');
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Every C0 control except the newline, plus DEL and the C1 range, as a space.
|
|
49
|
-
*
|
|
50
|
-
* A space rather than deletion: removing the byte would silently splice
|
|
51
|
-
* `atte` + `st` into a word that was never in the file, and a diagnostic that
|
|
52
|
-
* quietly rewrites what it quotes is its own kind of wrong.
|
|
53
|
-
*/
|
|
54
|
-
function control(text) {
|
|
55
|
-
return [...text]
|
|
56
|
-
.map((ch) => {
|
|
57
|
-
const c = ch.codePointAt(0);
|
|
58
|
-
const isControl = (c < 0x20 && c !== NEWLINE) || (c >= 0x7f && c <= 0x9f);
|
|
59
|
-
return isControl ? ' ' : ch;
|
|
60
|
-
})
|
|
61
|
-
.join('');
|
|
62
|
-
}
|
|
13
|
+
// `block` / `inline` live in core/terminal.ts: the loader has to make the same
|
|
14
|
+
// decision about Vite's log output and cannot import from this layer.
|
|
63
15
|
/**
|
|
64
16
|
* The line under a message pointing at the section that explains the code.
|
|
65
17
|
*
|
|
@@ -73,6 +25,28 @@ function docsLine(issue) {
|
|
|
73
25
|
const url = issue.docsUrl ?? docsUrl(issue.code);
|
|
74
26
|
return url ? `\n${INDENT}${chalk.dim(`→ ${url}`)}` : '';
|
|
75
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* A caught error, as it may be written to a terminal.
|
|
30
|
+
*
|
|
31
|
+
* The crash path was the hole in the guarantee above, and the least obvious
|
|
32
|
+
* place for one: every diagnostic went through `block`, and then the two
|
|
33
|
+
* `catch` blocks in the CLI printed `err.stack` raw. Under `verify`, `archive`
|
|
34
|
+
* or `--eval` that stack is not Attest's prose — a module from the repository
|
|
35
|
+
* under test threw it, so its message is that repository's text, arriving at
|
|
36
|
+
* the reviewer's terminal with nothing removed. The path a hostile registry
|
|
37
|
+
* reaches most easily is the one where it throws.
|
|
38
|
+
*
|
|
39
|
+
* `block`, not a rule of its own: a stack is exactly what that function is for
|
|
40
|
+
* — a multi-line message whose continuations must not be able to occupy the
|
|
41
|
+
* column a real header sits in. Frames indent by four more spaces than they
|
|
42
|
+
* already do, which is the whole cost.
|
|
43
|
+
*
|
|
44
|
+
* Here rather than in the CLI because this file is where the decision lives.
|
|
45
|
+
* Two `catch` blocks with a copy each is how the first one came to be missed.
|
|
46
|
+
*/
|
|
47
|
+
export function formatCrash(err) {
|
|
48
|
+
return chalk.red(block(err instanceof Error ? err.stack ?? err.message : String(err)));
|
|
49
|
+
}
|
|
76
50
|
export function formatIssues(issues) {
|
|
77
51
|
if (issues.length === 0)
|
|
78
52
|
return chalk.green('✓ No issues.');
|
package/dist/core/apply.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// OpenSpec's specs-apply: RENAMED -> REMOVED -> MODIFIED -> ADDED over a
|
|
3
3
|
// Map<id, Requirement>, with content-compare on ADDED and already-synced
|
|
4
4
|
// no-ops on RENAMED.
|
|
5
|
-
import { RequirementSchema } from './schema.js';
|
|
5
|
+
import { RequirementIdSchema, RequirementSchema } from './schema.js';
|
|
6
6
|
import { byCodeUnit } from './order.js';
|
|
7
7
|
/**
|
|
8
8
|
* The ids a delta ADDs — the scope of the first-red obligation (design §6).
|
|
@@ -24,6 +24,18 @@ export function applyDelta(base, d) {
|
|
|
24
24
|
const issues = [];
|
|
25
25
|
// 1) RENAMED
|
|
26
26
|
for (const { from, to } of d.renamed ?? []) {
|
|
27
|
+
// The target before anything else, because it is an id this delta
|
|
28
|
+
// *introduces* — the same thing an ADDED key is, and held to the same
|
|
29
|
+
// grammar by the same function. Checked ahead of the source so a delta
|
|
30
|
+
// naming an impossible target is diagnosed as that, rather than as whatever
|
|
31
|
+
// the source happens to be: `rename-source-missing` would send its reader to
|
|
32
|
+
// look for a requirement when the thing to fix is on the other side of the
|
|
33
|
+
// arrow.
|
|
34
|
+
const badTarget = introducedIdIssue(to, 'rename-target-invalid', `Rename target "${to}"`);
|
|
35
|
+
if (badTarget) {
|
|
36
|
+
issues.push(badTarget);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
27
39
|
if (map.has(from)) {
|
|
28
40
|
if (map.has(to)) {
|
|
29
41
|
issues.push(err('rename-target-exists', `Rename "${from}" -> "${to}" failed: "${to}" already exists.`, to));
|
|
@@ -64,6 +76,12 @@ export function applyDelta(base, d) {
|
|
|
64
76
|
}
|
|
65
77
|
// 4) ADDED (content-compare on collision)
|
|
66
78
|
for (const [id, value] of Object.entries(d.added ?? {})) {
|
|
79
|
+
// The id, before the requirement. See `introducedIdIssue`.
|
|
80
|
+
const badId = introducedIdIssue(id, 'add-invalid', `Added requirement "${id}"`);
|
|
81
|
+
if (badId) {
|
|
82
|
+
issues.push(badId);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
67
85
|
const parsed = RequirementSchema.safeParse(value);
|
|
68
86
|
if (!parsed.success) {
|
|
69
87
|
issues.push(err('add-invalid', `Added requirement "${id}" is invalid: ${firstMessage(parsed.error)}`, id));
|
|
@@ -81,6 +99,36 @@ export function applyDelta(base, d) {
|
|
|
81
99
|
}
|
|
82
100
|
return { registry: Object.fromEntries(map), issues };
|
|
83
101
|
}
|
|
102
|
+
/**
|
|
103
|
+
* The one place that decides whether a delta may bring an id into the registry.
|
|
104
|
+
*
|
|
105
|
+
* A delta introduces an id in exactly two ways — as an ADDED key, and as the
|
|
106
|
+
* target of a RENAME — and neither was checked: `applyDelta` validated the
|
|
107
|
+
* requirement a delta adds and never the key it files it under, so the grammar
|
|
108
|
+
* `defineRequirements` holds every registry key to did not apply on the one
|
|
109
|
+
* other path that also creates them. The mild form is an inconsistency, and an
|
|
110
|
+
* expensive one: a change could archive under an id nobody could then write
|
|
111
|
+
* into the `*.reqs.ts` the change is supposed to merge into, so the gate passed
|
|
112
|
+
* on an end state the intent layer cannot represent.
|
|
113
|
+
*
|
|
114
|
+
* One function rather than a check at each site, for the reason `hasError` is
|
|
115
|
+
* one function and `declaredNotRunIssues` was extracted from the two commands
|
|
116
|
+
* that had a copy each: this is a single rule about a single thing, and two
|
|
117
|
+
* spellings of a grammar are two grammars the moment one of them is edited. The
|
|
118
|
+
* `code` and the noun differ because the reader's next move does — one sends
|
|
119
|
+
* them to a key in `added`, the other to the right-hand side of an arrow — and
|
|
120
|
+
* that is the whole of the difference.
|
|
121
|
+
*
|
|
122
|
+
* `prefix` is the caller's, not built here: it is the part that names the site,
|
|
123
|
+
* and composing it from a noun and an id would put the message's shape in the
|
|
124
|
+
* one place that must not care which site it is serving.
|
|
125
|
+
*/
|
|
126
|
+
function introducedIdIssue(id, code, prefix) {
|
|
127
|
+
const parsed = RequirementIdSchema.safeParse(id);
|
|
128
|
+
if (parsed.success)
|
|
129
|
+
return undefined;
|
|
130
|
+
return err(code, `${prefix} is invalid: ${firstMessage(parsed.error)}`, id);
|
|
131
|
+
}
|
|
84
132
|
function err(code, message, reqId) {
|
|
85
133
|
return { level: 'ERROR', code, ...(reqId ? { reqId } : {}), message };
|
|
86
134
|
}
|
package/dist/core/docs.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and the `##` headings of both language documents, so landing here cannot
|
|
9
9
|
* produce a dead link.
|
|
10
10
|
*/
|
|
11
|
-
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "change-not-found", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "orphan-test", "possible-drift", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
|
|
11
|
+
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "change-not-found", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "orphan-test", "possible-drift", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
|
|
12
12
|
export type IssueCode = (typeof ISSUE_CODES)[number];
|
|
13
13
|
/**
|
|
14
14
|
* The page explaining `code`, or `undefined` when nothing explains it.
|
package/dist/core/docs.js
CHANGED
package/dist/core/loader.d.ts
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
import { type Logger } from 'vite';
|
|
2
|
+
/**
|
|
3
|
+
* Vite's logger, with everything it prints put through Attest's sanitiser
|
|
4
|
+
* (ATX-37).
|
|
5
|
+
*
|
|
6
|
+
* `logLevel: 'error'` silences the chatter and not the errors, and an error
|
|
7
|
+
* here is the one message guaranteed to carry the project's own text: when a
|
|
8
|
+
* `*.reqs.ts` throws, Vite prints `Error when evaluating SSR module …: <the
|
|
9
|
+
* message>` straight to stderr, before Attest ever sees the exception. So the
|
|
10
|
+
* `registry-invalid` diagnostic built from that same error was sanitised while
|
|
11
|
+
* the raw bytes had already gone past it — a forged `ERROR` header at column 0
|
|
12
|
+
* and a `\r` to erase the line above, from a file the reviewer only ran.
|
|
13
|
+
*
|
|
14
|
+
* The whole message is treated as untrusted, which costs Vite its own colour:
|
|
15
|
+
* by the time it arrives the project's text has already been interpolated into
|
|
16
|
+
* it, so there is no half to trust. Losing colour on the one path where a
|
|
17
|
+
* registry threw is not a price worth arguing about.
|
|
18
|
+
*
|
|
19
|
+
* Exported for its own scenario. The logger a live server holds is not
|
|
20
|
+
* reachable from outside, and a guarantee nothing can fail is not a guarantee.
|
|
21
|
+
*/
|
|
22
|
+
export declare function sanitisedLogger(base?: Logger): Logger;
|
|
1
23
|
export interface Loader {
|
|
2
24
|
load<T = unknown>(absPath: string): Promise<T>;
|
|
3
25
|
close(): Promise<void>;
|
package/dist/core/loader.js
CHANGED
|
@@ -6,11 +6,41 @@
|
|
|
6
6
|
// requirement()/scenario(), but the `attest` barrel transitively imports
|
|
7
7
|
// runtime.ts (which imports vitest), and importing vitest outside a test run
|
|
8
8
|
// throws. The stub sidesteps that without affecting any read value.
|
|
9
|
-
import { createServer } from 'vite';
|
|
9
|
+
import { createLogger, createServer } from 'vite';
|
|
10
10
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { rm } from 'node:fs/promises';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { block } from './terminal.js';
|
|
15
|
+
/**
|
|
16
|
+
* Vite's logger, with everything it prints put through Attest's sanitiser
|
|
17
|
+
* (ATX-37).
|
|
18
|
+
*
|
|
19
|
+
* `logLevel: 'error'` silences the chatter and not the errors, and an error
|
|
20
|
+
* here is the one message guaranteed to carry the project's own text: when a
|
|
21
|
+
* `*.reqs.ts` throws, Vite prints `Error when evaluating SSR module …: <the
|
|
22
|
+
* message>` straight to stderr, before Attest ever sees the exception. So the
|
|
23
|
+
* `registry-invalid` diagnostic built from that same error was sanitised while
|
|
24
|
+
* the raw bytes had already gone past it — a forged `ERROR` header at column 0
|
|
25
|
+
* and a `\r` to erase the line above, from a file the reviewer only ran.
|
|
26
|
+
*
|
|
27
|
+
* The whole message is treated as untrusted, which costs Vite its own colour:
|
|
28
|
+
* by the time it arrives the project's text has already been interpolated into
|
|
29
|
+
* it, so there is no half to trust. Losing colour on the one path where a
|
|
30
|
+
* registry threw is not a price worth arguing about.
|
|
31
|
+
*
|
|
32
|
+
* Exported for its own scenario. The logger a live server holds is not
|
|
33
|
+
* reachable from outside, and a guarantee nothing can fail is not a guarantee.
|
|
34
|
+
*/
|
|
35
|
+
export function sanitisedLogger(base = createLogger('error')) {
|
|
36
|
+
return {
|
|
37
|
+
...base,
|
|
38
|
+
info: (msg, opts) => base.info(block(msg), opts),
|
|
39
|
+
warn: (msg, opts) => base.warn(block(msg), opts),
|
|
40
|
+
warnOnce: (msg, opts) => base.warnOnce(block(msg), opts),
|
|
41
|
+
error: (msg, opts) => base.error(block(msg), opts),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
14
44
|
const VITEST_STUB = 'export const describe=()=>{};export const it=()=>{};export const test=()=>{};' +
|
|
15
45
|
'export const expect=()=>{};export const beforeAll=()=>{};export const afterAll=()=>{};' +
|
|
16
46
|
'export const beforeEach=()=>{};export const afterEach=()=>{};export const vi={};' +
|
|
@@ -22,6 +52,10 @@ export async function createLoader() {
|
|
|
22
52
|
const server = await createServer({
|
|
23
53
|
configFile: false,
|
|
24
54
|
logLevel: 'error',
|
|
55
|
+
// Not just quiet — sanitised. See `sanitisedLogger`: what survives
|
|
56
|
+
// `logLevel: 'error'` is exactly the message that carries the checked
|
|
57
|
+
// project's own text.
|
|
58
|
+
customLogger: sanitisedLogger(),
|
|
25
59
|
appType: 'custom',
|
|
26
60
|
// `ws: false` is load-bearing, and `middlewareMode` is not enough on its
|
|
27
61
|
// own: it suppresses the HTTP server but Vite still starts the HMR
|
package/dist/core/pipeline.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createLoader } from './loader.js';
|
|
|
4
4
|
import { evalReader, loadRegistry, parseSpecs, parseChangeSpecs, listChangeNames, scanProject, staticReader, } from './locate.js';
|
|
5
5
|
import { validateStructure, detectPotentialDrift, uncoveredIssues } from './validator.js';
|
|
6
6
|
import { byCodeUnit } from './order.js';
|
|
7
|
-
import { runAndCollect } from './runner.js';
|
|
7
|
+
import { runAndCollect, BASE_EXCLUDE } from './runner.js';
|
|
8
8
|
import { applyDelta, addedIds } from './apply.js';
|
|
9
9
|
import { readDeltaSource } from './static-registry.js';
|
|
10
10
|
import { statusRows, statusCounts } from './status.js';
|
|
@@ -16,7 +16,6 @@ import { writeAtomic } from './write.js';
|
|
|
16
16
|
import { mkdir, readFile } from 'node:fs/promises';
|
|
17
17
|
import { basename, dirname, join } from 'node:path';
|
|
18
18
|
import { relativePath } from './paths.js';
|
|
19
|
-
import { configDefaults } from 'vitest/config';
|
|
20
19
|
import { hasError } from './types.js';
|
|
21
20
|
/**
|
|
22
21
|
* Read every registry under root with the reader `options` asks for, and close
|
|
@@ -400,7 +399,7 @@ export async function runArchive(root, changeName, options = {}) {
|
|
|
400
399
|
// Run base specs + this change's specs; exclude other proposals and archive.
|
|
401
400
|
const others = (await listChangeNames(root)).filter((n) => n !== changeName);
|
|
402
401
|
const exclude = [
|
|
403
|
-
...
|
|
402
|
+
...BASE_EXCLUDE,
|
|
404
403
|
'**/archive/**',
|
|
405
404
|
...changeExcludeGlobs(others),
|
|
406
405
|
];
|
|
@@ -25,7 +25,15 @@ export type RedRecord = Record<string, Record<string, Outcome>>;
|
|
|
25
25
|
* the whole one.
|
|
26
26
|
*/
|
|
27
27
|
export declare function readRedRecord(root: string, changeName: string): Promise<RedRecord>;
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* What the record says about one scenario, or undefined if it never saw it.
|
|
30
|
+
*
|
|
31
|
+
* Own properties only, at both levels. The containers this module builds have
|
|
32
|
+
* no prototype, so for those this is belt and braces — but `RedRecord` is an
|
|
33
|
+
* ordinary structural type and this function is the gate's single reader, so
|
|
34
|
+
* what it must not do is answer from an inherited key whoever constructed the
|
|
35
|
+
* object it was handed. `record[reqId]?.[scenario]` did exactly that.
|
|
36
|
+
*/
|
|
29
37
|
export declare function recordedOutcome(record: RedRecord, reqId: string, scenario: string): Outcome | undefined;
|
|
30
38
|
/**
|
|
31
39
|
* Whether this scenario has satisfied the first-red obligation (design §6).
|
package/dist/core/red-record.js
CHANGED
|
@@ -47,6 +47,30 @@ import { readFile } from 'node:fs/promises';
|
|
|
47
47
|
import { join } from 'node:path';
|
|
48
48
|
import { z } from 'zod';
|
|
49
49
|
import { byCodeUnit } from './order.js';
|
|
50
|
+
/**
|
|
51
|
+
* **The record is a map, so it is built without a prototype.**
|
|
52
|
+
*
|
|
53
|
+
* Its keys are requirement ids and scenario names — the first from a delta, the
|
|
54
|
+
* second from a spec file — and on a plain object some of those strings are not
|
|
55
|
+
* keys at all. Two things followed from that, and both are about the gate:
|
|
56
|
+
*
|
|
57
|
+
* - `record[id] ??= {}` never fired for `__proto__`, because reading it yields
|
|
58
|
+
* `Object.prototype` rather than `undefined`. The outcome was then written
|
|
59
|
+
* onto the shared prototype of every object in the process.
|
|
60
|
+
* - A record read off disk carrying that key re-pointed the *record's* own
|
|
61
|
+
* prototype, so `hasRecordedRed` answered `true` for a requirement the file
|
|
62
|
+
* said nothing about — a never-red obligation satisfied by a file that
|
|
63
|
+
* serialises back as `{}`. Evidence that can be inherited is not evidence.
|
|
64
|
+
*
|
|
65
|
+
* Prototype-free rather than a check for the few key names that misbehave: a
|
|
66
|
+
* blocklist is a second answer to "is this a data key", and the container not
|
|
67
|
+
* having a prototype is the first one. apply.ts holds the other end by refusing
|
|
68
|
+
* such an id in the delta; neither is allowed to rely on the other, because
|
|
69
|
+
* `status` reads `addedIds` without applying the delta at all.
|
|
70
|
+
*/
|
|
71
|
+
function emptyMap() {
|
|
72
|
+
return Object.create(null);
|
|
73
|
+
}
|
|
50
74
|
/** Where the record lives, relative to the project root. */
|
|
51
75
|
export function redRecordPath(root, changeName) {
|
|
52
76
|
return join(root, 'changes', changeName, RED_RECORD_FILE);
|
|
@@ -87,24 +111,55 @@ export async function readRedRecord(root, changeName) {
|
|
|
87
111
|
raw = await readFile(redRecordPath(root, changeName), 'utf8');
|
|
88
112
|
}
|
|
89
113
|
catch {
|
|
90
|
-
return
|
|
114
|
+
return emptyMap();
|
|
91
115
|
}
|
|
92
116
|
let parsed;
|
|
93
117
|
try {
|
|
94
118
|
parsed = JSON.parse(raw);
|
|
95
119
|
}
|
|
96
120
|
catch {
|
|
97
|
-
return
|
|
121
|
+
return emptyMap();
|
|
98
122
|
}
|
|
99
123
|
// `JSON.parse` yields `any`; nothing about the file is known until the schema
|
|
100
124
|
// says so, including whether it is an object at all (`JSON.parse('null')`).
|
|
101
125
|
const firstRun = isRecord(parsed) ? parsed['firstRun'] : undefined;
|
|
102
126
|
const result = RedRecordSchema.safeParse(firstRun);
|
|
103
|
-
return result.success ? result.data :
|
|
127
|
+
return result.success ? adopt(result.data) : emptyMap();
|
|
104
128
|
}
|
|
105
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Re-home a parsed record into containers this module owns.
|
|
131
|
+
*
|
|
132
|
+
* The validator decides what the *values* may be; this decides what the
|
|
133
|
+
* container is, and the two are separate on purpose. `JSON.parse` makes
|
|
134
|
+
* `__proto__` an ordinary own property, and whether a schema library then
|
|
135
|
+
* carries it through, drops it, or assigns it onto a plain object — swapping
|
|
136
|
+
* that object's prototype — is an implementation detail of the library. The
|
|
137
|
+
* gate cannot rest on one, so the record is copied into a map that has no
|
|
138
|
+
* prototype for such a key to reach.
|
|
139
|
+
*/
|
|
140
|
+
function adopt(parsed) {
|
|
141
|
+
const record = emptyMap();
|
|
142
|
+
for (const [id, outcomes] of Object.entries(parsed)) {
|
|
143
|
+
record[id] = Object.assign(emptyMap(), outcomes);
|
|
144
|
+
}
|
|
145
|
+
return record;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* What the record says about one scenario, or undefined if it never saw it.
|
|
149
|
+
*
|
|
150
|
+
* Own properties only, at both levels. The containers this module builds have
|
|
151
|
+
* no prototype, so for those this is belt and braces — but `RedRecord` is an
|
|
152
|
+
* ordinary structural type and this function is the gate's single reader, so
|
|
153
|
+
* what it must not do is answer from an inherited key whoever constructed the
|
|
154
|
+
* object it was handed. `record[reqId]?.[scenario]` did exactly that.
|
|
155
|
+
*/
|
|
106
156
|
export function recordedOutcome(record, reqId, scenario) {
|
|
107
|
-
|
|
157
|
+
if (!Object.hasOwn(record, reqId))
|
|
158
|
+
return undefined;
|
|
159
|
+
const outcomes = record[reqId];
|
|
160
|
+
if (outcomes === undefined || !Object.hasOwn(outcomes, scenario))
|
|
161
|
+
return undefined;
|
|
162
|
+
return outcomes[scenario];
|
|
108
163
|
}
|
|
109
164
|
/**
|
|
110
165
|
* Whether this scenario has satisfied the first-red obligation (design §6).
|
|
@@ -130,12 +185,15 @@ export function hasRecordedRed(record, reqId, scenario) {
|
|
|
130
185
|
*/
|
|
131
186
|
export function mergeRedRecord(existing, plan, run, addedIds) {
|
|
132
187
|
const added = new Set(addedIds);
|
|
133
|
-
const record =
|
|
188
|
+
const record = emptyMap();
|
|
134
189
|
// Rebuild rather than mutate, so key order is a function of the data and two
|
|
135
190
|
// machines write byte-identical files (the same reason `render` orders ids
|
|
136
191
|
// through one code-unit comparator).
|
|
137
192
|
for (const id of Object.keys(existing).sort(byCodeUnit)) {
|
|
138
|
-
|
|
193
|
+
const outcomes = existing[id];
|
|
194
|
+
if (outcomes === undefined)
|
|
195
|
+
continue;
|
|
196
|
+
record[id] = Object.assign(emptyMap(), outcomes);
|
|
139
197
|
}
|
|
140
198
|
let changed = false;
|
|
141
199
|
for (const s of plan.scenarios) {
|
|
@@ -147,7 +205,7 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
|
|
|
147
205
|
// obligation unobserved rather than inventing a state for it.
|
|
148
206
|
if (!outcome)
|
|
149
207
|
continue;
|
|
150
|
-
const forId = (record[s.reqId] ??=
|
|
208
|
+
const forId = (record[s.reqId] ??= emptyMap());
|
|
151
209
|
// Monotonic toward `fail`: a recorded fail is final, a recorded pass can
|
|
152
210
|
// still be corrected by a real one. See the note at the top of this file.
|
|
153
211
|
if (forId[s.name] === 'fail' || forId[s.name] === outcome)
|
|
@@ -157,7 +215,7 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
|
|
|
157
215
|
}
|
|
158
216
|
// Sort within each requirement for the same byte-stability reason.
|
|
159
217
|
for (const id of Object.keys(record)) {
|
|
160
|
-
const sorted =
|
|
218
|
+
const sorted = emptyMap();
|
|
161
219
|
for (const name of Object.keys(record[id]).sort(byCodeUnit)) {
|
|
162
220
|
sorted[name] = record[id][name];
|
|
163
221
|
}
|
package/dist/core/runner.d.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
import { startVitest } from 'vitest/node';
|
|
2
2
|
import type { RunResult } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Vitest's own default exclusions, which every Attest run keeps on top of
|
|
5
|
+
* whatever else it excludes.
|
|
6
|
+
*
|
|
7
|
+
* Re-exported from here because this module is one of the three allowed to name
|
|
8
|
+
* `vite`/`vitest` at all (`tests/import-boundary.spec.ts`). `runArchive` needs
|
|
9
|
+
* this list to compose an exclude set of its own, and importing `vitest/config`
|
|
10
|
+
* in `pipeline.ts` to get it put the dependency on the module every static
|
|
11
|
+
* command goes through — the boundary the gate exists to hold.
|
|
12
|
+
*/
|
|
13
|
+
export declare const BASE_EXCLUDE: readonly string[];
|
|
3
14
|
export interface RunAndCollectOptions {
|
|
4
15
|
/** Project root to run. Defaults to the current working directory. */
|
|
5
16
|
root?: string;
|
package/dist/core/runner.js
CHANGED
|
@@ -6,8 +6,19 @@ import { startVitest } from 'vitest/node';
|
|
|
6
6
|
import { configDefaults } from 'vitest/config';
|
|
7
7
|
/** requirement() names each describe block `[reqId]`; recover the id from that. */
|
|
8
8
|
const REQ_SUITE = /^\[(.+)\]$/;
|
|
9
|
+
/**
|
|
10
|
+
* Vitest's own default exclusions, which every Attest run keeps on top of
|
|
11
|
+
* whatever else it excludes.
|
|
12
|
+
*
|
|
13
|
+
* Re-exported from here because this module is one of the three allowed to name
|
|
14
|
+
* `vite`/`vitest` at all (`tests/import-boundary.spec.ts`). `runArchive` needs
|
|
15
|
+
* this list to compose an exclude set of its own, and importing `vitest/config`
|
|
16
|
+
* in `pipeline.ts` to get it put the dependency on the module every static
|
|
17
|
+
* command goes through — the boundary the gate exists to hold.
|
|
18
|
+
*/
|
|
19
|
+
export const BASE_EXCLUDE = configDefaults.exclude;
|
|
9
20
|
/** Proposed / archived changes are excluded from a normal run (design §7, §8). */
|
|
10
|
-
const DEFAULT_EXCLUDE = [...
|
|
21
|
+
const DEFAULT_EXCLUDE = [...BASE_EXCLUDE, '**/changes/**', '**/archive/**'];
|
|
11
22
|
/**
|
|
12
23
|
* Build the child-run options. Attest owns the run *scope* — include/exclude/
|
|
13
24
|
* root/watch always come from here, so the caller can neither widen the spec
|
package/dist/core/schema.d.ts
CHANGED
|
@@ -16,6 +16,18 @@ export declare const RequirementSchema: z.ZodObject<{
|
|
|
16
16
|
params?: Record<string, string | number | boolean | (string | number | boolean)[]> | undefined;
|
|
17
17
|
outOfScope?: string[] | undefined;
|
|
18
18
|
}>;
|
|
19
|
+
/**
|
|
20
|
+
* The id grammar, on its own.
|
|
21
|
+
*
|
|
22
|
+
* Exported because the registry is not the only thing that files a requirement
|
|
23
|
+
* under an id: a delta's ADDED block does too, and `applyDelta` validated the
|
|
24
|
+
* requirement while never looking at the key it went under (design §7). So
|
|
25
|
+
* `changes/` could introduce an id `defineRequirements` would refuse — one that
|
|
26
|
+
* could therefore never be written into a `*.reqs.ts` after the change merged.
|
|
27
|
+
* One schema rather than a second regex, because two spellings of a grammar are
|
|
28
|
+
* two grammars as soon as one of them is edited.
|
|
29
|
+
*/
|
|
30
|
+
export declare const RequirementIdSchema: z.ZodString;
|
|
19
31
|
/** The requirement registry: stable ID -> requirement (design §2, §5.1). */
|
|
20
32
|
export declare const RegistrySchema: z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
21
33
|
statement: z.ZodEffects<z.ZodString, string, string>;
|
package/dist/core/schema.js
CHANGED
|
@@ -22,6 +22,20 @@ export const RequirementSchema = z.object({
|
|
|
22
22
|
.default({}),
|
|
23
23
|
outOfScope: z.array(z.string()).default([]),
|
|
24
24
|
});
|
|
25
|
+
/**
|
|
26
|
+
* The id grammar, on its own.
|
|
27
|
+
*
|
|
28
|
+
* Exported because the registry is not the only thing that files a requirement
|
|
29
|
+
* under an id: a delta's ADDED block does too, and `applyDelta` validated the
|
|
30
|
+
* requirement while never looking at the key it went under (design §7). So
|
|
31
|
+
* `changes/` could introduce an id `defineRequirements` would refuse — one that
|
|
32
|
+
* could therefore never be written into a `*.reqs.ts` after the change merged.
|
|
33
|
+
* One schema rather than a second regex, because two spellings of a grammar are
|
|
34
|
+
* two grammars as soon as one of them is edited.
|
|
35
|
+
*/
|
|
36
|
+
export const RequirementIdSchema = z
|
|
37
|
+
.string()
|
|
38
|
+
.regex(/^[A-Z]+-\d+$/, 'id must look like AUTH-3');
|
|
25
39
|
/** The requirement registry: stable ID -> requirement (design §2, §5.1). */
|
|
26
|
-
export const RegistrySchema = z.record(
|
|
40
|
+
export const RegistrySchema = z.record(RequirementIdSchema, RequirementSchema);
|
|
27
41
|
//# sourceMappingURL=schema.js.map
|