@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/CHANGELOG.md +782 -16
- package/README.md +2 -1
- package/dist/core/archive.d.ts +59 -0
- package/dist/core/archive.js +142 -0
- package/dist/core/red-record.d.ts +40 -1
- package/dist/core/red-record.js +61 -7
- package/dist/core/skill.js +8 -0
- package/dist/inspect.d.ts +146 -0
- package/dist/inspect.js +123 -0
- package/package.json +5 -1
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.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
|
-
/**
|
|
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/skill.js
CHANGED
|
@@ -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
|
package/dist/inspect.js
ADDED
|
@@ -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
|