@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/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.2/docs/en/troubleshooting.md#registry-not-static
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
@@ -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 an `unknown-target` ERROR, which is
78
- * refused before anything is written and so reports no files at all.
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 an `unknown-target` ERROR, which is
96
- * refused before anything is written and so reports no files at all.
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
@@ -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
@@ -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
@@ -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';
@@ -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
- const stub = join(dir, 'vitest-stub.mjs');
64
- writeFileSync(stub, VITEST_STUB);
65
- const server = await createServer({
66
- configFile: false,
67
- logLevel: 'error',
68
- // Not just quiet — sanitised. See `sanitisedLogger`: what survives
69
- // `logLevel: 'error'` is exactly the message that carries the checked
70
- // project's own text.
71
- customLogger: sanitisedLogger(),
72
- appType: 'custom',
73
- // `ws: false` is load-bearing, and `middlewareMode` is not enough on its
74
- // own: it suppresses the HTTP server but Vite still starts the HMR
75
- // WebSocket server, which binds `::` — every interface, not loopback — on
76
- // port 24678. Every `attest` command that reads a registry by evaluating it
77
- // therefore opened a network port for the length of the run, on a
78
- // developer's machine and on every CI runner, for a channel that does
79
- // nothing here: nothing subscribes to HMR, because nothing is watching.
80
- //
81
- // The visible symptom was the collision. Two attest processes at once —
82
- // ordinary in a CI matrix, and what this repo's own concurrent specs do —
83
- // and the second printed `WebSocket server error: Port is already in use`
84
- // into the middle of a report, through a `logLevel: 'error'` that was
85
- // supposed to have silenced the loader entirely.
86
- //
87
- // `hmr: false` does *not* close it (measured, Vite 5.4): the ws server is
88
- // created before the hmr option is consulted. `ws: false` is the one that
89
- // leaves no listening handle at all.
90
- server: { middlewareMode: true, ws: false },
91
- resolve: { alias: { vitest: stub } },
92
- ssr: { noExternal: ['vitest'] },
93
- optimizeDeps: { noDiscovery: true },
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
- await rm(dir, { recursive: true, force: true });
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
  };
@@ -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
- unreadableFiles: string[];
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`.
@@ -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 unreadableFiles = [];
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
- unreadableFiles.push(display);
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), unreadableFiles };
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`.
@@ -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
@@ -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 sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest,
8
- /* setParentNodes */ true);
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)
@@ -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
@@ -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