@am_shork/attest 0.4.0 → 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 +945 -225
- package/README.md +48 -292
- package/dist/cli/index.js +9 -5
- package/dist/cli/json.js +25 -1
- package/dist/cli/report.d.ts +20 -0
- package/dist/cli/report.js +32 -44
- package/dist/core/apply.js +49 -1
- package/dist/core/docs.d.ts +23 -0
- package/dist/core/docs.js +92 -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/types.d.ts +22 -1
- package/dist/{cli → core}/version.js +5 -1
- 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/cli/version.d.ts.map +0 -1
- package/dist/cli/version.js.map +0 -1
- package/dist/core/apply.d.ts.map +0 -1
- package/dist/core/apply.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/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/dist/{cli → core}/version.d.ts +0 -0
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
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every code the engine can put on an `Issue`, and therefore every section the
|
|
3
|
+
* troubleshooting document must have. Alphabetical, so a diff against a heading
|
|
4
|
+
* list reads as a diff.
|
|
5
|
+
*
|
|
6
|
+
* This roster is asserted against two independent things: the `code: '…'`
|
|
7
|
+
* literals in `src/`, so a new diagnostic cannot be added without landing here,
|
|
8
|
+
* and the `##` headings of both language documents, so landing here cannot
|
|
9
|
+
* produce a dead link.
|
|
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", "rename-target-invalid", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target"];
|
|
12
|
+
export type IssueCode = (typeof ISSUE_CODES)[number];
|
|
13
|
+
/**
|
|
14
|
+
* The page explaining `code`, or `undefined` when nothing explains it.
|
|
15
|
+
*
|
|
16
|
+
* `undefined` rather than a best-effort link: an anchor that resolves to the
|
|
17
|
+
* top of the page looks like an answer and is not one. In this repository the
|
|
18
|
+
* roster is exhaustive, so the undefined branch is unreachable from the
|
|
19
|
+
* engine's own issues — it exists because `code` is typed `string` on `Issue`,
|
|
20
|
+
* and a caller outside the engine can put anything there.
|
|
21
|
+
*/
|
|
22
|
+
export declare function docsUrl(code: string): string | undefined;
|
|
23
|
+
//# sourceMappingURL=docs.d.ts.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// The link from a diagnostic to the page that explains it (design §5.3).
|
|
2
|
+
//
|
|
3
|
+
// `Issue.code` is the machine-readable name of a rule, and the troubleshooting
|
|
4
|
+
// document gives every rule one section whose heading *is* that code. So the
|
|
5
|
+
// anchor is not a second thing to maintain — it is the code — and a link can be
|
|
6
|
+
// derived rather than authored. Nothing here is hand-written per issue site,
|
|
7
|
+
// which is the whole point: a diagnostic added without a link is impossible,
|
|
8
|
+
// and a link pointing at a section that does not exist is caught by
|
|
9
|
+
// `tests/docs-consistency.spec.ts` rather than by a reader clicking it.
|
|
10
|
+
import { packageVersion } from './version.js';
|
|
11
|
+
/**
|
|
12
|
+
* Every code the engine can put on an `Issue`, and therefore every section the
|
|
13
|
+
* troubleshooting document must have. Alphabetical, so a diff against a heading
|
|
14
|
+
* list reads as a diff.
|
|
15
|
+
*
|
|
16
|
+
* This roster is asserted against two independent things: the `code: '…'`
|
|
17
|
+
* literals in `src/`, so a new diagnostic cannot be added without landing here,
|
|
18
|
+
* and the `##` headings of both language documents, so landing here cannot
|
|
19
|
+
* produce a dead link.
|
|
20
|
+
*/
|
|
21
|
+
export const ISSUE_CODES = [
|
|
22
|
+
'add-conflict',
|
|
23
|
+
'add-invalid',
|
|
24
|
+
'change-not-found',
|
|
25
|
+
'declared-not-run',
|
|
26
|
+
'duplicate-prefix',
|
|
27
|
+
'duplicate-requirement',
|
|
28
|
+
'empty-spec',
|
|
29
|
+
'internal-error',
|
|
30
|
+
'invalid-change-name',
|
|
31
|
+
'missing-spec-doc',
|
|
32
|
+
'modify-invalid',
|
|
33
|
+
'modify-missing',
|
|
34
|
+
'never-red',
|
|
35
|
+
'orphan-test',
|
|
36
|
+
'possible-drift',
|
|
37
|
+
'rationale-placeholder',
|
|
38
|
+
'registry-invalid',
|
|
39
|
+
'registry-no-default',
|
|
40
|
+
'registry-not-static',
|
|
41
|
+
'rename-source-missing',
|
|
42
|
+
'rename-target-exists',
|
|
43
|
+
'rename-target-invalid',
|
|
44
|
+
'stale-spec-doc',
|
|
45
|
+
'tests-red',
|
|
46
|
+
'unbound-param',
|
|
47
|
+
'uncovered-requirement',
|
|
48
|
+
'unknown-target',
|
|
49
|
+
];
|
|
50
|
+
const DOCUMENTED = new Set(ISSUE_CODES);
|
|
51
|
+
const REPO = 'https://gitlab.com/Pseudorca/attest/-/blob';
|
|
52
|
+
const DOC = 'docs/en/troubleshooting.md';
|
|
53
|
+
/**
|
|
54
|
+
* The version whose documents a link points at, as a git ref.
|
|
55
|
+
*
|
|
56
|
+
* Pinned to the tag of the build that printed the diagnostic, not to `main`.
|
|
57
|
+
* The two failure modes are not symmetric:
|
|
58
|
+
*
|
|
59
|
+
* - **Pinned, between a release commit and its tag** — the ref does not resolve
|
|
60
|
+
* yet. But that window is a version nobody has installed: the manifest is
|
|
61
|
+
* bumped and the package is not published, so no build in anyone's
|
|
62
|
+
* `node_modules` prints those links. The 404 is unreachable in practice.
|
|
63
|
+
* - **Unpinned, after any breaking change to the codes** — a build from a year
|
|
64
|
+
* ago links into today's document. A renamed or removed code lands on a
|
|
65
|
+
* missing anchor, and worse, a code whose *meaning* changed lands on a
|
|
66
|
+
* section confidently describing a different rule. That one is reachable by
|
|
67
|
+
* every installed copy, forever, and it gets worse with every release.
|
|
68
|
+
*
|
|
69
|
+
* So the ref is `v<version>`, matching this repository's release tags. The
|
|
70
|
+
* fallback is `main`, for the one case where pinning is impossible rather than
|
|
71
|
+
* merely imprecise: `packageVersion()` answers `0.0.0` when it cannot read the
|
|
72
|
+
* manifest, and `v0.0.0` is a tag that has never existed.
|
|
73
|
+
*/
|
|
74
|
+
function ref() {
|
|
75
|
+
const version = packageVersion();
|
|
76
|
+
return version === '0.0.0' ? 'main' : `v${version}`;
|
|
77
|
+
}
|
|
78
|
+
/** Resolved once: the version cannot change while the process runs. */
|
|
79
|
+
const BASE = `${REPO}/${ref()}/${DOC}`;
|
|
80
|
+
/**
|
|
81
|
+
* The page explaining `code`, or `undefined` when nothing explains it.
|
|
82
|
+
*
|
|
83
|
+
* `undefined` rather than a best-effort link: an anchor that resolves to the
|
|
84
|
+
* top of the page looks like an answer and is not one. In this repository the
|
|
85
|
+
* roster is exhaustive, so the undefined branch is unreachable from the
|
|
86
|
+
* engine's own issues — it exists because `code` is typed `string` on `Issue`,
|
|
87
|
+
* and a caller outside the engine can put anything there.
|
|
88
|
+
*/
|
|
89
|
+
export function docsUrl(code) {
|
|
90
|
+
return DOCUMENTED.has(code) ? `${BASE}#${code}` : undefined;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=docs.js.map
|
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
|
package/dist/core/skill.js
CHANGED
|
@@ -148,7 +148,7 @@ export default delta({
|
|
|
148
148
|
'AUTH-3': { params: { idleTimeoutMin: 15 } }, // 30 → 15; the assertion follows, because it reads params
|
|
149
149
|
},
|
|
150
150
|
removed: ['AUTH-5'],
|
|
151
|
-
renamed: [{ from: 'AUTH-2', to: '
|
|
151
|
+
renamed: [{ from: 'AUTH-2', to: 'SESSION-1' }], // a rename target is a new id, so it obeys the id grammar
|
|
152
152
|
});
|
|
153
153
|
\`\`\`
|
|
154
154
|
|
|
@@ -184,6 +184,13 @@ Two consequences worth knowing before you start:
|
|
|
184
184
|
Commit \`first-run.json\` with the change. It is evidence a reviewer reads, and
|
|
185
185
|
the gate has to reach the same verdict on a CI checkout as on your machine.
|
|
186
186
|
|
|
187
|
+
**Never write or edit that file yourself.** The gate trusts it and cannot check
|
|
188
|
+
it: everything else Attest writes can be regenerated and compared, but a first
|
|
189
|
+
run cannot be recomputed once the change is green — which is the whole reason
|
|
190
|
+
the record exists. Producing it any way other than by running the gate destroys
|
|
191
|
+
the only thing it is for. If it says something you did not expect, run the gate
|
|
192
|
+
again; a real failing run replaces a recorded pass.
|
|
193
|
+
|
|
187
194
|
To see where the change stands at any point, without paying for a run:
|
|
188
195
|
|
|
189
196
|
\`\`\`
|
|
@@ -225,7 +232,7 @@ once. Branch on \`issues[].code\`, never on \`message\`:
|
|
|
225
232
|
| \`add-conflict\` | the delta adds an id that already exists with different content |
|
|
226
233
|
| \`change-not-found\` | no \`requirements.delta.ts\` for that name |
|
|
227
234
|
|
|
228
|
-
###
|
|
235
|
+
### Four things you must not do
|
|
229
236
|
|
|
230
237
|
Each turns the gate green without changing the system, which is the exact
|
|
231
238
|
failure this framework exists to make visible:
|
|
@@ -240,6 +247,10 @@ failure this framework exists to make visible:
|
|
|
240
247
|
is the drift the single source exists to prevent, and the param is typed at
|
|
241
248
|
the value written in the registry, so a stale expectation stops compiling
|
|
242
249
|
rather than silently passing.
|
|
250
|
+
4. **Do not write or edit \`first-run.json\`.** It is the one file here the gate
|
|
251
|
+
trusts without being able to check it, so a hand-written \`"fail"\` clears
|
|
252
|
+
\`never-red\` while proving nothing at all. Run the gate before implementing;
|
|
253
|
+
that is what produces the record honestly.
|
|
243
254
|
|
|
244
255
|
### When the gate passes
|
|
245
256
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** How far a message is indented under its header line. */
|
|
2
|
+
export declare const INDENT = " ";
|
|
3
|
+
/**
|
|
4
|
+
* A multi-line message, safe to print under a header.
|
|
5
|
+
*
|
|
6
|
+
* A newline survives, because one diagnostic is legitimately multi-line:
|
|
7
|
+
* `registry-invalid` carries Zod's list of field errors, a stack trace is a
|
|
8
|
+
* list of frames, and flattening either would cost a real reader a real thing
|
|
9
|
+
* to save a hypothetical one. It is re-indented instead, so injected text lands
|
|
10
|
+
* inside the message block rather than at column 0 where a forged `ERROR ...`
|
|
11
|
+
* header would be indistinguishable from a real one.
|
|
12
|
+
*/
|
|
13
|
+
export declare function block(text: string): string;
|
|
14
|
+
/** The same, for text that sits inline in a header and must stay one line. */
|
|
15
|
+
export declare function inline(text: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Every C0 control except the newline, plus DEL and the C1 range, as a space.
|
|
18
|
+
*
|
|
19
|
+
* A space rather than deletion: removing the byte would silently splice
|
|
20
|
+
* `atte` + `st` into a word that was never in the file, and a diagnostic that
|
|
21
|
+
* quietly rewrites what it quotes is its own kind of wrong.
|
|
22
|
+
*/
|
|
23
|
+
export declare function control(text: string): string;
|
|
24
|
+
//# sourceMappingURL=terminal.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Text from the project under test, on its way to a terminal (ATX-37).
|
|
2
|
+
//
|
|
3
|
+
// Most of what Attest prints is its own prose, but the parts a reader actually
|
|
4
|
+
// navigates by are not: a scenario name and a file path are quoted verbatim out
|
|
5
|
+
// of the repository being checked (`orphan-test`, `declared-not-run`,
|
|
6
|
+
// `never-red`), and the parser hands back the *cooked* string, so an ESC written
|
|
7
|
+
// as an escape in a spec file is a real control byte by the time it arrives
|
|
8
|
+
// here. So is the message of anything that repository throws, which under
|
|
9
|
+
// `verify`, `archive` or `--eval` is a module Attest is running.
|
|
10
|
+
//
|
|
11
|
+
// That makes Attest's output a place where the repository being checked can
|
|
12
|
+
// write to the terminal of whoever is checking it. On a fork MR the author is
|
|
13
|
+
// not the reviewer, and the payload is a scenario name or an exception: erase
|
|
14
|
+
// the lines above, repaint a red verdict green, rewrite the window title. The
|
|
15
|
+
// `--json` path was never exposed — `JSON.stringify` escapes every C0
|
|
16
|
+
// character — which is precisely why this has to hold on the side people read.
|
|
17
|
+
//
|
|
18
|
+
// **In `core/` rather than in the CLI**, though the CLI is its main caller: the
|
|
19
|
+
// loader has to sanitise Vite's log output for the same reason and cannot
|
|
20
|
+
// import from a layer above it. Two copies of this decision is how the crash
|
|
21
|
+
// path came to be missed once already.
|
|
22
|
+
/** The one control character that survives: see `block`. */
|
|
23
|
+
const NEWLINE = 0x0a;
|
|
24
|
+
/** How far a message is indented under its header line. */
|
|
25
|
+
export const INDENT = ' ';
|
|
26
|
+
/**
|
|
27
|
+
* A multi-line message, safe to print under a header.
|
|
28
|
+
*
|
|
29
|
+
* A newline survives, because one diagnostic is legitimately multi-line:
|
|
30
|
+
* `registry-invalid` carries Zod's list of field errors, a stack trace is a
|
|
31
|
+
* list of frames, and flattening either would cost a real reader a real thing
|
|
32
|
+
* to save a hypothetical one. It is re-indented instead, so injected text lands
|
|
33
|
+
* inside the message block rather than at column 0 where a forged `ERROR ...`
|
|
34
|
+
* header would be indistinguishable from a real one.
|
|
35
|
+
*/
|
|
36
|
+
export function block(text) {
|
|
37
|
+
return control(text).replaceAll('\n', `\n${INDENT}`);
|
|
38
|
+
}
|
|
39
|
+
/** The same, for text that sits inline in a header and must stay one line. */
|
|
40
|
+
export function inline(text) {
|
|
41
|
+
return control(text).replaceAll('\n', ' ');
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Every C0 control except the newline, plus DEL and the C1 range, as a space.
|
|
45
|
+
*
|
|
46
|
+
* A space rather than deletion: removing the byte would silently splice
|
|
47
|
+
* `atte` + `st` into a word that was never in the file, and a diagnostic that
|
|
48
|
+
* quietly rewrites what it quotes is its own kind of wrong.
|
|
49
|
+
*/
|
|
50
|
+
export function control(text) {
|
|
51
|
+
return [...text]
|
|
52
|
+
.map((ch) => {
|
|
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
|
+
}
|
|
59
|
+
//# sourceMappingURL=terminal.js.map
|