@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/CHANGELOG.md +954 -83
- package/README.md +1 -1
- package/bin/attest.js +0 -0
- package/dist/core/apply.js +5 -0
- package/dist/core/archive.d.ts +59 -0
- package/dist/core/archive.js +142 -0
- package/dist/core/docs.d.ts +1 -1
- package/dist/core/docs.js +2 -0
- package/dist/core/gate.d.ts +8 -0
- package/dist/core/gate.js +38 -0
- package/dist/core/red-record.d.ts +40 -1
- package/dist/core/red-record.js +61 -7
- package/dist/core/render.js +8 -0
- package/dist/core/schema.d.ts +4 -0
- package/dist/core/schema.js +11 -0
- package/dist/core/skill.js +33 -0
- package/dist/core/splice.d.ts +5 -5
- package/dist/core/splice.js +39 -11
- package/dist/core/validator.d.ts +2 -1
- package/dist/core/validator.js +20 -1
- package/dist/inspect.d.ts +92 -0
- package/dist/inspect.js +55 -0
- package/package.json +31 -17
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.
|
|
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
|
package/dist/core/apply.js
CHANGED
|
@@ -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
|
package/dist/core/docs.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* and the `##` headings of both language documents, so landing here cannot
|
|
9
9
|
* produce a dead link.
|
|
10
10
|
*/
|
|
11
|
-
export declare const ISSUE_CODES: readonly ["add-conflict", "add-invalid", "added-id-unmerged", "apply-no-prefix-owner", "apply-unsupported-delta", "change-not-found", "compiler-unsupported", "declared-not-run", "duplicate-prefix", "duplicate-requirement", "empty-spec", "internal-error", "invalid-change-name", "missing-spec-doc", "modify-invalid", "modify-missing", "never-red", "non-scalar-interpolation", "orphan-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',
|
package/dist/core/gate.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
package/dist/core/red-record.js
CHANGED
|
@@ -97,9 +97,35 @@ function fileKey(file) {
|
|
|
97
97
|
function emptyMap() {
|
|
98
98
|
return Object.create(null);
|
|
99
99
|
}
|
|
100
|
-
/**
|
|
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
|
|
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(
|
|
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
|
-
|
|
263
|
-
|
|
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] =
|
|
320
|
+
forFile[s.name] = kept;
|
|
267
321
|
changed = true;
|
|
268
322
|
}
|
|
269
323
|
// Sort within each requirement, and within each file, for the same
|
package/dist/core/render.js
CHANGED
|
@@ -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. */
|
package/dist/core/schema.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/schema.js
CHANGED
|
@@ -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.
|
package/dist/core/skill.js
CHANGED
|
@@ -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 |
|
package/dist/core/splice.d.ts
CHANGED
|
@@ -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 `
|
|
22
|
-
* and `[]`. The schema defaults
|
|
23
|
-
* and the shorter one is what a person writing this entry by hand
|
|
24
|
-
* produced — which is the standard for a file `--apply` is merging
|
|
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
|
/**
|