@am_shork/attest 0.9.1 → 0.9.3

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
@@ -167,7 +167,7 @@ Every diagnostic carries a `code`, and every code has a section in
167
167
  ```
168
168
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
169
169
  Value is not a literal.
170
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.1/docs/en/troubleshooting.md#registry-not-static
170
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.3/docs/en/troubleshooting.md#registry-not-static
171
171
  ```
172
172
 
173
173
  The anchor **is** the code, so the link cannot point somewhere the section
@@ -180,6 +180,7 @@ isn't. In `--json` the same link is on each issue as `docsUrl`.
180
180
  | **CLI reference** — every command, flag and JSON field | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/cli-reference.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/cli-reference.md) |
181
181
  | **Troubleshooting** — one section per issue code | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/troubleshooting.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/troubleshooting.md) |
182
182
  | **Design** — the authoritative design of the framework | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/attest-design.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/attest-design.md) |
183
+ | **Judging your own intent layer** — what no gate checks, and a method for it | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/intent-quality.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/intent-quality.md) |
183
184
  | **Feedback template** — report how adoption actually went | [en](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/en/feedback.template.md) | [中文](https://gitlab.com/Pseudorca/attest/-/blob/main/docs/zh/feedback.template.md) |
184
185
 
185
186
  The change workflow is not here: it is what `attest init` writes into your
@@ -0,0 +1,59 @@
1
+ import type { RedRecord } from './red-record.js';
2
+ /** One archived change folder, as this module found it. */
3
+ export interface ArchivedChange {
4
+ /** The folder name, `<YYYY-MM-DD>-<changeName>` as `--apply` spelled it. */
5
+ readonly name: string;
6
+ /**
7
+ * The folder relative to the project root, through `relativePath`.
8
+ *
9
+ * Not `join`ed by a caller from {@link ArchivedChange.name}: a value derived
10
+ * from the root is spelled one way here or it becomes a report field and a
11
+ * comparison key that depends on the host platform (`paths.ts`).
12
+ */
13
+ readonly path: string;
14
+ /**
15
+ * What this change's `first-run.json` holds, or an empty record.
16
+ *
17
+ * Empty covers both "the folder carries no record" and "the record did not
18
+ * validate", which `readRedRecordIn` does not distinguish — deliberately, and
19
+ * the same way round as the gate: an unreadable record is exactly as strict
20
+ * as a missing one. Kept per change rather than only merged so a caller can
21
+ * say which folders contributed nothing, which is the difference between a
22
+ * report and an assertion about an archive nobody counted.
23
+ */
24
+ readonly firstRun: RedRecord;
25
+ }
26
+ /** Everything `archive/` says about first runs, and where it said it. */
27
+ export interface ArchivedEvidence {
28
+ /** Every archived change folder, in folder-name order. */
29
+ readonly changes: readonly ArchivedChange[];
30
+ /** Their records merged. See {@link keepOutcome} for the precedence. */
31
+ readonly firstRun: RedRecord;
32
+ }
33
+ /**
34
+ * Read every archived change folder under `root`.
35
+ *
36
+ * A missing `archive/` is an empty list rather than an error: a project that has
37
+ * archived nothing is the normal state, and it is the state every project starts
38
+ * in. Directories only — a symbolic link is not followed, because this walks a
39
+ * tree to report on it and following one would let a link decide which files a
40
+ * report describes.
41
+ *
42
+ * Ordered by folder name, which is `<date>-<name>` and so is chronological, and
43
+ * through the same code-unit comparator as everything else this tool commits or
44
+ * compares — the report is then a function of the input rather than of the order
45
+ * a filesystem happened to hand back.
46
+ */
47
+ export declare function readArchivedChanges(root: string): Promise<ArchivedChange[]>;
48
+ /**
49
+ * The archive's first-run evidence, merged across every archived change.
50
+ *
51
+ * One `RedRecord` rather than a list to search, because the three questions a
52
+ * caller has of it are the three `recordedOutcome` already answers, at the
53
+ * granularity the gate leaves open: this scenario was observed failing; this
54
+ * scenario's id never came through the workflow; this id did and this scenario
55
+ * is not in it. Merging costs nothing else — the reader, the validator and the
56
+ * accessors are the ones the gate uses, unchanged.
57
+ */
58
+ export declare function readArchivedEvidence(root: string): Promise<ArchivedEvidence>;
59
+ //# sourceMappingURL=archive.d.ts.map
@@ -0,0 +1,142 @@
1
+ // The read side of `archive/`, which until now had none.
2
+ //
3
+ // `--apply` moves a change folder whole into `archive/<date>-<name>/`
4
+ // (`merge.ts`), carrying `proposal.md`, the delta and `first-run.json` — why the
5
+ // change was agreed, every id it touched, and the recorded failing run behind
6
+ // each one it ADDed. Nothing read any of it back: `archive` is in `locate.ts`'s
7
+ // scan exclusions, in `runner.ts`'s child-run exclusions and in `pipeline.ts`'s.
8
+ //
9
+ // **Those exclusions stay exactly as they are.** They are design §7 and §8 — an
10
+ // archived change's requirements and specs must not re-enter a normal run — and
11
+ // nothing here relaxes them. This module walks `archive/` on its own terms, for
12
+ // facts *about* archived changes rather than to fold their contents back into a
13
+ // registry or a plan.
14
+ //
15
+ // **Only `first-run.json` is read, and that is a selection rather than a first
16
+ // instalment.** It is the typed half: a `RedRecord` in a versioned envelope,
17
+ // schema-validated on read and discarded whole if it does not parse
18
+ // (`red-record.ts`). `proposal.md` is prose, and in this repository it is a
19
+ // pointer — `CLAUDE.md` makes `CHANGELOG.md` the single decision record, so a
20
+ // proposal restating an argument would be a second store of it with nothing
21
+ // comparing the two. There is nothing in the prose half for an index to offer
22
+ // but its path.
23
+ //
24
+ // **What the merged record is for.** Design §11 records that the never-red
25
+ // guarantee is a property of the `changes/` workflow, not of the registry as a
26
+ // whole, so nothing else in the tool can say across a project which scenarios
27
+ // were ever observed failing. Note the granularity: the gate blocks on *every*
28
+ // scenario of an ADDED id (`gate.ts`), so asking this per requirement comes back
29
+ // yes for every archived id and answers nothing. Where the gate genuinely stops
30
+ // is one level down — a scenario grown onto a requirement after it archived
31
+ // carries no obligation, because `never-red` fires only on ids a change is
32
+ // currently ADDing, and MODIFIED carries no such obligation at all. Those are
33
+ // the absences this record makes visible, and a `RedRecord` answers them
34
+ // directly: an id absent from it never came through the workflow, while an id
35
+ // present without a given scenario is one that did and then drifted.
36
+ import { readdir } from 'node:fs/promises';
37
+ import { join } from 'node:path';
38
+ import { byCodeUnit } from './order.js';
39
+ import { relativePath } from './paths.js';
40
+ import { keepOutcome, readRedRecordIn } from './red-record.js';
41
+ /** The directory archived changes are moved into, under the project root. */
42
+ const ARCHIVE_DIR = 'archive';
43
+ /**
44
+ * Read every archived change folder under `root`.
45
+ *
46
+ * A missing `archive/` is an empty list rather than an error: a project that has
47
+ * archived nothing is the normal state, and it is the state every project starts
48
+ * in. Directories only — a symbolic link is not followed, because this walks a
49
+ * tree to report on it and following one would let a link decide which files a
50
+ * report describes.
51
+ *
52
+ * Ordered by folder name, which is `<date>-<name>` and so is chronological, and
53
+ * through the same code-unit comparator as everything else this tool commits or
54
+ * compares — the report is then a function of the input rather than of the order
55
+ * a filesystem happened to hand back.
56
+ */
57
+ export async function readArchivedChanges(root) {
58
+ const dir = join(root, ARCHIVE_DIR);
59
+ let entries;
60
+ try {
61
+ entries = await readdir(dir, { withFileTypes: true });
62
+ }
63
+ catch {
64
+ return [];
65
+ }
66
+ const names = entries
67
+ .filter((e) => e.isDirectory())
68
+ .map((e) => e.name)
69
+ .sort(byCodeUnit);
70
+ return Promise.all(names.map(async (name) => {
71
+ const path = join(dir, name);
72
+ return { name, path: relativePath(root, path), firstRun: await readRedRecordIn(path) };
73
+ }));
74
+ }
75
+ /**
76
+ * The archive's first-run evidence, merged across every archived change.
77
+ *
78
+ * One `RedRecord` rather than a list to search, because the three questions a
79
+ * caller has of it are the three `recordedOutcome` already answers, at the
80
+ * granularity the gate leaves open: this scenario was observed failing; this
81
+ * scenario's id never came through the workflow; this id did and this scenario
82
+ * is not in it. Merging costs nothing else — the reader, the validator and the
83
+ * accessors are the ones the gate uses, unchanged.
84
+ */
85
+ export async function readArchivedEvidence(root) {
86
+ const changes = await readArchivedChanges(root);
87
+ return { changes, firstRun: mergeRecords(changes.map((c) => c.firstRun)) };
88
+ }
89
+ /**
90
+ * Fold records into one, `fail` winning.
91
+ *
92
+ * The precedence is `keepOutcome`'s and is order-independent, so nothing here
93
+ * depends on the order `readArchivedChanges` returned — which matters because
94
+ * one id can legitimately appear in two archived changes (removed, then added
95
+ * again) and neither folder's date makes its observation the truer one.
96
+ *
97
+ * Built into prototype-free maps at every level for the reason `red-record.ts`
98
+ * gives at length: the keys are requirement ids, paths and scenario names read
99
+ * off disk, and evidence that can be inherited is not evidence. The records
100
+ * being merged already have no prototype; the container this builds must not
101
+ * reintroduce one.
102
+ */
103
+ function mergeRecords(records) {
104
+ const merged = Object.create(null);
105
+ for (const record of records) {
106
+ for (const [reqId, byFile] of Object.entries(record)) {
107
+ const files = (merged[reqId] ??= Object.create(null));
108
+ for (const [file, outcomes] of Object.entries(byFile)) {
109
+ const kept = (files[file] ??= Object.create(null));
110
+ for (const [name, outcome] of Object.entries(outcomes)) {
111
+ kept[name] = keepOutcome(kept[name], outcome);
112
+ }
113
+ }
114
+ }
115
+ }
116
+ return sortRecord(merged);
117
+ }
118
+ /**
119
+ * Rebuild the record in key order at every level.
120
+ *
121
+ * The same byte-stability rule `mergeRedRecord` and `render` follow: two
122
+ * machines reading one archive produce one document, so key order has to be a
123
+ * function of the data rather than of insertion. `sortDeep` is not used because
124
+ * it would have to be told these containers must stay prototype-free.
125
+ */
126
+ function sortRecord(record) {
127
+ const sorted = Object.create(null);
128
+ for (const reqId of Object.keys(record).sort(byCodeUnit)) {
129
+ const byFile = record[reqId];
130
+ const files = Object.create(null);
131
+ for (const file of Object.keys(byFile).sort(byCodeUnit)) {
132
+ const outcomes = byFile[file];
133
+ const names = Object.create(null);
134
+ for (const name of Object.keys(outcomes).sort(byCodeUnit))
135
+ names[name] = outcomes[name];
136
+ files[file] = names;
137
+ }
138
+ sorted[reqId] = files;
139
+ }
140
+ return sorted;
141
+ }
142
+ //# sourceMappingURL=archive.js.map
@@ -1,5 +1,19 @@
1
1
  import type { AttestPlan, Outcome, RunResult, ScenarioRef } from './types.js';
2
- /** Where the record lives, relative to the project root. */
2
+ /**
3
+ * Where the record lives inside a change folder, wherever that folder is.
4
+ *
5
+ * **The unit is the folder, not the project plus a name**, because `--apply`
6
+ * moves the folder whole into `archive/<date>-<name>/` (`merge.ts`) and this
7
+ * file travels inside it. An archived record is therefore the same file at a
8
+ * different path, not a different format — so a reader spelled `root` +
9
+ * `changeName` could not reach it, and that is exactly where the reading side
10
+ * stopped: the archive is written and nothing reads it back.
11
+ *
12
+ * The two spellings below are the two places a change folder is, and they are
13
+ * the whole of the difference.
14
+ */
15
+ export declare function redRecordPathIn(changeDir: string): string;
16
+ /** Where the record lives for a change still in flight, from the project root. */
3
17
  export declare function redRecordPath(root: string, changeName: string): string;
4
18
  /** The file name, exported so a diagnostic can name it without rebuilding it. */
5
19
  export declare const RED_RECORD_FILE = "first-run.json";
@@ -34,6 +48,16 @@ export type RedRecord = Record<string, Record<string, Record<string, Outcome>>>;
34
48
  * the whole one.
35
49
  */
36
50
  export declare function readRedRecord(root: string, changeName: string): Promise<RedRecord>;
51
+ /**
52
+ * The same read, against a change folder given directly.
53
+ *
54
+ * Every rule in the comment above is this function's; `readRedRecord` is the
55
+ * in-flight spelling of it. Split out rather than parameterised on a directory
56
+ * name because an archived folder is not a variant of `changes/` — it is the
57
+ * same folder after `--apply` moved it, and naming the folder says so where a
58
+ * `'changes' | 'archive'` argument would invite a caller to pick.
59
+ */
60
+ export declare function readRedRecordIn(changeDir: string): Promise<RedRecord>;
37
61
  /**
38
62
  * What the record says about one scenario, or undefined if it never saw it.
39
63
  *
@@ -54,6 +78,21 @@ export declare function recordedOutcome(record: RedRecord, ref: ScenarioRef): Ou
54
78
  * `uncoveredIssues` from the commands that had a copy each.
55
79
  */
56
80
  export declare function hasRecordedRed(record: RedRecord, ref: ScenarioRef): boolean;
81
+ /**
82
+ * Which of two observations of one scenario the record keeps.
83
+ *
84
+ * **Monotonic toward `fail`**: a recorded fail is final, a recorded pass can
85
+ * still be corrected by a real one. The reasoning is at the top of this file;
86
+ * what is here is the one place it is decided, because two callers now decide
87
+ * on it — a run being folded into a change's record, and two records being
88
+ * merged. A second spelling of `=== 'fail'` would let them disagree about what
89
+ * the same pair of observations means, which is the argument that gave
90
+ * `hasRecordedRed` its single definition.
91
+ *
92
+ * Order-independent by construction — `keepOutcome(a, b)` and `keepOutcome(b, a)`
93
+ * agree — so a caller merging many records owes no sequencing rule.
94
+ */
95
+ export declare function keepOutcome(existing: Outcome | undefined, incoming: Outcome): Outcome;
57
96
  /**
58
97
  * Fold this run's outcomes into the record, for the scenarios that carry an
59
98
  * obligation — those covering a requirement this change ADDs.
@@ -97,9 +97,35 @@ function fileKey(file) {
97
97
  function emptyMap() {
98
98
  return Object.create(null);
99
99
  }
100
- /** Where the record lives, relative to the project root. */
100
+ /**
101
+ * Where the record lives inside a change folder, wherever that folder is.
102
+ *
103
+ * **The unit is the folder, not the project plus a name**, because `--apply`
104
+ * moves the folder whole into `archive/<date>-<name>/` (`merge.ts`) and this
105
+ * file travels inside it. An archived record is therefore the same file at a
106
+ * different path, not a different format — so a reader spelled `root` +
107
+ * `changeName` could not reach it, and that is exactly where the reading side
108
+ * stopped: the archive is written and nothing reads it back.
109
+ *
110
+ * The two spellings below are the two places a change folder is, and they are
111
+ * the whole of the difference.
112
+ */
113
+ export function redRecordPathIn(changeDir) {
114
+ return join(changeDir, RED_RECORD_FILE);
115
+ }
116
+ /** Where the record lives for a change still in flight, from the project root. */
101
117
  export function redRecordPath(root, changeName) {
102
- return join(root, 'changes', changeName, RED_RECORD_FILE);
118
+ return redRecordPathIn(inFlightChangeDir(root, changeName));
119
+ }
120
+ /**
121
+ * The folder a change occupies before it is archived.
122
+ *
123
+ * One spelling, because both public entry points below would otherwise carry a
124
+ * copy of `'changes'` and they must not be able to disagree about where a
125
+ * record is read from versus where a diagnostic says it is.
126
+ */
127
+ function inFlightChangeDir(root, changeName) {
128
+ return join(root, 'changes', changeName);
103
129
  }
104
130
  /** The file name, exported so a diagnostic can name it without rebuilding it. */
105
131
  export const RED_RECORD_FILE = 'first-run.json';
@@ -132,9 +158,21 @@ const RedRecordSchema = z.record(z.string(), z.record(z.string(), z.record(z.str
132
158
  * the whole one.
133
159
  */
134
160
  export async function readRedRecord(root, changeName) {
161
+ return readRedRecordIn(inFlightChangeDir(root, changeName));
162
+ }
163
+ /**
164
+ * The same read, against a change folder given directly.
165
+ *
166
+ * Every rule in the comment above is this function's; `readRedRecord` is the
167
+ * in-flight spelling of it. Split out rather than parameterised on a directory
168
+ * name because an archived folder is not a variant of `changes/` — it is the
169
+ * same folder after `--apply` moved it, and naming the folder says so where a
170
+ * `'changes' | 'archive'` argument would invite a caller to pick.
171
+ */
172
+ export async function readRedRecordIn(changeDir) {
135
173
  let raw;
136
174
  try {
137
- raw = await readFile(redRecordPath(root, changeName), 'utf8');
175
+ raw = await readFile(redRecordPathIn(changeDir), 'utf8');
138
176
  }
139
177
  catch {
140
178
  return emptyMap();
@@ -218,6 +256,23 @@ export function recordedOutcome(record, ref) {
218
256
  export function hasRecordedRed(record, ref) {
219
257
  return recordedOutcome(record, ref) === 'fail';
220
258
  }
259
+ /**
260
+ * Which of two observations of one scenario the record keeps.
261
+ *
262
+ * **Monotonic toward `fail`**: a recorded fail is final, a recorded pass can
263
+ * still be corrected by a real one. The reasoning is at the top of this file;
264
+ * what is here is the one place it is decided, because two callers now decide
265
+ * on it — a run being folded into a change's record, and two records being
266
+ * merged. A second spelling of `=== 'fail'` would let them disagree about what
267
+ * the same pair of observations means, which is the argument that gave
268
+ * `hasRecordedRed` its single definition.
269
+ *
270
+ * Order-independent by construction — `keepOutcome(a, b)` and `keepOutcome(b, a)`
271
+ * agree — so a caller merging many records owes no sequencing rule.
272
+ */
273
+ export function keepOutcome(existing, incoming) {
274
+ return existing === 'fail' ? 'fail' : incoming;
275
+ }
221
276
  /**
222
277
  * Fold this run's outcomes into the record, for the scenarios that carry an
223
278
  * obligation — those covering a requirement this change ADDs.
@@ -259,11 +314,10 @@ export function mergeRedRecord(existing, plan, run, addedIds) {
259
314
  continue;
260
315
  const forId = (record[s.reqId] ??= emptyMap());
261
316
  const forFile = (forId[fileKey(s.file)] ??= emptyMap());
262
- // Monotonic toward `fail`: a recorded fail is final, a recorded pass can
263
- // still be corrected by a real one. See the note at the top of this file.
264
- if (forFile[s.name] === 'fail' || forFile[s.name] === outcome)
317
+ const kept = keepOutcome(forFile[s.name], outcome);
318
+ if (kept === forFile[s.name])
265
319
  continue;
266
- forFile[s.name] = outcome;
320
+ forFile[s.name] = kept;
267
321
  changed = true;
268
322
  }
269
323
  // Sort within each requirement, and within each file, for the same
@@ -318,6 +318,14 @@ blocks the gate with the suite fully green; so does a spec file that will not
318
318
  import, or a proposed spec no delta claims. A clean table plus green tests is
319
319
  still not a verdict — \`attest archive\` is the only thing that decides.
320
320
 
321
+ **Three questions before you present it**, each a category no diagnostic reports:
322
+
323
+ - **What pins each expectation** — a fixture, a literal, an independently
324
+ derived value; never the param the code under test consumed?
325
+ - **Does each statement carry one obligation**, or several under one SHALL?
326
+ - **Was any of this ever red**, or does part of it already work? \`never-red\`
327
+ asks for a recorded failing run only on the ids you ADD.
328
+
321
329
  Then present the proposal, the ids, and the red output, and **stop**. Wait for
322
330
  agreement before implementing.
323
331
 
@@ -0,0 +1,146 @@
1
+ import type { Issue, ParsedScenario, Registry } from './core/types.js';
2
+ export type { Issue, ParsedScenario, Registry, Requirement } from './core/types.js';
3
+ /** What a project declares, read without executing any of it. */
4
+ export interface Inspection {
5
+ /**
6
+ * Every requirement declared under the root, merged across registry files.
7
+ *
8
+ * A registry file that could not be read statically contributes nothing here
9
+ * and one entry to {@link Inspection.issues} — so an empty result never means
10
+ * "this project has no requirements" without the issues saying why.
11
+ */
12
+ readonly registry: Registry;
13
+ /**
14
+ * The scenarios each requirement id has, in declaration order.
15
+ *
16
+ * A `Map` rather than a record, and not only for the key type: a record keyed
17
+ * by ids from a file on disk is the `Object.prototype` hazard ATX-42 exists
18
+ * for, and a `Map` has no prototype chain to read a requirement out of. The
19
+ * cost is that `JSON.stringify` renders it as `{}` — a caller serialising
20
+ * this owes `Object.fromEntries`, and the type is a `Map` so that the
21
+ * conversion is a decision rather than a surprise.
22
+ *
23
+ * An id with no scenarios is absent rather than present-and-empty — the same
24
+ * distinction `cover` reports, and the one a coverage check is asking about.
25
+ */
26
+ readonly scenarios: ReadonlyMap<string, readonly ParsedScenario[]>;
27
+ /**
28
+ * Everything that stopped a *file* being read, in the shape every command
29
+ * reports: one unreadable file scraps itself and nothing else (ATX-65), which
30
+ * is what lets a caller report on the rest of the project.
31
+ *
32
+ * Not a promise that nothing throws. A root that cannot be walked at all is a
33
+ * caller's mistake rather than a finding about a project, and rejects.
34
+ */
35
+ readonly issues: readonly Issue[];
36
+ }
37
+ /**
38
+ * Read the requirements and scenarios declared under `root`, executing nothing.
39
+ *
40
+ * `root` is the project directory, resolved here so a relative one behaves the
41
+ * same as an absolute one rather than half-working: the walk would find the
42
+ * files either way, and only the relative paths in the result would be wrong,
43
+ * which is the shape of bug that survives a demo.
44
+ *
45
+ * Every `file` in the result is relative to that root and POSIX-spelled on
46
+ * every platform (`paths.ts`) — the spelling a caller can compare against and
47
+ * store, on a value that would otherwise carry the host's separator.
48
+ *
49
+ * The registry read and the spec parse are independent, so they run
50
+ * concurrently — each already bounds its own fan-out over a tree whose size is
51
+ * not ours to choose.
52
+ */
53
+ export declare function inspectProject(root: string): Promise<Inspection>;
54
+ /**
55
+ * What the archived first-run records say about one scenario a spec declares.
56
+ *
57
+ * Four values, not two, and the two absences are the reason. `'fail'` and
58
+ * `'pass'` are the record's own outcomes, spelled the same so a caller
59
+ * comparing against `'fail'` is comparing against the string the gate compares
60
+ * against. The other two are both "no observation", and folding them together
61
+ * would report a design working as intended as if it were a defect:
62
+ *
63
+ * - `'unarchived'` — this scenario's requirement appears in no archived change
64
+ * at all. It was written straight into the registry, which is how existing
65
+ * behaviour is described in a brownfield adoption, and it carries **no**
66
+ * obligation to have been red. Nothing is wrong.
67
+ * - `'unobserved'` — the requirement *is* archived and this scenario is not in
68
+ * the record. It was added after the change was archived, and nothing will
69
+ * ever require it to have failed first: the gate raises `never-red` only for
70
+ * ids a change is currently ADDing, and an archived id is ADDed by nothing
71
+ * again. This is the one no gate can see.
72
+ *
73
+ * `'pass'` is a state the gate cannot produce — it blocks a change whose
74
+ * scenario passed on its first run — so an archived `'pass'` means the record
75
+ * was hand-edited or written by a format that has since moved. Reported rather
76
+ * than folded into the absences, because the fix is different.
77
+ *
78
+ * Attest computes which of the four holds and stops there. Whether an
79
+ * `'unobserved'` scenario matters is a judgement about that scenario, which is
80
+ * the caller's (design §0).
81
+ */
82
+ export type ScenarioEvidence = 'fail' | 'pass' | 'unobserved' | 'unarchived';
83
+ /** One declared scenario, and what the archive records about it. */
84
+ export interface EvidenceRow {
85
+ readonly reqId: string;
86
+ /** The spec file, relative to the root and POSIX-spelled, as {@link ParsedScenario} spells it. */
87
+ readonly file: string;
88
+ readonly name: string;
89
+ readonly line: number;
90
+ readonly evidence: ScenarioEvidence;
91
+ }
92
+ /** An archived change folder the evidence was read from. */
93
+ export interface ArchivedChangeRef {
94
+ /** The folder name, `<YYYY-MM-DD>-<change>`, as `archive --apply` spelled it. */
95
+ readonly name: string;
96
+ /** The folder relative to the root, POSIX-spelled. */
97
+ readonly path: string;
98
+ }
99
+ /** What {@link inspectRedEvidence} answers. */
100
+ export interface RedEvidenceInspection {
101
+ /**
102
+ * One row per scenario declared under the root, in the order
103
+ * {@link Inspection.scenarios} lists them.
104
+ *
105
+ * Rows for *declared* scenarios rather than for recorded ones: the question is
106
+ * what today's suite can show about itself, so a record naming a scenario no
107
+ * spec declares any more contributes nothing here. It is not lost — the folder
108
+ * it came from is in {@link RedEvidenceInspection.archived} — but a row about
109
+ * a scenario that no longer exists is not something a caller can act on.
110
+ */
111
+ readonly scenarios: readonly EvidenceRow[];
112
+ /**
113
+ * Every archived change folder that was read, in folder-name order.
114
+ *
115
+ * Provenance, and it is load-bearing: an empty list means the project has
116
+ * archived nothing, in which case every row is `'unarchived'` and the report
117
+ * says nothing about the project rather than something bad about it.
118
+ */
119
+ readonly archived: readonly ArchivedChangeRef[];
120
+ /** Everything that stopped a file being read, exactly as {@link Inspection.issues}. */
121
+ readonly issues: readonly Issue[];
122
+ }
123
+ /**
124
+ * Join what a project declares against what its archive recorded, executing
125
+ * nothing.
126
+ *
127
+ * **Why this is not a field on {@link Inspection}.** It walks `archive/`, which
128
+ * most callers of {@link inspectProject} have no use for, and a reader should
129
+ * not pay a second tree walk to be told about requirements.
130
+ *
131
+ * **Why it returns rows rather than the records.** The record is a nested map
132
+ * keyed by requirement ids, paths and scenario names read off disk, and reading
133
+ * it correctly means never answering from an inherited key — the whole of the
134
+ * prototype argument `red-record.ts` carries. Handing that container to a caller
135
+ * would export the hazard along with the data. What is exported is the answer,
136
+ * at the granularity the question is asked.
137
+ *
138
+ * **What the granularity is, and why it is not the requirement.** The gate
139
+ * blocks a change unless *every* scenario of every id it ADDs was observed
140
+ * failing, so "did this requirement ever have red evidence" comes back yes for
141
+ * every archived id and answers nothing. Where the gate stops is one level down:
142
+ * a scenario grown onto a requirement after it archived, and a requirement whose
143
+ * statement a later change MODIFIED, which carries no such obligation at all.
144
+ */
145
+ export declare function inspectRedEvidence(root: string): Promise<RedEvidenceInspection>;
146
+ //# sourceMappingURL=inspect.d.ts.map
@@ -0,0 +1,123 @@
1
+ // The static reading layer, as a second public entry point (design §10).
2
+ //
3
+ // Why it exists: everything this repository knows about the quality of its own
4
+ // requirements is a hand-judged table in `tests/intent-rule-candidates.spec.ts`,
5
+ // and the check that keeps such a table from rotting is "the scenario this row
6
+ // names is one a spec really declares". An adopter could not write that check.
7
+ // The package exported the authoring API and nothing else, so the ids, the
8
+ // statements and above all the *scenario names* of their own project were
9
+ // unreachable from outside — not merely undocumented, unreachable. That is what
10
+ // this entry point answers, and the deliberate limit is that it answers only
11
+ // that: it reports what a project declares, and judges none of it (design §0).
12
+ //
13
+ // **One function rather than the four internals it composes**, and the reason is
14
+ // a security property rather than convenience. `loadRegistry` takes a
15
+ // `RegistryReader`, and which reader runs is fixed by the command — never chosen
16
+ // by a caller, never a fallback — because one of the two executes the project's
17
+ // code and the other does not (design §5.1). Exporting the readers would hand
18
+ // that choice to an adopter, which is the one thing the two-adapter seam must
19
+ // never allow. What is exported therefore has the static reader welded in, and
20
+ // there is no parameter that could change it.
21
+ //
22
+ // The same rule decides the import graph: nothing here may reach `vite` or
23
+ // `vitest`, so a consumer that only analyses does not acquire the runner peer.
24
+ // `tests/import-boundary.spec.ts` gates that rather than leaving it to review —
25
+ // this module is on the short list of entry points whose closure is walked.
26
+ import { resolve } from 'node:path';
27
+ import { readArchivedEvidence } from './core/archive.js';
28
+ import { loadRegistry, parseSpecs, scanProject, staticReader } from './core/locate.js';
29
+ import { recordedOutcome } from './core/red-record.js';
30
+ /**
31
+ * Read the requirements and scenarios declared under `root`, executing nothing.
32
+ *
33
+ * `root` is the project directory, resolved here so a relative one behaves the
34
+ * same as an absolute one rather than half-working: the walk would find the
35
+ * files either way, and only the relative paths in the result would be wrong,
36
+ * which is the shape of bug that survives a demo.
37
+ *
38
+ * Every `file` in the result is relative to that root and POSIX-spelled on
39
+ * every platform (`paths.ts`) — the spelling a caller can compare against and
40
+ * store, on a value that would otherwise carry the host's separator.
41
+ *
42
+ * The registry read and the spec parse are independent, so they run
43
+ * concurrently — each already bounds its own fan-out over a tree whose size is
44
+ * not ours to choose.
45
+ */
46
+ export async function inspectProject(root) {
47
+ const projectRoot = resolve(root);
48
+ const { reqsFiles, specFiles } = await scanProject(projectRoot);
49
+ const [loaded, parsed] = await Promise.all([
50
+ loadRegistry(projectRoot, staticReader(), reqsFiles),
51
+ parseSpecs(specFiles, projectRoot),
52
+ ]);
53
+ const scenarios = new Map();
54
+ for (const scenario of parsed.plan.scenarios) {
55
+ const existing = scenarios.get(scenario.reqId);
56
+ if (existing === undefined)
57
+ scenarios.set(scenario.reqId, [scenario]);
58
+ else
59
+ existing.push(scenario);
60
+ }
61
+ return {
62
+ registry: loaded.registry,
63
+ scenarios,
64
+ // Registry issues before spec issues, and each already in sorted file
65
+ // order: the list is a function of the project rather than of which read
66
+ // finished first, so two runs over one tree answer identically.
67
+ issues: [...loaded.issues, ...parsed.issues],
68
+ };
69
+ }
70
+ /**
71
+ * Join what a project declares against what its archive recorded, executing
72
+ * nothing.
73
+ *
74
+ * **Why this is not a field on {@link Inspection}.** It walks `archive/`, which
75
+ * most callers of {@link inspectProject} have no use for, and a reader should
76
+ * not pay a second tree walk to be told about requirements.
77
+ *
78
+ * **Why it returns rows rather than the records.** The record is a nested map
79
+ * keyed by requirement ids, paths and scenario names read off disk, and reading
80
+ * it correctly means never answering from an inherited key — the whole of the
81
+ * prototype argument `red-record.ts` carries. Handing that container to a caller
82
+ * would export the hazard along with the data. What is exported is the answer,
83
+ * at the granularity the question is asked.
84
+ *
85
+ * **What the granularity is, and why it is not the requirement.** The gate
86
+ * blocks a change unless *every* scenario of every id it ADDs was observed
87
+ * failing, so "did this requirement ever have red evidence" comes back yes for
88
+ * every archived id and answers nothing. Where the gate stops is one level down:
89
+ * a scenario grown onto a requirement after it archived, and a requirement whose
90
+ * statement a later change MODIFIED, which carries no such obligation at all.
91
+ */
92
+ export async function inspectRedEvidence(root) {
93
+ const projectRoot = resolve(root);
94
+ // Sequential rather than concurrent, unlike the two reads inside
95
+ // `inspectProject`: this is one tree walk after another over the same disk,
96
+ // and the archive holds only the folders of changes already merged — so
97
+ // overlapping them buys nothing worth a second fan-out.
98
+ const inspection = await inspectProject(projectRoot);
99
+ const { changes, firstRun } = await readArchivedEvidence(projectRoot);
100
+ const scenarios = [];
101
+ for (const [reqId, declared] of inspection.scenarios) {
102
+ // Own property only, and the distinction the two absences rest on: an id
103
+ // the archive never mentions is a different fact from an id it mentions
104
+ // without this scenario.
105
+ const archived = Object.hasOwn(firstRun, reqId);
106
+ for (const s of declared) {
107
+ const outcome = recordedOutcome(firstRun, { reqId, file: s.file, name: s.name });
108
+ scenarios.push({
109
+ reqId,
110
+ file: s.file,
111
+ name: s.name,
112
+ line: s.line,
113
+ evidence: outcome ?? (archived ? 'unobserved' : 'unarchived'),
114
+ });
115
+ }
116
+ }
117
+ return {
118
+ scenarios,
119
+ archived: changes.map((c) => ({ name: c.name, path: c.path })),
120
+ issues: inspection.issues,
121
+ };
122
+ }
123
+ //# sourceMappingURL=inspect.js.map