@am_shork/attest 0.9.2 → 0.9.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
@@ -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.2/docs/en/troubleshooting.md#registry-not-static
170
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.4/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
package/bin/attest.js CHANGED
File without changes
@@ -97,6 +97,10 @@ export function applyDelta(base, d) {
97
97
  ...patch,
98
98
  params: { ...existing.params, ...(patch.params ?? {}) },
99
99
  outOfScope: patch.outOfScope ?? existing.outOfScope,
100
+ // Replaced wholesale rather than merged, exactly as `outOfScope` is: a
101
+ // patch that closes a question says so by writing the list that remains,
102
+ // and a union would make closing the last one impossible to express.
103
+ open: patch.open ?? existing.open,
100
104
  };
101
105
  const parsed = RequirementSchema.safeParse(merged);
102
106
  if (!parsed.success) {
@@ -209,6 +213,7 @@ function canonical(req) {
209
213
  // against itself. Code-unit order throughout, and `sortDeep` says why.
210
214
  params: sortDeep(req.params),
211
215
  outOfScope: [...req.outOfScope],
216
+ open: [...req.open],
212
217
  });
213
218
  }
214
219
  //# sourceMappingURL=apply.js.map
@@ -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
@@ -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-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"];
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", "open-unresolved", "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", "requirement-open", "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
+ 'open-unresolved',
40
41
  'orphan-from-failed-registry',
41
42
  'orphan-test',
42
43
  'possible-drift',
@@ -49,6 +50,7 @@ export const ISSUE_CODES = [
49
50
  'rename-source-missing',
50
51
  'rename-target-exists',
51
52
  'rename-target-invalid',
53
+ 'requirement-open',
52
54
  'spec-in-change-dir',
53
55
  'spec-load-failed',
54
56
  'stale-spec-doc',
@@ -120,6 +120,14 @@ export declare function notRunIssues(plan: AttestPlan, run: RunResult, specific?
120
120
  * never a moment when its scenario was supposed to fail.
121
121
  */
122
122
  export declare function neverRedIssues(plan: AttestPlan, addedIds: readonly string[], firstRun: RedRecord): Issue[];
123
+ /**
124
+ * Every requirement that still declares an open question.
125
+ *
126
+ * Exported for the same reason `neverRedIssues` is: it is one of the gate's
127
+ * obligations, and a caller projecting the gate's verdict has to reach the same
128
+ * answer from the same function rather than re-deriving it.
129
+ */
130
+ export declare function openIssues(registry: Registry): Issue[];
123
131
  /**
124
132
  * Decide whether a change may be archived. Returns the blocking issues; an
125
133
  * empty array means the gate passes (design §8). Checks, in order:
package/dist/core/gate.js CHANGED
@@ -171,6 +171,27 @@ export function neverRedIssues(plan, addedIds, firstRun) {
171
171
  }
172
172
  return issues;
173
173
  }
174
+ /**
175
+ * Every requirement that still declares an open question.
176
+ *
177
+ * Exported for the same reason `neverRedIssues` is: it is one of the gate's
178
+ * obligations, and a caller projecting the gate's verdict has to reach the same
179
+ * answer from the same function rather than re-deriving it.
180
+ */
181
+ export function openIssues(registry) {
182
+ const out = [];
183
+ for (const [id, req] of Object.entries(registry)) {
184
+ for (const question of req.open) {
185
+ out.push({
186
+ level: 'ERROR',
187
+ code: 'open-unresolved',
188
+ reqId: id,
189
+ message: `Requirement "${id}" still declares an open question, so the registry is not done: ${question} Answer it and remove the entry from open, then run the gate again.`,
190
+ });
191
+ }
192
+ }
193
+ return out;
194
+ }
174
195
  /**
175
196
  * Decide whether a change may be archived. Returns the blocking issues; an
176
197
  * empty array means the gate passes (design §8). Checks, in order:
@@ -209,6 +230,23 @@ export function evaluateGate({ registry, plan, run, addedIds, unmergedAddedIds,
209
230
  if (addedIds && addedIds.length > 0) {
210
231
  blocking.push(...neverRedIssues(plan, addedIds, firstRun ?? {}));
211
232
  }
233
+ // 6) Open questions: nothing in the end-state registry may still be undecided.
234
+ //
235
+ // **Over the whole registry rather than the ids this change adds**, which is
236
+ // where it parts company with step 5 above. `never-red` is scoped to added ids
237
+ // because "was ever observed failing" is a fact about history and cannot be
238
+ // demanded retroactively of a requirement written straight into the base
239
+ // registry. An open field is not a historical fact; it is a claim that is true
240
+ // right now, so step 1's whole-registry re-validation is the precedent this
241
+ // belongs to. The scoping is also what closes the path the narrow form would
242
+ // open: `applyDelta` merges a MODIFIED patch field-wise, so an id that
243
+ // archived long ago can acquire `open` from a later change, and an
244
+ // added-ids-only gate would never look at it.
245
+ //
246
+ // The cost is deliberate: one unresolved question anywhere blocks every
247
+ // unrelated change from archiving. That is the intended reading of a registry
248
+ // that is not done, and it is recoverable by answering the question.
249
+ blocking.push(...openIssues(registry));
212
250
  return blocking;
213
251
  }
214
252
  //# sourceMappingURL=gate.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
@@ -83,6 +83,7 @@ function sanitised(registry) {
83
83
  rationale: control(req.rationale),
84
84
  params: Object.fromEntries(Object.entries(req.params).map(([k, v]) => [control(k), sanitisedValue(v)])),
85
85
  outOfScope: req.outOfScope.map(control),
86
+ open: req.open.map(control),
86
87
  };
87
88
  }
88
89
  return out;
@@ -231,6 +232,13 @@ function section(id, req) {
231
232
  if (req.outOfScope.length > 0) {
232
233
  out.push('', '**Out of scope**', '', ...req.outOfScope.map((s) => `- ${prose(s)}`));
233
234
  }
235
+ // Last, and rendered at all because this document is the only human review
236
+ // surface a requirement has: a reader who is told what a requirement promises
237
+ // without being told what about it is still undecided has been told the
238
+ // stronger half of a claim on its own.
239
+ if (req.open.length > 0) {
240
+ out.push('', '**Open**', '', ...req.open.map((s) => `- ${prose(s)}`));
241
+ }
234
242
  return out;
235
243
  }
236
244
  /** The statement as the document says it, with its params in place. */
@@ -11,6 +11,7 @@ export declare const RequirementSchema: z.ZodObject<{
11
11
  rationale: z.ZodString;
12
12
  params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
13
13
  outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
14
+ open: z.ZodDefault<z.ZodArray<z.ZodString>>;
14
15
  }, z.core.$strip>;
15
16
  /**
16
17
  * The id grammar, on its own.
@@ -45,16 +46,19 @@ export declare const RegistrySchema: z.ZodPipe<z.ZodCustom<Record<string, {
45
46
  rationale: string;
46
47
  params?: Record<string, unknown> | undefined;
47
48
  outOfScope?: string[] | undefined;
49
+ open?: string[] | undefined;
48
50
  }>, Record<string, {
49
51
  statement: string;
50
52
  rationale: string;
51
53
  params?: Record<string, unknown> | undefined;
52
54
  outOfScope?: string[] | undefined;
55
+ open?: string[] | undefined;
53
56
  }>>, z.ZodRecord<z.ZodString, z.ZodObject<{
54
57
  statement: z.ZodString;
55
58
  rationale: z.ZodString;
56
59
  params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodType<ParamValue, unknown, z.core.$ZodTypeInternals<ParamValue, unknown>>>>;
57
60
  outOfScope: z.ZodDefault<z.ZodArray<z.ZodString>>;
61
+ open: z.ZodDefault<z.ZodArray<z.ZodString>>;
58
62
  }, z.core.$strip>>>;
59
63
  /** Parsed (output) shapes — defaults applied. */
60
64
  export type Requirement = z.infer<typeof RequirementSchema>;
@@ -63,6 +63,17 @@ export const RequirementSchema = z.object({
63
63
  // instance.
64
64
  params: z.record(z.string(), paramValue).default({}),
65
65
  outOfScope: z.array(z.string()).default([]),
66
+ // What is still undecided about this requirement, in free text, one entry per
67
+ // question. Shaped as `outOfScope` rather than as a new kind of thing: every
68
+ // place that has to learn about it — the patch merge, both splice writers, the
69
+ // renderer — already carries that field's branch to copy, and a defaulted
70
+ // array means an empty list and an absent field are the same state, so no
71
+ // caller has to distinguish "closed" from "never opened".
72
+ //
73
+ // No grammar is imposed on an entry. A greppable free-text line is what
74
+ // spec-kit's `[NEEDS CLARIFICATION: …]` is, and constraining it here would be
75
+ // deciding for every adopter what counts as a question.
76
+ open: z.array(z.string()).default([]),
66
77
  });
67
78
  /**
68
79
  * The id grammar, on its own.
@@ -132,6 +132,30 @@ promises is a two-stage workflow, and the stages are separate on purpose.
132
132
  or it is an \`unbound-param\` ERROR. A \`{placeholder}\` written into a
133
133
  *rationale* is a \`rationale-placeholder\` WARNING — rationales are not
134
134
  interpolated, so it would reach the rendered document with its braces intact.
135
+ - **If you cannot determine a value, say so — do not pick one.** \`open\` is a
136
+ list of what is still undecided about the requirement, in free text, one entry
137
+ per question. Each is a \`requirement-open\` WARNING from the static commands,
138
+ which does not fail them, so a proposal may be legitimately half-finished
139
+ while you are still writing it. The archive gate is where it stops: it blocks
140
+ with \`open-unresolved\` while any requirement in the merged registry still
141
+ carries one, so "done" is never diluted.
142
+
143
+ \`\`\`ts
144
+ 'AUTH-7': {
145
+ statement: 'The system MUST require a TOTP code within {totpWindowSec} seconds.',
146
+ rationale: 'A password alone no longer meets the account-takeover risk we accept.',
147
+ params: { totpWindowSec: 30 },
148
+ open: ['is 30 seconds right, or does the authenticator app dictate it?'],
149
+ },
150
+ \`\`\`
151
+
152
+ This matters more for you than for a human author, and the reason is worth
153
+ stating plainly: the schema will not let you leave the field out, so an agent
154
+ that cannot determine a number picks one. A scenario is then written against
155
+ the invented value, and the result is a single source that is perfectly
156
+ consistent and describes a promise nobody made — the exact drift this workflow
157
+ exists to prevent, arriving through the mechanism meant to stop it. Writing
158
+ the question down costs nothing and blocks nothing until archive time.
135
159
  - **A registry is a literal, and so is a delta.** Every value is written in the
136
160
  file: no imported constant (\`params: { maxMb: MAX_MB }\`), no computed value,
137
161
  no \`Date.now()\`. \`check\`, \`cover\` and \`render\` read \`*.reqs.ts\` with the
@@ -318,6 +342,14 @@ blocks the gate with the suite fully green; so does a spec file that will not
318
342
  import, or a proposed spec no delta claims. A clean table plus green tests is
319
343
  still not a verdict — \`attest archive\` is the only thing that decides.
320
344
 
345
+ **Three questions before you present it**, each a category no diagnostic reports:
346
+
347
+ - **What pins each expectation** — a fixture, a literal, an independently
348
+ derived value; never the param the code under test consumed?
349
+ - **Does each statement carry one obligation**, or several under one SHALL?
350
+ - **Was any of this ever red**, or does part of it already work? \`never-red\`
351
+ asks for a recorded failing run only on the ids you ADD.
352
+
321
353
  Then present the proposal, the ids, and the red output, and **stop**. Wait for
322
354
  agreement before implementing.
323
355
 
@@ -340,6 +372,7 @@ once. Branch on \`issues[].code\`, never on \`message\`:
340
372
  | --- | --- |
341
373
  | \`tests-red\` | a test is failing — the normal state until you are finished |
342
374
  | \`never-red\` | an added requirement's scenario has no recorded failing run |
375
+ | \`open-unresolved\` | a requirement anywhere in the merged registry still declares an \`open\` question |
343
376
  | \`uncovered-requirement\` | a requirement in the applied registry has no scenario |
344
377
  | \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) — withdrawn when one of the two rows below already explains its file, so it never stands in for a load failure |
345
378
  | \`spec-load-failed\` | a spec file could not be imported, so nothing in it ran. The run output carries the import error itself; this names which file it stopped |
@@ -18,11 +18,11 @@ export declare class UnwritableValue extends Error {
18
18
  * body needs one after, and putting that decision here would mean this function
19
19
  * had to be told which case it was in anyway.
20
20
  *
21
- * `params` and `outOfScope` are omitted when empty rather than written as `{}`
22
- * and `[]`. The schema defaults both, so the two spellings mean the same thing,
23
- * and the shorter one is what a person writing this entry by hand would have
24
- * produced — which is the standard for a file `--apply` is merging into rather
25
- * than generating.
21
+ * `params`, `outOfScope` and `open` are omitted when empty rather than written
22
+ * as `{}` and `[]`. The schema defaults all three, so the two spellings mean the
23
+ * same thing, and the shorter one is what a person writing this entry by hand
24
+ * would have produced — which is the standard for a file `--apply` is merging
25
+ * into rather than generating.
26
26
  */
27
27
  export declare function requirementSource(id: string, req: Requirement, indent: string): string;
28
28
  /**