@am_shork/attest 0.7.2 → 0.7.4
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 +608 -166
- package/README.md +10 -1
- package/bin/attest.js +0 -0
- package/dist/cli/json.d.ts +3 -2
- package/dist/cli/json.js +3 -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 +2 -0
- package/dist/core/loader.js +63 -33
- package/dist/core/locate.d.ts +22 -2
- package/dist/core/locate.js +29 -4
- package/dist/core/parser.d.ts +12 -0
- package/dist/core/parser.js +18 -2
- package/dist/core/paths.d.ts +16 -0
- package/dist/core/paths.js +20 -1
- package/dist/core/pipeline.js +72 -9
- package/dist/core/render.js +190 -21
- package/dist/core/skill.js +19 -0
- package/dist/core/static-registry.d.ts +34 -2
- package/dist/core/static-registry.js +133 -8
- package/dist/core/validator.d.ts +9 -4
- package/dist/core/validator.js +74 -21
- package/package.json +17 -29
package/README.md
CHANGED
|
@@ -29,6 +29,15 @@ together and the test stays green through any edit. Pin the expectation to
|
|
|
29
29
|
something that does not move with the param — a fixture, a literal in the test,
|
|
30
30
|
or a second independently derived value.
|
|
31
31
|
|
|
32
|
+
A composite param has a second failure of its own, and it runs the other way. When
|
|
33
|
+
a test **loops over** a list it read from `params`, that list is the set of cases
|
|
34
|
+
the run covers: drop a member and every assertion inside the loop still passes
|
|
35
|
+
over what is left, so the suite quietly tests less with nothing to show for it.
|
|
36
|
+
Pin the extent beside the loop — the members against a literal when their identity
|
|
37
|
+
is the promise, the length when the size is. That literal is not the copy the
|
|
38
|
+
single source exists to prevent: it is not what the system is measured against,
|
|
39
|
+
it is what the intent claimed to cover.
|
|
40
|
+
|
|
32
41
|
## Prerequisites
|
|
33
42
|
|
|
34
43
|
- Node ≥ 20.19
|
|
@@ -157,7 +166,7 @@ Every diagnostic carries a `code`, and every code has a section in
|
|
|
157
166
|
```
|
|
158
167
|
ERROR registry-not-static (requirements/upload.reqs.ts:5)
|
|
159
168
|
Value is not a literal.
|
|
160
|
-
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.7.
|
|
169
|
+
→ https://gitlab.com/Pseudorca/attest/-/blob/v0.7.4/docs/en/troubleshooting.md#registry-not-static
|
|
161
170
|
```
|
|
162
171
|
|
|
163
172
|
The anchor **is** the code, so the link cannot point somewhere the section
|
package/bin/attest.js
CHANGED
|
File without changes
|
package/dist/cli/json.d.ts
CHANGED
|
@@ -74,8 +74,9 @@ export declare function renderReport(version: string, issues: Issue[], outFile?:
|
|
|
74
74
|
/**
|
|
75
75
|
* `init`. Carries the paths written so a consumer learns where the instructions
|
|
76
76
|
* went without hardcoding the convention. `issues` is empty on the success path
|
|
77
|
-
* — `init` writes or throws — apart from
|
|
78
|
-
*
|
|
77
|
+
* — `init` writes or throws — apart from the two ERRORs it refuses on,
|
|
78
|
+
* `unknown-target` and `unsafe-target-path`. Both are decided before anything is
|
|
79
|
+
* written and so report no files at all.
|
|
79
80
|
*
|
|
80
81
|
* `outFile` survives beside `outFiles` for the one-file run, which is what the
|
|
81
82
|
* default invocation still is: dropping it would break the consumer the field
|
package/dist/cli/json.js
CHANGED
|
@@ -92,8 +92,9 @@ export function renderReport(version, issues, outFile) {
|
|
|
92
92
|
/**
|
|
93
93
|
* `init`. Carries the paths written so a consumer learns where the instructions
|
|
94
94
|
* went without hardcoding the convention. `issues` is empty on the success path
|
|
95
|
-
* — `init` writes or throws — apart from
|
|
96
|
-
*
|
|
95
|
+
* — `init` writes or throws — apart from the two ERRORs it refuses on,
|
|
96
|
+
* `unknown-target` and `unsafe-target-path`. Both are decided before anything is
|
|
97
|
+
* written and so report no files at all.
|
|
97
98
|
*
|
|
98
99
|
* `outFile` survives beside `outFiles` for the one-file run, which is what the
|
|
99
100
|
* default invocation still is: dropping it would break the consumer the field
|
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"];
|
|
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
|
@@ -37,6 +37,7 @@ export const ISSUE_CODES = [
|
|
|
37
37
|
'modify-missing',
|
|
38
38
|
'never-red',
|
|
39
39
|
'non-scalar-interpolation',
|
|
40
|
+
'orphan-from-failed-registry',
|
|
40
41
|
'orphan-test',
|
|
41
42
|
'possible-drift',
|
|
42
43
|
'proposed-spec-name-taken',
|
|
@@ -56,6 +57,7 @@ export const ISSUE_CODES = [
|
|
|
56
57
|
'uncovered-requirement',
|
|
57
58
|
'unknown-target',
|
|
58
59
|
'unreadable-file',
|
|
60
|
+
'unsafe-target-path',
|
|
59
61
|
];
|
|
60
62
|
const DOCUMENTED = new Set(ISSUE_CODES);
|
|
61
63
|
const REPO = 'https://gitlab.com/Pseudorca/attest/-/blob';
|
package/dist/core/loader.js
CHANGED
|
@@ -60,38 +60,59 @@ const VITEST_STUB = 'export const describe=()=>{};export const it=()=>{};export
|
|
|
60
60
|
'export default {};';
|
|
61
61
|
export async function createLoader() {
|
|
62
62
|
const dir = mkdtempSync(join(tmpdir(), 'attest-loader-'));
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
63
|
+
// Everything between creating the directory and returning its owner runs
|
|
64
|
+
// inside this `try`, because until the caller holds the object below, nothing
|
|
65
|
+
// else can call `close` — so a throw here is the one path where the directory
|
|
66
|
+
// outlives the only code that knows about it. That is the same leak the
|
|
67
|
+
// comment on `close` records as already fixed, surviving on the other side of
|
|
68
|
+
// the same function.
|
|
69
|
+
//
|
|
70
|
+
// Stated over the window rather than over `createServer`, which is where it
|
|
71
|
+
// was found: `writeFileSync` is inside it too, and a disk that is full or a
|
|
72
|
+
// temp directory that turns read-only between the two calls reaches it without
|
|
73
|
+
// any project being involved.
|
|
74
|
+
let server;
|
|
75
|
+
try {
|
|
76
|
+
const stub = join(dir, 'vitest-stub.mjs');
|
|
77
|
+
writeFileSync(stub, VITEST_STUB);
|
|
78
|
+
server = await createServer({
|
|
79
|
+
configFile: false,
|
|
80
|
+
logLevel: 'error',
|
|
81
|
+
// Not just quiet — sanitised. See `sanitisedLogger`: what survives
|
|
82
|
+
// `logLevel: 'error'` is exactly the message that carries the checked
|
|
83
|
+
// project's own text.
|
|
84
|
+
customLogger: sanitisedLogger(),
|
|
85
|
+
appType: 'custom',
|
|
86
|
+
// `ws: false` is load-bearing, and `middlewareMode` is not enough on its
|
|
87
|
+
// own: it suppresses the HTTP server but Vite still starts the HMR
|
|
88
|
+
// WebSocket server, which binds `::` — every interface, not loopback — on
|
|
89
|
+
// port 24678. Every `attest` command that reads a registry by evaluating
|
|
90
|
+
// it therefore opened a network port for the length of the run, on a
|
|
91
|
+
// developer's machine and on every CI runner, for a channel that does
|
|
92
|
+
// nothing here: nothing subscribes to HMR, because nothing is watching.
|
|
93
|
+
//
|
|
94
|
+
// The visible symptom was the collision. Two attest processes at once —
|
|
95
|
+
// ordinary in a CI matrix, and what this repo's own concurrent specs do —
|
|
96
|
+
// and the second printed `WebSocket server error: Port is already in use`
|
|
97
|
+
// into the middle of a report, through a `logLevel: 'error'` that was
|
|
98
|
+
// supposed to have silenced the loader entirely.
|
|
99
|
+
//
|
|
100
|
+
// `hmr: false` does *not* close it (measured, Vite 5.4): the ws server is
|
|
101
|
+
// created before the hmr option is consulted. `ws: false` is the one that
|
|
102
|
+
// leaves no listening handle at all.
|
|
103
|
+
server: { middlewareMode: true, ws: false },
|
|
104
|
+
resolve: { alias: { vitest: stub } },
|
|
105
|
+
ssr: { noExternal: ['vitest'] },
|
|
106
|
+
optimizeDeps: { noDiscovery: true },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
// The original error is what the caller has to see, so a cleanup that fails
|
|
111
|
+
// must not replace it. `force` already forgives a directory that is not
|
|
112
|
+
// there; this forgives one that will not go.
|
|
113
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
95
116
|
let closed = false;
|
|
96
117
|
return {
|
|
97
118
|
scratchDir: dir,
|
|
@@ -111,7 +132,16 @@ export async function createLoader() {
|
|
|
111
132
|
await server.close();
|
|
112
133
|
}
|
|
113
134
|
finally {
|
|
114
|
-
|
|
135
|
+
// Swallowed for the same reason as the `rm` on the failing path above,
|
|
136
|
+
// and it counts for more here: every caller closes from a `finally`, so
|
|
137
|
+
// a throw from this line replaces whatever the block was doing — the
|
|
138
|
+
// real error on the way out, or a *successful* return turned into a
|
|
139
|
+
// crash about a temp directory. What that reader would then be handed
|
|
140
|
+
// is a misdiagnosis pointing at their own correct files, which this
|
|
141
|
+
// repository already treats as worse than the gap it fills.
|
|
142
|
+
// Given up with it: the one signal a failed cleanup could have raised,
|
|
143
|
+
// which no caller could have acted on anyway.
|
|
144
|
+
await rm(dir, { recursive: true, force: true }).catch(() => { });
|
|
115
145
|
}
|
|
116
146
|
},
|
|
117
147
|
};
|
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';
|
|
7
|
+
import { declaredIdsFromSource, readRegistrySource } from './static-registry.js';
|
|
8
8
|
import { RegistryValidationError } from './registry.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
|
/**
|
|
@@ -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/paths.d.ts
CHANGED
|
@@ -12,4 +12,20 @@
|
|
|
12
12
|
export declare function toPosixPath(path: string, separator?: string): string;
|
|
13
13
|
/** `path.relative`, in the one spelling the rest of the engine expects. */
|
|
14
14
|
export declare function relativePath(from: string, to: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Whether `path` is `root` or sits below it, both given as resolved paths.
|
|
17
|
+
*
|
|
18
|
+
* Asked through `relativePath` rather than by comparing prefixes, because the
|
|
19
|
+
* two ways this goes wrong are both invisible in a `startsWith`. A sibling
|
|
20
|
+
* directory shares the prefix — `/repo-backup` starts with `/repo` — and on
|
|
21
|
+
* Windows a path on another drive has *no* relative spelling at all, so
|
|
22
|
+
* `path.relative` answers with an absolute one rather than a chain of `..`.
|
|
23
|
+
* Testing the relative form catches both: an escape is either `..`-led or
|
|
24
|
+
* absolute, and nothing else is.
|
|
25
|
+
*
|
|
26
|
+
* The caller owes the resolution. Nothing here follows a symbolic link, so a
|
|
27
|
+
* path that is lexically inside can still be physically outside — that is the
|
|
28
|
+
* question `join` cannot answer and this function does not pretend to.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isInside(root: string, path: string): boolean;
|
|
15
31
|
//# sourceMappingURL=paths.d.ts.map
|
package/dist/core/paths.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Normalising here rather than at the glob has a second dividend: a report is
|
|
15
15
|
// then byte-identical across platforms, so a `--json` consumer diffing two CI
|
|
16
16
|
// runs is not reading the runner's operating system.
|
|
17
|
-
import { relative, sep } from 'node:path';
|
|
17
|
+
import { isAbsolute, relative, sep } from 'node:path';
|
|
18
18
|
/**
|
|
19
19
|
* A native path as a POSIX one.
|
|
20
20
|
*
|
|
@@ -33,4 +33,23 @@ export function toPosixPath(path, separator = sep) {
|
|
|
33
33
|
export function relativePath(from, to) {
|
|
34
34
|
return toPosixPath(relative(from, to));
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Whether `path` is `root` or sits below it, both given as resolved paths.
|
|
38
|
+
*
|
|
39
|
+
* Asked through `relativePath` rather than by comparing prefixes, because the
|
|
40
|
+
* two ways this goes wrong are both invisible in a `startsWith`. A sibling
|
|
41
|
+
* directory shares the prefix — `/repo-backup` starts with `/repo` — and on
|
|
42
|
+
* Windows a path on another drive has *no* relative spelling at all, so
|
|
43
|
+
* `path.relative` answers with an absolute one rather than a chain of `..`.
|
|
44
|
+
* Testing the relative form catches both: an escape is either `..`-led or
|
|
45
|
+
* absolute, and nothing else is.
|
|
46
|
+
*
|
|
47
|
+
* The caller owes the resolution. Nothing here follows a symbolic link, so a
|
|
48
|
+
* path that is lexically inside can still be physically outside — that is the
|
|
49
|
+
* question `join` cannot answer and this function does not pretend to.
|
|
50
|
+
*/
|
|
51
|
+
export function isInside(root, path) {
|
|
52
|
+
const rel = relativePath(root, path);
|
|
53
|
+
return rel !== '..' && !rel.startsWith('../') && !isAbsolute(rel);
|
|
54
|
+
}
|
|
36
55
|
//# sourceMappingURL=paths.js.map
|