@am_shork/attest 0.7.4 → 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 +761 -131
- 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/gate.js +3 -3
- package/dist/core/locate.js +4 -4
- package/dist/core/pipeline.d.ts +17 -1
- package/dist/core/pipeline.js +104 -12
- 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.js +5 -4
- package/dist/core/status.js +2 -2
- package/dist/core/terminal.js +5 -2
- package/dist/core/types.d.ts +39 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -166,7 +166,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
166
166
|
```
|
|
167
167
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
168
168
|
Value is not a literal.
|
|
169
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.
|
|
169
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.8.0/docs/en/troubleshooting.md#registry-not-static
|
|
170
170
|
```
|
|
171
171
|
|
|
172
172
|
The anchor **is** the code, so the link cannot point somewhere the section
|
package/dist/cli/index.js
CHANGED
|
@@ -13,7 +13,6 @@
|
|
|
13
13
|
import { Command } from 'commander';
|
|
14
14
|
import chalk from 'chalk';
|
|
15
15
|
import { resolve } from 'node:path';
|
|
16
|
-
import { writeAtomic } from '../core/write.js';
|
|
17
16
|
import { runCheck, runVerify, runCover, runArchive, runArchiveApply, runInit, runRender, runRenderCheck, runStatus, DEFAULT_TARGET, TARGET_NAMES, } from '../core/pipeline.js';
|
|
18
17
|
import { hasError } from '../core/types.js';
|
|
19
18
|
// The one project-derived value this shell still interpolates itself. `--out`
|
|
@@ -135,12 +134,16 @@ program
|
|
|
135
134
|
if (opts.json && !opts.out && !opts.check) {
|
|
136
135
|
cmd.error('error: --json requires --out <file> (stdout cannot carry both the document and the report)');
|
|
137
136
|
}
|
|
138
|
-
|
|
137
|
+
// Both spellings of the destination, because both are needed downstream: the
|
|
138
|
+
// resolved one is what the filesystem is given, the typed one is what a
|
|
139
|
+
// diagnostic echoes. Where it may point, and the write itself, are the
|
|
140
|
+
// pipeline's — this shell decides what to print and nothing else.
|
|
141
|
+
const out = opts.out ? { file: resolve(process.cwd(), opts.out), display: opts.out } : undefined;
|
|
139
142
|
return runAction('render', opts, async () => {
|
|
140
143
|
if (opts.check) {
|
|
141
144
|
// The fix hint has to be the command the user can actually re-run from
|
|
142
145
|
// here: their own --out string, and the [dir] they passed.
|
|
143
|
-
const issues = await runRenderCheck(root(dir),
|
|
146
|
+
const issues = await runRenderCheck(root(dir), out.file, {
|
|
144
147
|
display: opts.out,
|
|
145
148
|
command: `attest render${dir ? ` ${dir}` : ''} --out ${opts.out}`,
|
|
146
149
|
}, { evaluate: opts.eval });
|
|
@@ -156,10 +159,10 @@ program
|
|
|
156
159
|
},
|
|
157
160
|
};
|
|
158
161
|
}
|
|
159
|
-
const { markdown, issues } = await runRender(root(dir), {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
const { markdown, issues, wrote } = await runRender(root(dir), {
|
|
163
|
+
evaluate: opts.eval,
|
|
164
|
+
out,
|
|
165
|
+
});
|
|
163
166
|
return {
|
|
164
167
|
report: renderReport(VERSION, issues, opts.out),
|
|
165
168
|
human: () => {
|
|
@@ -167,7 +170,7 @@ program
|
|
|
167
170
|
console.log(formatIssues(issues));
|
|
168
171
|
console.log(summarize(issues));
|
|
169
172
|
}
|
|
170
|
-
else if (
|
|
173
|
+
else if (wrote) {
|
|
171
174
|
console.log(chalk.green(`✓ Wrote ${inline(opts.out ?? '')}`));
|
|
172
175
|
}
|
|
173
176
|
else {
|
package/dist/cli/json.d.ts
CHANGED
|
@@ -100,6 +100,31 @@ export declare function statusReport(version: string, result: StatusResult): Jso
|
|
|
100
100
|
* empty stdout and a stack trace on stderr.
|
|
101
101
|
*/
|
|
102
102
|
export declare function errorReport(version: string, command: JsonCommand, err: unknown): JsonReport;
|
|
103
|
-
/**
|
|
103
|
+
/**
|
|
104
|
+
* Serialize a report for stdout. Pretty-printed: still one parseable document.
|
|
105
|
+
*
|
|
106
|
+
* Every string in it loses its control characters first (design §9.1). The
|
|
107
|
+
* machine surface was recorded as exempt because `JSON.stringify` escapes every
|
|
108
|
+
* C0 character — true, and narrower than the rule: §9.1's class also holds DEL
|
|
109
|
+
* and the C1 range, and `JSON.stringify` emits those verbatim. `U+009B` is
|
|
110
|
+
* 8-bit CSI, the same introducer as `ESC [` on a terminal that reads
|
|
111
|
+
* UTF-8-decoded C1 as control — so a scenario name or a thrown message can
|
|
112
|
+
* drive the terminal of someone reading the JSON rather than parsing it, from
|
|
113
|
+
* `check --json`, which executes nothing.
|
|
114
|
+
*
|
|
115
|
+
* Applied here rather than at the report builders, and that is §9.1's own
|
|
116
|
+
* distinction rather than a preference: this is a *document*, not a stream. It
|
|
117
|
+
* has one point of assembly, before any of it is seen, so an obligation sitting
|
|
118
|
+
* there covers a field nobody has added yet — as `outFiles` and `docsUrl` both
|
|
119
|
+
* were. The terminal path is sanitised at each write site for the opposite
|
|
120
|
+
* reason, recorded in core/terminal.ts.
|
|
121
|
+
*
|
|
122
|
+
* A replacer rather than a walk over the fields that carry project text —
|
|
123
|
+
* `issues[].message`, `issues[].file`, `change`, `outFile`, `outFiles` today —
|
|
124
|
+
* because enumerating them is the arrangement that leaves the next one out. It
|
|
125
|
+
* costs nothing on the fields that cannot carry a control character: `control`
|
|
126
|
+
* allocates when something matches, and nothing does. Non-mutating, because a
|
|
127
|
+
* caller may render the same report for a human afterwards.
|
|
128
|
+
*/
|
|
104
129
|
export declare function renderJson(report: JsonReport): string;
|
|
105
130
|
//# sourceMappingURL=json.d.ts.map
|
package/dist/cli/json.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// and every command emits the same envelope (tool/version/command/ok/summary/
|
|
7
7
|
// issues) plus command-specific fields.
|
|
8
8
|
import { docsUrl } from '../core/docs.js';
|
|
9
|
+
import { control } from '../core/terminal.js';
|
|
9
10
|
import { hasError } from '../core/types.js';
|
|
10
11
|
/** Bumped whenever the emitted shape changes incompatibly. */
|
|
11
12
|
export const SCHEMA_VERSION = 1;
|
|
@@ -156,8 +157,33 @@ export function errorReport(version, command, err) {
|
|
|
156
157
|
const issue = { level: 'ERROR', code: 'internal-error', message };
|
|
157
158
|
return envelope({ version, command, ok: false, issues: [issue] });
|
|
158
159
|
}
|
|
159
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Serialize a report for stdout. Pretty-printed: still one parseable document.
|
|
162
|
+
*
|
|
163
|
+
* Every string in it loses its control characters first (design §9.1). The
|
|
164
|
+
* machine surface was recorded as exempt because `JSON.stringify` escapes every
|
|
165
|
+
* C0 character — true, and narrower than the rule: §9.1's class also holds DEL
|
|
166
|
+
* and the C1 range, and `JSON.stringify` emits those verbatim. `U+009B` is
|
|
167
|
+
* 8-bit CSI, the same introducer as `ESC [` on a terminal that reads
|
|
168
|
+
* UTF-8-decoded C1 as control — so a scenario name or a thrown message can
|
|
169
|
+
* drive the terminal of someone reading the JSON rather than parsing it, from
|
|
170
|
+
* `check --json`, which executes nothing.
|
|
171
|
+
*
|
|
172
|
+
* Applied here rather than at the report builders, and that is §9.1's own
|
|
173
|
+
* distinction rather than a preference: this is a *document*, not a stream. It
|
|
174
|
+
* has one point of assembly, before any of it is seen, so an obligation sitting
|
|
175
|
+
* there covers a field nobody has added yet — as `outFiles` and `docsUrl` both
|
|
176
|
+
* were. The terminal path is sanitised at each write site for the opposite
|
|
177
|
+
* reason, recorded in core/terminal.ts.
|
|
178
|
+
*
|
|
179
|
+
* A replacer rather than a walk over the fields that carry project text —
|
|
180
|
+
* `issues[].message`, `issues[].file`, `change`, `outFile`, `outFiles` today —
|
|
181
|
+
* because enumerating them is the arrangement that leaves the next one out. It
|
|
182
|
+
* costs nothing on the fields that cannot carry a control character: `control`
|
|
183
|
+
* allocates when something matches, and nothing does. Non-mutating, because a
|
|
184
|
+
* caller may render the same report for a human afterwards.
|
|
185
|
+
*/
|
|
160
186
|
export function renderJson(report) {
|
|
161
|
-
return JSON.stringify(report,
|
|
187
|
+
return JSON.stringify(report, (_key, value) => (typeof value === 'string' ? control(value) : value), 2);
|
|
162
188
|
}
|
|
163
189
|
//# sourceMappingURL=json.js.map
|
package/dist/core/gate.js
CHANGED
|
@@ -16,7 +16,7 @@ import { RED_RECORD_FILE, hasRecordedRed, recordedOutcome, } from './red-record.
|
|
|
16
16
|
export function declaredNotRunIssues(plan, run) {
|
|
17
17
|
const issues = [];
|
|
18
18
|
for (const s of plan.scenarios) {
|
|
19
|
-
if (!run.runtimeCoverage.get(s.reqId)?.has(s.name)) {
|
|
19
|
+
if (!run.runtimeCoverage.get(s.reqId)?.get(s.file)?.has(s.name)) {
|
|
20
20
|
issues.push({
|
|
21
21
|
level: 'ERROR',
|
|
22
22
|
code: 'declared-not-run',
|
|
@@ -155,9 +155,9 @@ export function neverRedIssues(plan, addedIds, firstRun) {
|
|
|
155
155
|
for (const s of plan.scenarios) {
|
|
156
156
|
if (!added.has(s.reqId))
|
|
157
157
|
continue;
|
|
158
|
-
if (hasRecordedRed(firstRun, s
|
|
158
|
+
if (hasRecordedRed(firstRun, s))
|
|
159
159
|
continue;
|
|
160
|
-
const outcome = recordedOutcome(firstRun, s
|
|
160
|
+
const outcome = recordedOutcome(firstRun, s);
|
|
161
161
|
issues.push({
|
|
162
162
|
level: 'ERROR',
|
|
163
163
|
code: 'never-red',
|
package/dist/core/locate.js
CHANGED
|
@@ -5,7 +5,7 @@ import { basename, join } from 'node:path';
|
|
|
5
5
|
import { relativePath } from './paths.js';
|
|
6
6
|
import { parseSpecFile } from './parser.js';
|
|
7
7
|
import { declaredIdsFromSource, readRegistrySource } from './static-registry.js';
|
|
8
|
-
import {
|
|
8
|
+
import { registryValidationMessage } from './registry-issues.js';
|
|
9
9
|
import { RegistrySchema } from './schema.js';
|
|
10
10
|
import { byCodeUnit } from './order.js';
|
|
11
11
|
import { SourceNotCompiled } from './compiler.js';
|
|
@@ -191,13 +191,13 @@ export function evalReader(loader) {
|
|
|
191
191
|
const parsed = RegistrySchema.safeParse(exported);
|
|
192
192
|
if (!parsed.success) {
|
|
193
193
|
// The same code, the same prefix and the same rendering of the field
|
|
194
|
-
// errors as `readRegistrySource` —
|
|
195
|
-
//
|
|
194
|
+
// errors as `readRegistrySource` — sharing one formatter keeps the
|
|
195
|
+
// message identical rather than merely similar.
|
|
196
196
|
return {
|
|
197
197
|
issue: {
|
|
198
198
|
level: 'ERROR',
|
|
199
199
|
code: 'registry-invalid',
|
|
200
|
-
message: `Failed to load registry: ${
|
|
200
|
+
message: `Failed to load registry: ${registryValidationMessage(parsed.error.issues)}`,
|
|
201
201
|
},
|
|
202
202
|
};
|
|
203
203
|
}
|
package/dist/core/pipeline.d.ts
CHANGED
|
@@ -81,6 +81,20 @@ export interface RenderResult {
|
|
|
81
81
|
markdown: string;
|
|
82
82
|
/** Registry-loading issues — rendering a half-loaded registry would lie. */
|
|
83
83
|
issues: Issue[];
|
|
84
|
+
/** Whether the document reached `out.file`. False whenever `out` is absent. */
|
|
85
|
+
wrote: boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* A destination `render` was asked to write, as the two spellings it needs.
|
|
89
|
+
*
|
|
90
|
+
* `file` is resolved against the caller's working directory and is what the
|
|
91
|
+
* filesystem is given; `display` is the string the user typed, and is what a
|
|
92
|
+
* diagnostic has to echo so the path in the report is the path they can act on.
|
|
93
|
+
* They travel together because a destination is not usable without both.
|
|
94
|
+
*/
|
|
95
|
+
export interface RenderOut {
|
|
96
|
+
file: string;
|
|
97
|
+
display: string;
|
|
84
98
|
}
|
|
85
99
|
/**
|
|
86
100
|
* Markdown projection of the intent layer (design §9: `attest render`).
|
|
@@ -90,7 +104,9 @@ export interface RenderResult {
|
|
|
90
104
|
* `*.reqs.ts` files, so a committed rendering can be gated without going stale
|
|
91
105
|
* every time a line moves in a test file.
|
|
92
106
|
*/
|
|
93
|
-
export declare function runRender(root: string, options?: ReadOptions
|
|
107
|
+
export declare function runRender(root: string, options?: ReadOptions & {
|
|
108
|
+
out?: RenderOut | undefined;
|
|
109
|
+
}): Promise<RenderResult>;
|
|
94
110
|
/**
|
|
95
111
|
* Freshness gate for a committed rendering (`attest render --check`).
|
|
96
112
|
*
|
package/dist/core/pipeline.js
CHANGED
|
@@ -14,7 +14,7 @@ import { writeAtomic } from './write.js';
|
|
|
14
14
|
import { applyMerge, mergedSpecPath } from './merge.js';
|
|
15
15
|
import { compilerIssue } from './compiler.js';
|
|
16
16
|
import { mkdir, readFile, realpath } from 'node:fs/promises';
|
|
17
|
-
import { basename, dirname, join } from 'node:path';
|
|
17
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
18
18
|
import { isInside, relativePath } from './paths.js';
|
|
19
19
|
import { hasError } from './types.js';
|
|
20
20
|
// The runner half of the engine, reached only when a command actually needs it.
|
|
@@ -405,15 +405,38 @@ export async function runCover(root, options = {}) {
|
|
|
405
405
|
* every time a line moves in a test file.
|
|
406
406
|
*/
|
|
407
407
|
export async function runRender(root, options = {}) {
|
|
408
|
+
// The compiler guard stays the first statement, as it is in every other entry
|
|
409
|
+
// point (ATX-56): a TypeScript with no AST API is a refusal about the
|
|
410
|
+
// toolchain, and it outranks anything this run could say about the caller's
|
|
411
|
+
// paths or registry.
|
|
408
412
|
const unusable = compilerIssue();
|
|
409
413
|
if (unusable)
|
|
410
|
-
return { markdown: '', issues: [unusable] };
|
|
414
|
+
return { markdown: '', issues: [unusable], wrote: false };
|
|
415
|
+
// Then the destination, before the registry is read, so a refused path is the
|
|
416
|
+
// only thing reported: a run that cannot write anywhere has no reason to also
|
|
417
|
+
// tell the user about their registry, and the report a pipeline reads should
|
|
418
|
+
// name the one thing that has to change.
|
|
419
|
+
const dest = options.out ? await resolveOutFile(root, options.out) : undefined;
|
|
420
|
+
if (dest && !dest.ok)
|
|
421
|
+
return { markdown: '', issues: [dest.issue], wrote: false };
|
|
411
422
|
const { registry, issues } = await readRegistry(root, options);
|
|
412
423
|
// A registry that failed to load yields a document that silently omits
|
|
413
|
-
// requirements. An incomplete spec doc is worse than none, so refuse
|
|
424
|
+
// requirements. An incomplete spec doc is worse than none, so refuse — and
|
|
425
|
+
// since the write is below this line, refusing is also what keeps a broken
|
|
426
|
+
// registry from overwriting a good committed document.
|
|
414
427
|
if (hasError(issues))
|
|
415
|
-
return { markdown: '', issues };
|
|
416
|
-
|
|
428
|
+
return { markdown: '', issues, wrote: false };
|
|
429
|
+
const markdown = renderMarkdown(registry);
|
|
430
|
+
// The write lives here rather than in the CLI, and that is the whole of what
|
|
431
|
+
// ATX-73 is structurally about: while it sat in the shell it was the one
|
|
432
|
+
// `writeAtomic` call outside this layer, and it reached the filesystem
|
|
433
|
+
// without the resolution `runInit` had performed since ATX-66. Resolving and
|
|
434
|
+
// writing in one function is what stops the two from coming apart again —
|
|
435
|
+
// a later caller cannot forget a step it never had to take.
|
|
436
|
+
if (!dest)
|
|
437
|
+
return { markdown, issues, wrote: false };
|
|
438
|
+
await writeAtomic(dest.dest, markdown);
|
|
439
|
+
return { markdown, issues, wrote: true };
|
|
417
440
|
}
|
|
418
441
|
/**
|
|
419
442
|
* Freshness gate for a committed rendering (`attest render --check`).
|
|
@@ -423,10 +446,29 @@ export async function runRender(root, options = {}) {
|
|
|
423
446
|
* alongside a gate that fails when the file no longer matches its source.
|
|
424
447
|
*/
|
|
425
448
|
export async function runRenderCheck(root, outFile, target, options = {}) {
|
|
449
|
+
// Called here despite the delegation further down, because the resolution
|
|
450
|
+
// below it runs first: the guard has to be the first statement of an entry
|
|
451
|
+
// point (ATX-56), and delegation cannot supply that once anything precedes
|
|
452
|
+
// it. Same precedence in both, for the reason `runRender` states.
|
|
453
|
+
const unusable = compilerIssue();
|
|
454
|
+
if (unusable)
|
|
455
|
+
return [unusable];
|
|
456
|
+
// Resolved before it is read, not only before it is written. A link along the
|
|
457
|
+
// path makes this compare a fresh document against a file outside the project
|
|
458
|
+
// — and both verdicts that can produce are ones the gate reports normally, so
|
|
459
|
+
// the wrong answer is indistinguishable from a right one.
|
|
460
|
+
//
|
|
461
|
+
// `target` already carries the spelling a diagnostic must echo, so the pair
|
|
462
|
+
// `resolveOutFile` wants is assembled here rather than asked of the caller:
|
|
463
|
+
// taking a `RenderOut` beside a `RenderTarget` would make the shell pass the
|
|
464
|
+
// same string twice, which is a clump waiting to disagree with itself.
|
|
465
|
+
const dest = await resolveOutFile(root, { file: outFile, display: target.display });
|
|
466
|
+
if (!dest.ok)
|
|
467
|
+
return [dest.issue];
|
|
426
468
|
const { markdown, issues } = await runRender(root, options);
|
|
427
469
|
if (hasError(issues))
|
|
428
470
|
return issues;
|
|
429
|
-
const current = await readFile(
|
|
471
|
+
const current = await readFile(dest.dest, 'utf8').catch(() => undefined);
|
|
430
472
|
const stale = staleIssue(target, current, markdown);
|
|
431
473
|
return stale ? [...issues, stale] : issues;
|
|
432
474
|
}
|
|
@@ -467,12 +509,7 @@ export async function runInit(root, names) {
|
|
|
467
509
|
writes.push({ target, dest: resolved.dest });
|
|
468
510
|
}
|
|
469
511
|
else {
|
|
470
|
-
refusals.push(
|
|
471
|
-
level: 'ERROR',
|
|
472
|
-
code: 'unsafe-target-path',
|
|
473
|
-
file: target.file,
|
|
474
|
-
message: `Refusing to write ${target.file}: it resolves to ${resolved.escape}, outside the project. Remove the link at that path and re-run.`,
|
|
475
|
-
});
|
|
512
|
+
refusals.push(unsafeTargetPathIssue(target.file, resolved.escape));
|
|
476
513
|
}
|
|
477
514
|
}
|
|
478
515
|
if (refusals.length > 0)
|
|
@@ -485,6 +522,61 @@ export async function runInit(root, names) {
|
|
|
485
522
|
}
|
|
486
523
|
return { files, issues };
|
|
487
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Where a `render` destination really lands, or the refusal that a link
|
|
527
|
+
* redirected it (design §9: ownership of the path is checked, and who named the
|
|
528
|
+
* path decides what the refusal is about).
|
|
529
|
+
*
|
|
530
|
+
* `init` names its own destinations, so every one of them is checked; `--out`
|
|
531
|
+
* is the caller's path, and that is the whole of the difference between the two
|
|
532
|
+
* callers. A path spelled outside the project is the user asking for a file
|
|
533
|
+
* outside the project — `--out ../site/SPEC.md` is a destination, not an
|
|
534
|
+
* escape, and refusing it would be refusing the flag. What is not visible in
|
|
535
|
+
* the argument is a *link* along a path that reads as staying inside, which is
|
|
536
|
+
* the one a checkout can plant: the victim's `--out` is the string their README
|
|
537
|
+
* or CI already documents, and the content redirected is the document their
|
|
538
|
+
* repository wrote.
|
|
539
|
+
*
|
|
540
|
+
* So the check applies exactly when the destination is lexically inside the
|
|
541
|
+
* root, and it is `resolveDest`'s, unchanged. The leaf needs no separate
|
|
542
|
+
* defence and never did — `writeAtomic` lands by `rename`, which replaces a
|
|
543
|
+
* symlink rather than following it — but it is resolved with the rest anyway,
|
|
544
|
+
* because a link there is still a file the user made deliberately and this
|
|
545
|
+
* refuses rather than destroys it.
|
|
546
|
+
*
|
|
547
|
+
* Both entry points that name a destination come through here — the one that
|
|
548
|
+
* writes it and the one that reads it back to date it — which is why this is
|
|
549
|
+
* private to this module rather than something a shell is trusted to call.
|
|
550
|
+
*/
|
|
551
|
+
async function resolveOutFile(root, out) {
|
|
552
|
+
const dest = resolve(out.file);
|
|
553
|
+
const realRoot = await realpath(root).catch(() => root);
|
|
554
|
+
// Asked against both spellings of the root, because they differ whenever the
|
|
555
|
+
// project itself sits under a link: `dest` is built from the caller's cwd, so
|
|
556
|
+
// it is lexical, and testing only the resolved root would skip the check for
|
|
557
|
+
// every such checkout — failing open on exactly the layout that has links in
|
|
558
|
+
// it. `''` is the root itself, which is a directory and not a destination.
|
|
559
|
+
const rel = isInside(root, dest)
|
|
560
|
+
? relativePath(root, dest)
|
|
561
|
+
: isInside(realRoot, dest)
|
|
562
|
+
? relativePath(realRoot, dest)
|
|
563
|
+
: undefined;
|
|
564
|
+
if (rel === undefined || rel === '')
|
|
565
|
+
return { ok: true, dest };
|
|
566
|
+
const resolved = await resolveDest(realRoot, rel);
|
|
567
|
+
return resolved.ok
|
|
568
|
+
? { ok: true, dest: resolved.dest }
|
|
569
|
+
: { ok: false, issue: unsafeTargetPathIssue(out.display, resolved.escape) };
|
|
570
|
+
}
|
|
571
|
+
/** The one refusal both destination checks report (design §9). */
|
|
572
|
+
function unsafeTargetPathIssue(display, escape) {
|
|
573
|
+
return {
|
|
574
|
+
level: 'ERROR',
|
|
575
|
+
code: 'unsafe-target-path',
|
|
576
|
+
file: display,
|
|
577
|
+
message: `Refusing to write ${display}: it resolves to ${escape}, outside the project. Remove the link at that path and re-run.`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
488
580
|
/**
|
|
489
581
|
* Where a target's file really goes, or the path outside the project that
|
|
490
582
|
* asking put it at (design §9: ownership of the path is checked, not assumed).
|
|
@@ -1,17 +1,26 @@
|
|
|
1
|
-
import type { AttestPlan, Outcome, RunResult } from './types.js';
|
|
1
|
+
import type { AttestPlan, Outcome, RunResult, ScenarioRef } from './types.js';
|
|
2
2
|
/** Where the record lives, relative to the project root. */
|
|
3
3
|
export declare function redRecordPath(root: string, changeName: string): string;
|
|
4
4
|
/** The file name, exported so a diagnostic can name it without rebuilding it. */
|
|
5
5
|
export declare const RED_RECORD_FILE = "first-run.json";
|
|
6
6
|
/**
|
|
7
|
-
* reqId -> scenario name -> the outcome of its first observed run.
|
|
7
|
+
* reqId -> spec file -> scenario name -> the outcome of its first observed run.
|
|
8
8
|
*
|
|
9
9
|
* Deliberately not a list of "red scenarios": a scenario whose first run passed
|
|
10
10
|
* is the case mechanism 2 exists to catch, so it has to be recorded as a fact
|
|
11
11
|
* rather than as an absence. An absence then means only one thing — never
|
|
12
12
|
* observed at all — and the gate can report the two separately.
|
|
13
|
+
*
|
|
14
|
+
* **The file level is what identifies a scenario, and it was missing.** A
|
|
15
|
+
* scenario is `(reqId, file, name)` in the static plan and was `(reqId, name)`
|
|
16
|
+
* here, so two spec files declaring the same name under one added requirement
|
|
17
|
+
* shared an entry — and because the record is monotonic toward `fail`, one of
|
|
18
|
+
* them going red satisfied the other's first-red obligation. Nested rather than
|
|
19
|
+
* a composite key string, so no separator has to be chosen that a path or a
|
|
20
|
+
* scenario name could contain, and so the committed file stays something a
|
|
21
|
+
* reviewer reads (design §6).
|
|
13
22
|
*/
|
|
14
|
-
export type RedRecord = Record<string, Record<string, Outcome
|
|
23
|
+
export type RedRecord = Record<string, Record<string, Record<string, Outcome>>>;
|
|
15
24
|
/**
|
|
16
25
|
* Read the record for a change. A missing or unreadable file is an empty
|
|
17
26
|
* record, never an error: the first stage-1 run legitimately finds nothing, and
|
|
@@ -34,7 +43,7 @@ export declare function readRedRecord(root: string, changeName: string): Promise
|
|
|
34
43
|
* what it must not do is answer from an inherited key whoever constructed the
|
|
35
44
|
* object it was handed. `record[reqId]?.[scenario]` did exactly that.
|
|
36
45
|
*/
|
|
37
|
-
export declare function recordedOutcome(record: RedRecord,
|
|
46
|
+
export declare function recordedOutcome(record: RedRecord, ref: ScenarioRef): Outcome | undefined;
|
|
38
47
|
/**
|
|
39
48
|
* Whether this scenario has satisfied the first-red obligation (design §6).
|
|
40
49
|
*
|
|
@@ -44,7 +53,7 @@ export declare function recordedOutcome(record: RedRecord, reqId: string, scenar
|
|
|
44
53
|
* apart — the same argument that extracted `declaredNotRunIssues` and
|
|
45
54
|
* `uncoveredIssues` from the commands that had a copy each.
|
|
46
55
|
*/
|
|
47
|
-
export declare function hasRecordedRed(record: RedRecord,
|
|
56
|
+
export declare function hasRecordedRed(record: RedRecord, ref: ScenarioRef): boolean;
|
|
48
57
|
/**
|
|
49
58
|
* Fold this run's outcomes into the record, for the scenarios that carry an
|
|
50
59
|
* obligation — those covering a requirement this change ADDs.
|
package/dist/core/red-record.js
CHANGED
|
@@ -47,6 +47,32 @@ 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
|
+
import { mergedSpecPath } from './merge.js';
|
|
51
|
+
import { toPosixPath } from './paths.js';
|
|
52
|
+
/**
|
|
53
|
+
* The file half of a scenario's identity, as this record spells it.
|
|
54
|
+
*
|
|
55
|
+
* A scenario is `(reqId, file, name)` — but the one file this record is about
|
|
56
|
+
* is a **proposed** spec, and `--apply` renames it in place as step 2 of the
|
|
57
|
+
* merge. Keyed by the name on disk, a resume after that rename looked up
|
|
58
|
+
* `app2.spec.ts` in a record written under `app2.proposed.spec.ts`, found
|
|
59
|
+
* nothing, and blocked a change whose scenarios had been observed red — the
|
|
60
|
+
* gate refusing its own evidence. Rewriting the record beside the rename was
|
|
61
|
+
* the alternative and is worse: two files that must move together, in a design
|
|
62
|
+
* whose whole resume story is that each step is separately idempotent.
|
|
63
|
+
*
|
|
64
|
+
* So the key is the **merged** spelling from the start, which the rename cannot
|
|
65
|
+
* move. It still separates two spec files, which is the collapse this key
|
|
66
|
+
* exists to prevent; what it deliberately does not separate is one file from
|
|
67
|
+
* its own proposed name, because those are the same file.
|
|
68
|
+
*
|
|
69
|
+
* Through `toPosixPath` because `mergedSpecPath` rebuilds the path with
|
|
70
|
+
* `node:path`, which on Windows hands back a backslash — and this is a
|
|
71
|
+
* comparison key against a plan built on POSIX separators (`paths.ts`).
|
|
72
|
+
*/
|
|
73
|
+
function fileKey(file) {
|
|
74
|
+
return toPosixPath(mergedSpecPath(file));
|
|
75
|
+
}
|
|
50
76
|
/**
|
|
51
77
|
* **The record is a map, so it is built without a prototype.**
|
|
52
78
|
*
|
|
@@ -92,7 +118,7 @@ export const RED_RECORD_FILE = 'first-run.json';
|
|
|
92
118
|
* The gate was never at risk — `hasRecordedRed` compares against `'fail'`, so an
|
|
93
119
|
* unrecognised value blocks exactly like a missing one. This closes the report.
|
|
94
120
|
*/
|
|
95
|
-
const RedRecordSchema = z.record(z.string(), z.record(z.string(), z.enum(['pass', 'fail'])));
|
|
121
|
+
const RedRecordSchema = z.record(z.string(), z.record(z.string(), z.record(z.string(), z.enum(['pass', 'fail']))));
|
|
96
122
|
/**
|
|
97
123
|
* Read the record for a change. A missing or unreadable file is an empty
|
|
98
124
|
* record, never an error: the first stage-1 run legitimately finds nothing, and
|
|
@@ -122,8 +148,15 @@ export async function readRedRecord(root, changeName) {
|
|
|
122
148
|
}
|
|
123
149
|
// `JSON.parse` yields `any`; nothing about the file is known until the schema
|
|
124
150
|
// says so, including whether it is an object at all (`JSON.parse('null')`).
|
|
125
|
-
|
|
126
|
-
|
|
151
|
+
// The version is checked rather than inferred from whether the shape happens
|
|
152
|
+
// to parse. A version-1 record keyed `reqId -> name` would fail the schema
|
|
153
|
+
// anyway and be discarded whole, which is the safe direction — but "discarded
|
|
154
|
+
// because the format moved" and "discarded because the file is corrupt" are
|
|
155
|
+
// different facts, and reading one as the other is how a format change gets
|
|
156
|
+
// made without anyone deciding to make it.
|
|
157
|
+
if (!isRecord(parsed) || parsed['version'] !== 2)
|
|
158
|
+
return emptyMap();
|
|
159
|
+
const result = RedRecordSchema.safeParse(parsed['firstRun']);
|
|
127
160
|
return result.success ? adopt(result.data) : emptyMap();
|
|
128
161
|
}
|
|
129
162
|
/**
|
|
@@ -139,8 +172,16 @@ export async function readRedRecord(root, changeName) {
|
|
|
139
172
|
*/
|
|
140
173
|
function adopt(parsed) {
|
|
141
174
|
const record = emptyMap();
|
|
142
|
-
for (const [id,
|
|
143
|
-
|
|
175
|
+
for (const [id, byFile] of Object.entries(parsed)) {
|
|
176
|
+
const files = emptyMap();
|
|
177
|
+
// Every level, not just the two that existed before: the file level is as
|
|
178
|
+
// much a key taken from a string this module did not choose as the other
|
|
179
|
+
// two, and a container with a prototype is what the whole comment above is
|
|
180
|
+
// about.
|
|
181
|
+
for (const [file, outcomes] of Object.entries(byFile)) {
|
|
182
|
+
files[file] = Object.assign(emptyMap(), outcomes);
|
|
183
|
+
}
|
|
184
|
+
record[id] = files;
|
|
144
185
|
}
|
|
145
186
|
return record;
|
|
146
187
|
}
|
|
@@ -153,13 +194,17 @@ function adopt(parsed) {
|
|
|
153
194
|
* what it must not do is answer from an inherited key whoever constructed the
|
|
154
195
|
* object it was handed. `record[reqId]?.[scenario]` did exactly that.
|
|
155
196
|
*/
|
|
156
|
-
export function recordedOutcome(record,
|
|
157
|
-
if (!Object.hasOwn(record, reqId))
|
|
197
|
+
export function recordedOutcome(record, ref) {
|
|
198
|
+
if (!Object.hasOwn(record, ref.reqId))
|
|
158
199
|
return undefined;
|
|
159
|
-
const
|
|
160
|
-
|
|
200
|
+
const byFile = record[ref.reqId];
|
|
201
|
+
const key = fileKey(ref.file);
|
|
202
|
+
if (byFile === undefined || !Object.hasOwn(byFile, key))
|
|
161
203
|
return undefined;
|
|
162
|
-
|
|
204
|
+
const outcomes = byFile[key];
|
|
205
|
+
if (outcomes === undefined || !Object.hasOwn(outcomes, ref.name))
|
|
206
|
+
return undefined;
|
|
207
|
+
return outcomes[ref.name];
|
|
163
208
|
}
|
|
164
209
|
/**
|
|
165
210
|
* Whether this scenario has satisfied the first-red obligation (design §6).
|
|
@@ -170,8 +215,8 @@ export function recordedOutcome(record, reqId, scenario) {
|
|
|
170
215
|
* apart — the same argument that extracted `declaredNotRunIssues` and
|
|
171
216
|
* `uncoveredIssues` from the commands that had a copy each.
|
|
172
217
|
*/
|
|
173
|
-
export function hasRecordedRed(record,
|
|
174
|
-
return recordedOutcome(record,
|
|
218
|
+
export function hasRecordedRed(record, ref) {
|
|
219
|
+
return recordedOutcome(record, ref) === 'fail';
|
|
175
220
|
}
|
|
176
221
|
/**
|
|
177
222
|
* Fold this run's outcomes into the record, for the scenarios that carry an
|
|
@@ -190,42 +235,55 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
|
|
|
190
235
|
// machines write byte-identical files (the same reason `render` orders ids
|
|
191
236
|
// through one code-unit comparator).
|
|
192
237
|
for (const id of Object.keys(existing).sort(byCodeUnit)) {
|
|
193
|
-
const
|
|
194
|
-
if (
|
|
238
|
+
const byFile = existing[id];
|
|
239
|
+
if (byFile === undefined)
|
|
195
240
|
continue;
|
|
196
|
-
|
|
241
|
+
const files = emptyMap();
|
|
242
|
+
for (const file of Object.keys(byFile).sort(byCodeUnit)) {
|
|
243
|
+
const outcomes = byFile[file];
|
|
244
|
+
if (outcomes === undefined)
|
|
245
|
+
continue;
|
|
246
|
+
files[file] = Object.assign(emptyMap(), outcomes);
|
|
247
|
+
}
|
|
248
|
+
record[id] = files;
|
|
197
249
|
}
|
|
198
250
|
let changed = false;
|
|
199
251
|
for (const s of plan.scenarios) {
|
|
200
252
|
if (!added.has(s.reqId))
|
|
201
253
|
continue;
|
|
202
|
-
const outcome = run.outcomes.get(s.reqId)?.get(s.name);
|
|
254
|
+
const outcome = run.outcomes.get(s.reqId)?.get(s.file)?.get(s.name);
|
|
203
255
|
// No outcome means the scenario did not execute. That is `declared-not-run`,
|
|
204
256
|
// reported by the gate on its own terms; writing nothing here leaves the
|
|
205
257
|
// obligation unobserved rather than inventing a state for it.
|
|
206
258
|
if (!outcome)
|
|
207
259
|
continue;
|
|
208
260
|
const forId = (record[s.reqId] ??= emptyMap());
|
|
261
|
+
const forFile = (forId[fileKey(s.file)] ??= emptyMap());
|
|
209
262
|
// Monotonic toward `fail`: a recorded fail is final, a recorded pass can
|
|
210
263
|
// still be corrected by a real one. See the note at the top of this file.
|
|
211
|
-
if (
|
|
264
|
+
if (forFile[s.name] === 'fail' || forFile[s.name] === outcome)
|
|
212
265
|
continue;
|
|
213
|
-
|
|
266
|
+
forFile[s.name] = outcome;
|
|
214
267
|
changed = true;
|
|
215
268
|
}
|
|
216
|
-
// Sort within each requirement for the same
|
|
269
|
+
// Sort within each requirement, and within each file, for the same
|
|
270
|
+
// byte-stability reason.
|
|
217
271
|
for (const id of Object.keys(record)) {
|
|
218
|
-
const
|
|
219
|
-
for (const
|
|
220
|
-
sorted
|
|
272
|
+
const sortedFiles = emptyMap();
|
|
273
|
+
for (const file of Object.keys(record[id]).sort(byCodeUnit)) {
|
|
274
|
+
const sorted = emptyMap();
|
|
275
|
+
for (const name of Object.keys(record[id][file]).sort(byCodeUnit)) {
|
|
276
|
+
sorted[name] = record[id][file][name];
|
|
277
|
+
}
|
|
278
|
+
sortedFiles[file] = sorted;
|
|
221
279
|
}
|
|
222
|
-
record[id] =
|
|
280
|
+
record[id] = sortedFiles;
|
|
223
281
|
}
|
|
224
282
|
return { record, changed };
|
|
225
283
|
}
|
|
226
284
|
/** Serialise the record. A trailing newline, so the file is a well-formed text file. */
|
|
227
285
|
export function serialiseRedRecord(changeName, record) {
|
|
228
|
-
const file = { version:
|
|
286
|
+
const file = { version: 2, change: changeName, firstRun: record };
|
|
229
287
|
return `${JSON.stringify(file, null, 2)}\n`;
|
|
230
288
|
}
|
|
231
289
|
/** Container check only — what is *in* it is the schema's business, not this. */
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One structural failure from validating a registry.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not the validator's own issue type: `issues` is reached through
|
|
5
|
+
* the `./define` subpath, so its element type is a public contract, and §10's
|
|
6
|
+
* fourth encapsulation rule keeps a dependency's type off one. These two fields
|
|
7
|
+
* are the whole of what this project consumes — nothing reads `code`,
|
|
8
|
+
* `expected`, `received` or `fatal`.
|
|
9
|
+
*
|
|
10
|
+
* `path` is `PropertyKey[]` rather than `(string | number)[]` because that is
|
|
11
|
+
* the wider of the two spellings zod has used, so a validator issue assigns to
|
|
12
|
+
* this without a cast in either direction. Keep it the wider one: narrowing it
|
|
13
|
+
* would put the next reshape of that type back on the boundary.
|
|
14
|
+
*/
|
|
15
|
+
export interface RegistryValidationIssue {
|
|
16
|
+
path: PropertyKey[];
|
|
17
|
+
message: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The message `RegistryValidationError` carries, available without one.
|
|
21
|
+
*
|
|
22
|
+
* Three sites report `registry-invalid` — the throwing path in
|
|
23
|
+
* `defineRequirements`, and the two readers in `locate.ts` and
|
|
24
|
+
* `static-registry.ts` that report it without throwing — and their messages
|
|
25
|
+
* have to be identical rather than merely similar. Sharing this function is
|
|
26
|
+
* what makes that structural rather than a coincidence maintained by hand, so
|
|
27
|
+
* a new site formats through here and does not grow a fourth spelling.
|
|
28
|
+
*/
|
|
29
|
+
export declare function registryValidationMessage(issues: readonly RegistryValidationIssue[]): string;
|
|
30
|
+
//# sourceMappingURL=registry-issues.d.ts.map
|