@am_shork/attest 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1053 -172
- package/README.md +1 -1
- package/dist/cli/index.js +11 -8
- package/dist/cli/json.d.ts +26 -1
- package/dist/cli/json.js +28 -2
- package/dist/core/compiler.d.ts +32 -0
- package/dist/core/compiler.js +78 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +1 -0
- package/dist/core/gate.js +3 -3
- package/dist/core/locate.d.ts +22 -2
- package/dist/core/locate.js +33 -8
- package/dist/core/parser.d.ts +12 -0
- package/dist/core/parser.js +18 -2
- package/dist/core/pipeline.d.ts +17 -1
- package/dist/core/pipeline.js +109 -17
- package/dist/core/red-record.d.ts +14 -5
- package/dist/core/red-record.js +82 -24
- package/dist/core/registry-issues.d.ts +30 -0
- package/dist/core/registry-issues.js +26 -0
- package/dist/core/registry.d.ts +12 -5
- package/dist/core/registry.js +10 -8
- package/dist/core/runner.js +21 -9
- package/dist/core/schema.d.ts +32 -27
- package/dist/core/schema.js +33 -5
- package/dist/core/skill.js +13 -0
- package/dist/core/splice.js +45 -14
- package/dist/core/static-registry.d.ts +34 -2
- package/dist/core/static-registry.js +138 -12
- package/dist/core/status.js +2 -2
- package/dist/core/terminal.js +5 -2
- package/dist/core/types.d.ts +39 -10
- package/dist/core/validator.d.ts +9 -4
- package/dist/core/validator.js +74 -21
- package/package.json +2 -2
package/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/compiler.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
1
2
|
import type { Issue } from './types.js';
|
|
2
3
|
/**
|
|
3
4
|
* The range the readers are written against.
|
|
@@ -36,5 +37,36 @@ interface CompilerSurface {
|
|
|
36
37
|
* requirement and every file in the project is equally unreadable because of it.
|
|
37
38
|
*/
|
|
38
39
|
export declare function compilerIssue(compiler?: CompilerSurface): Issue | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* A source that does not compile, thrown where a reader has no failure channel
|
|
42
|
+
* of its own.
|
|
43
|
+
*
|
|
44
|
+
* `parseSpecFile` returns a plan and nothing else, so its refusal has to be a
|
|
45
|
+
* throw — and `parseSpecs` already catches every throw per file (ATX-65). The
|
|
46
|
+
* class exists so the *line* survives that trip: without it the position is
|
|
47
|
+
* prose inside the message, and `unreadable-file` would carry `line` when a
|
|
48
|
+
* registry failed to compile and not when a spec did, for one condition.
|
|
49
|
+
*/
|
|
50
|
+
export declare class SourceNotCompiled extends Error {
|
|
51
|
+
readonly line?: number | undefined;
|
|
52
|
+
constructor(message: string, line?: number | undefined);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* `ts.createSourceFile`, refusing a source that does not compile.
|
|
56
|
+
*
|
|
57
|
+
* Every reader that turns source into a *value* goes through this. The one that
|
|
58
|
+
* deliberately does not is `declaredIdsFromSource`, which runs only on files a
|
|
59
|
+
* reader has already refused — recovering the ids of a broken registry is the
|
|
60
|
+
* whole of its job, and a strict parse there would take the diagnostic that
|
|
61
|
+
* names them away again.
|
|
62
|
+
*/
|
|
63
|
+
export declare function parseSource(file: string, source: string): {
|
|
64
|
+
sf: ts.SourceFile;
|
|
65
|
+
} | {
|
|
66
|
+
error: {
|
|
67
|
+
message: string;
|
|
68
|
+
line?: number;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
39
71
|
export {};
|
|
40
72
|
//# sourceMappingURL=compiler.d.ts.map
|
package/dist/core/compiler.js
CHANGED
|
@@ -61,4 +61,82 @@ export function compilerIssue(compiler = ts) {
|
|
|
61
61
|
`If a resolution or override pins the compiler for the whole tree, exclude Attest from it or move that pin back into the supported range.`,
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The syntax error a source carries, or `undefined` when it compiles.
|
|
66
|
+
*
|
|
67
|
+
* **`ts.createSourceFile` recovers**: handed a file that does not compile it
|
|
68
|
+
* still returns a tree, built from what the parser guessed the author meant. So
|
|
69
|
+
* a tree is not evidence that a file compiles, and every reader that turns
|
|
70
|
+
* source into a value has to ask separately (ATX-69).
|
|
71
|
+
*
|
|
72
|
+
* Lives here because this module already owns what the compiler does and does
|
|
73
|
+
* not promise — it names `parser.ts`, `static-registry.ts` and `splice.ts` as
|
|
74
|
+
* the three that open with `createSourceFile`, which is exactly the set that has
|
|
75
|
+
* to ask this question.
|
|
76
|
+
*
|
|
77
|
+
* **Syntactic only, and that is the whole of what keeps it safe.**
|
|
78
|
+
* `parseDiagnostics` is populated by the parser, so a type error, an unresolved
|
|
79
|
+
* import and a name that does not exist are all *absent* from it — checking it
|
|
80
|
+
* cannot turn Attest into a typechecker of someone's project, which it is not
|
|
81
|
+
* and must not become.
|
|
82
|
+
*
|
|
83
|
+
* What "does not compile" means is "does not compile under the TypeScript Attest
|
|
84
|
+
* resolved", which is the same limit ATX-65 records about parser depth: the
|
|
85
|
+
* bound belongs to the compiler this is built on, not to a promise this project
|
|
86
|
+
* is in a position to make.
|
|
87
|
+
*/
|
|
88
|
+
function syntaxError(sf) {
|
|
89
|
+
// `parseDiagnostics` is not on the public `SourceFile` type — it is
|
|
90
|
+
// TypeScript's own field, which is why nothing here found it by reading the
|
|
91
|
+
// signature. Declared *optional* so reaching for it stays honest: a compiler
|
|
92
|
+
// that stops populating it degrades to reading the recovered tree rather than
|
|
93
|
+
// throwing, which is the same fail-open `compilerIssue` above is written for.
|
|
94
|
+
const diagnostics = sf.parseDiagnostics;
|
|
95
|
+
const first = diagnostics?.[0];
|
|
96
|
+
if (!first)
|
|
97
|
+
return undefined;
|
|
98
|
+
// The first error only. A syntax error cascades — a stray token measured four
|
|
99
|
+
// of them, where the truncation this was found on measured one — and the ones
|
|
100
|
+
// after the first are artefacts of the parser's recovery from it, so listing
|
|
101
|
+
// them describes the recovery rather than the file.
|
|
102
|
+
const message = ts.flattenDiagnosticMessageText(first.messageText, ' ');
|
|
103
|
+
const count = diagnostics.length;
|
|
104
|
+
const suffix = count > 1 ? ` (and ${count - 1} more)` : '';
|
|
105
|
+
return {
|
|
106
|
+
message: `${message}${suffix}`,
|
|
107
|
+
line: sf.getLineAndCharacterOfPosition(first.start).line + 1,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A source that does not compile, thrown where a reader has no failure channel
|
|
112
|
+
* of its own.
|
|
113
|
+
*
|
|
114
|
+
* `parseSpecFile` returns a plan and nothing else, so its refusal has to be a
|
|
115
|
+
* throw — and `parseSpecs` already catches every throw per file (ATX-65). The
|
|
116
|
+
* class exists so the *line* survives that trip: without it the position is
|
|
117
|
+
* prose inside the message, and `unreadable-file` would carry `line` when a
|
|
118
|
+
* registry failed to compile and not when a spec did, for one condition.
|
|
119
|
+
*/
|
|
120
|
+
export class SourceNotCompiled extends Error {
|
|
121
|
+
line;
|
|
122
|
+
constructor(message, line) {
|
|
123
|
+
super(message);
|
|
124
|
+
this.line = line;
|
|
125
|
+
this.name = 'SourceNotCompiled';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* `ts.createSourceFile`, refusing a source that does not compile.
|
|
130
|
+
*
|
|
131
|
+
* Every reader that turns source into a *value* goes through this. The one that
|
|
132
|
+
* deliberately does not is `declaredIdsFromSource`, which runs only on files a
|
|
133
|
+
* reader has already refused — recovering the ids of a broken registry is the
|
|
134
|
+
* whole of its job, and a strict parse there would take the diagnostic that
|
|
135
|
+
* names them away again.
|
|
136
|
+
*/
|
|
137
|
+
export function parseSource(file, source) {
|
|
138
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
|
|
139
|
+
const error = syntaxError(sf);
|
|
140
|
+
return error ? { error } : { sf };
|
|
141
|
+
}
|
|
64
142
|
//# sourceMappingURL=compiler.js.map
|
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", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file", "unsafe-target-path"];
|
|
11
|
+
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-from-failed-registry", "orphan-test", "possible-drift", "proposed-spec-name-taken", "proposed-spec-unclaimed", "rationale-placeholder", "registry-invalid", "registry-no-default", "registry-not-static", "rename-source-missing", "rename-target-exists", "rename-target-invalid", "spec-in-change-dir", "spec-load-failed", "stale-spec-doc", "tests-red", "unbound-param", "uncovered-requirement", "unknown-target", "unreadable-file", "unsafe-target-path"];
|
|
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/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.d.ts
CHANGED
|
@@ -91,6 +91,20 @@ export declare function evalReader(loader: Loader): RegistryReader;
|
|
|
91
91
|
* execute user code — and were advertised as static while doing exactly that.
|
|
92
92
|
*/
|
|
93
93
|
export declare function staticReader(): RegistryReader;
|
|
94
|
+
/**
|
|
95
|
+
* A registry file that contributed no ids, and the ids its source still names.
|
|
96
|
+
*
|
|
97
|
+
* `ids` is empty when nothing could be recovered — a registry the file computes
|
|
98
|
+
* rather than writes out, or a source that could not be read at all — and an
|
|
99
|
+
* empty list is the honest answer, not a failure: it says the ids of that file
|
|
100
|
+
* are unknown, which is a different thing from that file declaring none.
|
|
101
|
+
*/
|
|
102
|
+
export interface UnreadableRegistry {
|
|
103
|
+
/** Path relative to the project root, as every `Issue.file` is. */
|
|
104
|
+
readonly file: string;
|
|
105
|
+
/** Requirement ids recovered from the source; possibly none. */
|
|
106
|
+
readonly ids: readonly string[];
|
|
107
|
+
}
|
|
94
108
|
/**
|
|
95
109
|
* Read and merge every `*.reqs.ts` registry under root, with the given reader.
|
|
96
110
|
* A registry the reader rejects becomes its own ERROR; a duplicate id across
|
|
@@ -110,7 +124,8 @@ export declare function loadRegistry(root: string, reader: RegistryReader, files
|
|
|
110
124
|
issues: Issue[];
|
|
111
125
|
prefixOwners: Record<string, string>;
|
|
112
126
|
/**
|
|
113
|
-
* Registry files that contributed **no ids**, relative to `root
|
|
127
|
+
* Registry files that contributed **no ids**, relative to `root`, each with
|
|
128
|
+
* whatever ids its source still says it declares.
|
|
114
129
|
*
|
|
115
130
|
* Not the same question as "did loading produce an ERROR": `duplicate-prefix`
|
|
116
131
|
* and `duplicate-requirement` are ERRORs raised *after* a successful read, and
|
|
@@ -119,8 +134,13 @@ export declare function loadRegistry(root: string, reader: RegistryReader, files
|
|
|
119
134
|
* re-derived that from the issue codes would be maintaining a second answer
|
|
120
135
|
* to a question this loop already knows — the mistake `prefixOwners` is here
|
|
121
136
|
* to avoid one shape of.
|
|
137
|
+
*
|
|
138
|
+
* The ids come with the file for the same reason: `validateStructure` has to
|
|
139
|
+
* separate a scenario attesting a *broken* file's requirement from one
|
|
140
|
+
* attesting an id that does not exist, and re-deriving which file an id
|
|
141
|
+
* belongs to somewhere else would be that same second answer.
|
|
122
142
|
*/
|
|
123
|
-
|
|
143
|
+
unreadable: UnreadableRegistry[];
|
|
124
144
|
}>;
|
|
125
145
|
/**
|
|
126
146
|
* The part of an id that names the space it lives in — `AUTH` of `AUTH-3`.
|
package/dist/core/locate.js
CHANGED
|
@@ -4,10 +4,11 @@ import { readdir, readFile } from 'node:fs/promises';
|
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import { relativePath } from './paths.js';
|
|
6
6
|
import { parseSpecFile } from './parser.js';
|
|
7
|
-
import { readRegistrySource } from './static-registry.js';
|
|
8
|
-
import {
|
|
7
|
+
import { declaredIdsFromSource, readRegistrySource } from './static-registry.js';
|
|
8
|
+
import { registryValidationMessage } from './registry-issues.js';
|
|
9
9
|
import { RegistrySchema } from './schema.js';
|
|
10
10
|
import { byCodeUnit } from './order.js';
|
|
11
|
+
import { SourceNotCompiled } from './compiler.js';
|
|
11
12
|
// Proposed changes and archived changes are excluded from normal scanning:
|
|
12
13
|
// a change's requirements/specs only count once its gate passes and it is
|
|
13
14
|
// merged (design §7, §8). `attest archive <name>` includes them explicitly.
|
|
@@ -107,11 +108,17 @@ function unreadableIssue(err) {
|
|
|
107
108
|
// the run, and narrowing would make that promise true only of the one trigger
|
|
108
109
|
// that has been measured.
|
|
109
110
|
const detail = err instanceof Error ? err.message : String(err);
|
|
111
|
+
// The one throw that carries a position: a source that does not compile knows
|
|
112
|
+
// which line stopped it, and a syntax error without a line is most of the
|
|
113
|
+
// diagnostic gone. Narrowing to enrich, never to decide — everything still
|
|
114
|
+
// arrives here and still becomes this issue, which is the promise above.
|
|
115
|
+
const line = err instanceof SourceNotCompiled ? err.line : undefined;
|
|
110
116
|
return {
|
|
111
117
|
level: 'ERROR',
|
|
112
118
|
code: 'unreadable-file',
|
|
113
119
|
message: `Could not be read, so nothing in it was checked: ${detail}. ` +
|
|
114
120
|
`Everything else in this run was still reported.`,
|
|
121
|
+
...(line === undefined ? {} : { line }),
|
|
115
122
|
};
|
|
116
123
|
}
|
|
117
124
|
/**
|
|
@@ -184,13 +191,13 @@ export function evalReader(loader) {
|
|
|
184
191
|
const parsed = RegistrySchema.safeParse(exported);
|
|
185
192
|
if (!parsed.success) {
|
|
186
193
|
// The same code, the same prefix and the same rendering of the field
|
|
187
|
-
// errors as `readRegistrySource` —
|
|
188
|
-
//
|
|
194
|
+
// errors as `readRegistrySource` — sharing one formatter keeps the
|
|
195
|
+
// message identical rather than merely similar.
|
|
189
196
|
return {
|
|
190
197
|
issue: {
|
|
191
198
|
level: 'ERROR',
|
|
192
199
|
code: 'registry-invalid',
|
|
193
|
-
message: `Failed to load registry: ${
|
|
200
|
+
message: `Failed to load registry: ${registryValidationMessage(parsed.error.issues)}`,
|
|
194
201
|
},
|
|
195
202
|
};
|
|
196
203
|
}
|
|
@@ -226,6 +233,18 @@ export function staticReader() {
|
|
|
226
233
|
},
|
|
227
234
|
};
|
|
228
235
|
}
|
|
236
|
+
/** Ids recoverable from a registry file that would not load; none if it will not even open. */
|
|
237
|
+
async function declaredIds(absPath) {
|
|
238
|
+
try {
|
|
239
|
+
return declaredIdsFromSource(basename(absPath), await readFile(absPath, 'utf8'));
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
// The file that failed to load is already reported. A second failure
|
|
243
|
+
// reading it changes nothing about that diagnosis and must not replace it
|
|
244
|
+
// with a crash — this path only ever *adds* precision to another finding.
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
229
248
|
/**
|
|
230
249
|
* Read and merge every `*.reqs.ts` registry under root, with the given reader.
|
|
231
250
|
* A registry the reader rejects becomes its own ERROR; a duplicate id across
|
|
@@ -247,7 +266,7 @@ export async function loadRegistry(root, reader, files) {
|
|
|
247
266
|
const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await readGuarded(reader, file) })));
|
|
248
267
|
const registry = {};
|
|
249
268
|
const issues = [];
|
|
250
|
-
const
|
|
269
|
+
const unreadable = [];
|
|
251
270
|
// Which file first claimed each id prefix. The prefix is the only unit above
|
|
252
271
|
// the requirement (design §11) and nothing allocates it, so two files
|
|
253
272
|
// claiming one is the collision no command would otherwise report — the ids
|
|
@@ -263,7 +282,13 @@ export async function loadRegistry(root, reader, files) {
|
|
|
263
282
|
const { outcome } = entry;
|
|
264
283
|
if ('issue' in outcome) {
|
|
265
284
|
issues.push({ ...outcome.issue, file: display });
|
|
266
|
-
|
|
285
|
+
// Read the source again for the ids alone. Both readers land here, and the
|
|
286
|
+
// evaluating one has not read the text at all — a file that threw on
|
|
287
|
+
// import produced an exception and no bytes. One extra read of a file that
|
|
288
|
+
// is already an ERROR is not a cost worth arranging around, and sharing
|
|
289
|
+
// the reader's source instead would mean the two readers passing different
|
|
290
|
+
// things to one diagnostic.
|
|
291
|
+
unreadable.push({ file: display, ids: await declaredIds(entry.file) });
|
|
267
292
|
continue;
|
|
268
293
|
}
|
|
269
294
|
// One issue per colliding prefix rather than per requirement: the fact is
|
|
@@ -301,7 +326,7 @@ export async function loadRegistry(root, reader, files) {
|
|
|
301
326
|
}
|
|
302
327
|
}
|
|
303
328
|
}
|
|
304
|
-
return { registry, issues, prefixOwners: Object.fromEntries(prefixOwner),
|
|
329
|
+
return { registry, issues, prefixOwners: Object.fromEntries(prefixOwner), unreadable };
|
|
305
330
|
}
|
|
306
331
|
/**
|
|
307
332
|
* The part of an id that names the space it lives in — `AUTH` of `AUTH-3`.
|
package/dist/core/parser.d.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
import type { AttestPlan } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Throws when `source` does not compile, rather than returning a plan built out
|
|
4
|
+
* of what the parser recovered from it — a spec truncated mid-file was handing
|
|
5
|
+
* back the scenarios above the break, so the file counted as coverage while not
|
|
6
|
+
* compiling. `parseSpecs` turns the throw into the per-file `unreadable-file`
|
|
7
|
+
* its guard already produces (ATX-65), which is why this can throw at all: the
|
|
8
|
+
* loop that owns "one file's failure is not the run's failure" is already there.
|
|
9
|
+
*
|
|
10
|
+
* A throw rather than a result type, unlike the registry readers, because this
|
|
11
|
+
* returns a plan and has no failure channel of its own; adding one would change
|
|
12
|
+
* every caller to say what the guard above them already says.
|
|
13
|
+
*/
|
|
2
14
|
export declare function parseSpecFile(file: string, source: string): AttestPlan;
|
|
3
15
|
//# sourceMappingURL=parser.d.ts.map
|
package/dist/core/parser.js
CHANGED
|
@@ -3,9 +3,25 @@
|
|
|
3
3
|
// requirement()/scenario() calls with literal ids, and grabs the line number
|
|
4
4
|
// at parse time — OpenSpec's "parse into strong types, capture the line now".
|
|
5
5
|
import ts from 'typescript';
|
|
6
|
+
import { parseSource, SourceNotCompiled } from './compiler.js';
|
|
7
|
+
/**
|
|
8
|
+
* Throws when `source` does not compile, rather than returning a plan built out
|
|
9
|
+
* of what the parser recovered from it — a spec truncated mid-file was handing
|
|
10
|
+
* back the scenarios above the break, so the file counted as coverage while not
|
|
11
|
+
* compiling. `parseSpecs` turns the throw into the per-file `unreadable-file`
|
|
12
|
+
* its guard already produces (ATX-65), which is why this can throw at all: the
|
|
13
|
+
* loop that owns "one file's failure is not the run's failure" is already there.
|
|
14
|
+
*
|
|
15
|
+
* A throw rather than a result type, unlike the registry readers, because this
|
|
16
|
+
* returns a plan and has no failure channel of its own; adding one would change
|
|
17
|
+
* every caller to say what the guard above them already says.
|
|
18
|
+
*/
|
|
6
19
|
export function parseSpecFile(file, source) {
|
|
7
|
-
const
|
|
8
|
-
|
|
20
|
+
const parsed = parseSource(file, source);
|
|
21
|
+
if ('error' in parsed) {
|
|
22
|
+
throw new SourceNotCompiled(`this file does not compile (${parsed.error.message})`, parsed.error.line);
|
|
23
|
+
}
|
|
24
|
+
const sf = parsed.sf;
|
|
9
25
|
const scenarios = [];
|
|
10
26
|
const paramRefs = [];
|
|
11
27
|
const seenRefs = new Set(); // dedupe: one ref per (reqId, scenario)
|
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
|
*
|