@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/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.7.3/docs/en/troubleshooting.md#registry-not-static
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
- const dest = opts.out ? resolve(process.cwd(), opts.out) : undefined;
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), dest, {
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), { evaluate: opts.eval });
160
- // Never overwrite a good document with the output of a broken registry.
161
- if (!hasError(issues) && dest)
162
- await writeAtomic(dest, markdown);
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 (dest) {
173
+ else if (wrote) {
171
174
  console.log(chalk.green(`✓ Wrote ${inline(opts.out ?? '')}`));
172
175
  }
173
176
  else {
@@ -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
- /** Serialize a report for stdout. Pretty-printed: still one parseable document. */
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
- /** Serialize a report for stdout. Pretty-printed: still one parseable document. */
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, null, 2);
187
+ return JSON.stringify(report, (_key, value) => (typeof value === 'string' ? control(value) : value), 2);
162
188
  }
163
189
  //# sourceMappingURL=json.js.map
@@ -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", "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
@@ -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',
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.reqId, s.name))
158
+ if (hasRecordedRed(firstRun, s))
159
159
  continue;
160
- const outcome = recordedOutcome(firstRun, s.reqId, s.name);
160
+ const outcome = recordedOutcome(firstRun, s);
161
161
  issues.push({
162
162
  level: 'ERROR',
163
163
  code: 'never-red',
@@ -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';
8
- import { RegistryValidationError } from './registry.js';
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` — going through RegistryValidationError
188
- // keeps the message identical rather than merely similar.
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: ${new RegistryValidationError(parsed.error.issues).message}`,
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 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)
@@ -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): Promise<RenderResult>;
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
  *