@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/dist/core/splice.js
CHANGED
|
@@ -148,11 +148,11 @@ function paramSource(value) {
|
|
|
148
148
|
* body needs one after, and putting that decision here would mean this function
|
|
149
149
|
* had to be told which case it was in anyway.
|
|
150
150
|
*
|
|
151
|
-
* `params` and `
|
|
152
|
-
* and `[]`. The schema defaults
|
|
153
|
-
* and the shorter one is what a person writing this entry by hand
|
|
154
|
-
* produced — which is the standard for a file `--apply` is merging
|
|
155
|
-
* than generating.
|
|
151
|
+
* `params`, `outOfScope` and `open` are omitted when empty rather than written
|
|
152
|
+
* as `{}` and `[]`. The schema defaults all three, so the two spellings mean the
|
|
153
|
+
* same thing, and the shorter one is what a person writing this entry by hand
|
|
154
|
+
* would have produced — which is the standard for a file `--apply` is merging
|
|
155
|
+
* into rather than generating.
|
|
156
156
|
*/
|
|
157
157
|
export function requirementSource(id, req, indent) {
|
|
158
158
|
const inner = `${indent} `;
|
|
@@ -172,13 +172,28 @@ export function requirementSource(id, req, indent) {
|
|
|
172
172
|
lines.push(`${inner}params: { ${body} },`);
|
|
173
173
|
}
|
|
174
174
|
if (req.outOfScope.length > 0) {
|
|
175
|
-
lines.push(`${inner}outOfScope: ${
|
|
175
|
+
lines.push(`${inner}outOfScope: ${stringListSource(req.outOfScope)},`);
|
|
176
|
+
}
|
|
177
|
+
// Reachable only through a delta the gate never approves — `open-unresolved`
|
|
178
|
+
// blocks any end state that still carries one, so an ADDED entry written here
|
|
179
|
+
// has an empty list. Emitted anyway rather than left out: this function's
|
|
180
|
+
// contract is to write the requirement it is given, and a writer silently
|
|
181
|
+
// shorter than the schema is exactly the divergence the MODIFIED half below
|
|
182
|
+
// was found to have.
|
|
183
|
+
if (req.open.length > 0) {
|
|
184
|
+
lines.push(`${inner}open: ${stringListSource(req.open)},`);
|
|
176
185
|
}
|
|
177
186
|
lines.push(`${indent}}`);
|
|
178
187
|
return lines.join('\n');
|
|
179
188
|
}
|
|
180
|
-
/**
|
|
181
|
-
|
|
189
|
+
/**
|
|
190
|
+
* One list-of-strings field as source — `outOfScope` or `open`.
|
|
191
|
+
*
|
|
192
|
+
* One function for both, and named for the shape rather than for either field,
|
|
193
|
+
* so a third such field does not arrive with a third spelling of the same
|
|
194
|
+
* emitter. Shared with the modification writer below.
|
|
195
|
+
*/
|
|
196
|
+
function stringListSource(entries) {
|
|
182
197
|
return `[${entries.map((s) => tsString(s)).join(', ')}]`;
|
|
183
198
|
}
|
|
184
199
|
/** One `params` key as source, at the key order the emitter writes everywhere. */
|
|
@@ -290,14 +305,24 @@ export function spliceModifications(file, source, changes) {
|
|
|
290
305
|
}
|
|
291
306
|
else if (name === 'outOfScope') {
|
|
292
307
|
if (!sameValue(before.outOfScope, after.outOfScope)) {
|
|
293
|
-
out.at(name, span,
|
|
308
|
+
out.at(name, span, stringListSource(after.outOfScope));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (name === 'open') {
|
|
312
|
+
if (!sameValue(before.open, after.open)) {
|
|
313
|
+
out.at(name, span, stringListSource(after.open));
|
|
294
314
|
}
|
|
295
315
|
}
|
|
296
316
|
else if (name === 'params') {
|
|
297
317
|
editParams(layout, before, after, out);
|
|
298
318
|
}
|
|
299
319
|
// Any other field is one the schema does not define, and not this
|
|
300
|
-
// module's to rewrite or to remove.
|
|
320
|
+
// module's to rewrite or to remove. `open` is above rather than here for
|
|
321
|
+
// exactly that reason: adding it to the schema without adding it to this
|
|
322
|
+
// loop would let the gate approve an end state where a question is closed
|
|
323
|
+
// while the file on disk still carries it — a divergence between the
|
|
324
|
+
// registry the verdict was reached on and the registry the merge commits,
|
|
325
|
+
// with nothing anywhere comparing the two.
|
|
301
326
|
}
|
|
302
327
|
if (!layout.fields.has('statement') && before.statement !== after.statement) {
|
|
303
328
|
openField('statement', tsString(after.statement));
|
|
@@ -311,7 +336,10 @@ export function spliceModifications(file, source, changes) {
|
|
|
311
336
|
openField('params', `{ ${fresh.join(', ')} }`);
|
|
312
337
|
}
|
|
313
338
|
if (!layout.fields.has('outOfScope') && !sameValue(before.outOfScope, after.outOfScope)) {
|
|
314
|
-
openField('outOfScope',
|
|
339
|
+
openField('outOfScope', stringListSource(after.outOfScope));
|
|
340
|
+
}
|
|
341
|
+
if (!layout.fields.has('open') && !sameValue(before.open, after.open)) {
|
|
342
|
+
openField('open', stringListSource(after.open));
|
|
315
343
|
}
|
|
316
344
|
if (opened.length > 0) {
|
|
317
345
|
const point = layout.fieldInsertion;
|
package/dist/core/validator.d.ts
CHANGED
|
@@ -19,8 +19,9 @@ export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): I
|
|
|
19
19
|
* - unbound-param: a statement placeholder has no matching param
|
|
20
20
|
* - non-scalar-interpolation: a statement placeholder names a structured param
|
|
21
21
|
*
|
|
22
|
-
* and
|
|
22
|
+
* and two WARNINGs:
|
|
23
23
|
* - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
|
|
24
|
+
* - requirement-open: the requirement names something still undecided about it
|
|
24
25
|
*
|
|
25
26
|
* and one more WARNING, which exists only when a registry file failed to load:
|
|
26
27
|
* - orphan-from-failed-registry: the scenarios attesting ids that file declares
|
package/dist/core/validator.js
CHANGED
|
@@ -34,8 +34,9 @@ export function uncoveredIssues(registry, plan) {
|
|
|
34
34
|
* - unbound-param: a statement placeholder has no matching param
|
|
35
35
|
* - non-scalar-interpolation: a statement placeholder names a structured param
|
|
36
36
|
*
|
|
37
|
-
* and
|
|
37
|
+
* and two WARNINGs:
|
|
38
38
|
* - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
|
|
39
|
+
* - requirement-open: the requirement names something still undecided about it
|
|
39
40
|
*
|
|
40
41
|
* and one more WARNING, which exists only when a registry file failed to load:
|
|
41
42
|
* - orphan-from-failed-registry: the scenarios attesting ids that file declares
|
|
@@ -208,6 +209,24 @@ export function validateStructure(registry, plan, unreadable = []) {
|
|
|
208
209
|
});
|
|
209
210
|
}
|
|
210
211
|
}
|
|
212
|
+
// requirement-open: the requirement says what is still undecided about it.
|
|
213
|
+
// WARNING and not an ERROR, because the four static commands are where an
|
|
214
|
+
// author works while a proposal legitimately still has questions in it —
|
|
215
|
+
// failing here would push them back to inventing a value, which is the
|
|
216
|
+
// behaviour the field exists to replace. What refuses to call such a registry
|
|
217
|
+
// done is the archive gate (`open-unresolved`), the same asymmetry `never-red`
|
|
218
|
+
// runs on. One issue per question rather than one per requirement: each is a
|
|
219
|
+
// separate thing to answer, and a reader resolving them wants them listed.
|
|
220
|
+
for (const [id, req] of Object.entries(registry)) {
|
|
221
|
+
for (const question of req.open) {
|
|
222
|
+
issues.push({
|
|
223
|
+
level: 'WARNING',
|
|
224
|
+
code: 'requirement-open',
|
|
225
|
+
reqId: id,
|
|
226
|
+
message: `Requirement "${id}" is still open: ${question} Answer it and remove the entry from open — the archive gate refuses a registry that still carries one.`,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
211
230
|
return issues;
|
|
212
231
|
}
|
|
213
232
|
/**
|
package/dist/inspect.d.ts
CHANGED
|
@@ -51,4 +51,96 @@ export interface Inspection {
|
|
|
51
51
|
* not ours to choose.
|
|
52
52
|
*/
|
|
53
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>;
|
|
54
146
|
//# sourceMappingURL=inspect.d.ts.map
|
package/dist/inspect.js
CHANGED
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
// `tests/import-boundary.spec.ts` gates that rather than leaving it to review —
|
|
25
25
|
// this module is on the short list of entry points whose closure is walked.
|
|
26
26
|
import { resolve } from 'node:path';
|
|
27
|
+
import { readArchivedEvidence } from './core/archive.js';
|
|
27
28
|
import { loadRegistry, parseSpecs, scanProject, staticReader } from './core/locate.js';
|
|
29
|
+
import { recordedOutcome } from './core/red-record.js';
|
|
28
30
|
/**
|
|
29
31
|
* Read the requirements and scenarios declared under `root`, executing nothing.
|
|
30
32
|
*
|
|
@@ -65,4 +67,57 @@ export async function inspectProject(root) {
|
|
|
65
67
|
issues: [...loaded.issues, ...parsed.issues],
|
|
66
68
|
};
|
|
67
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
|
+
}
|
|
68
123
|
//# sourceMappingURL=inspect.js.map
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@am_shork/attest",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"description": "TDD-native spec framework: tests are the source of truth for verification, ID-bound requirements the source of truth for intent.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"packageManager": "pnpm@10.28.0",
|
|
6
7
|
"engines": {
|
|
7
8
|
"node": ">=20.19"
|
|
8
9
|
},
|
|
@@ -41,6 +42,24 @@
|
|
|
41
42
|
"publishConfig": {
|
|
42
43
|
"access": "public"
|
|
43
44
|
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
|
|
47
|
+
"build": "pnpm run clean && tsc -p tsconfig.json",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
49
|
+
"typecheck:all": "tsc -p tsconfig.typecheck.json",
|
|
50
|
+
"test": "vitest run",
|
|
51
|
+
"test:watch": "vitest",
|
|
52
|
+
"test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
|
|
53
|
+
"lint": "eslint .",
|
|
54
|
+
"prepack": "pnpm run build",
|
|
55
|
+
"prepublishOnly": "pnpm run test:consumer",
|
|
56
|
+
"attest": "node bin/attest.js",
|
|
57
|
+
"check:self": "node bin/attest.js check self",
|
|
58
|
+
"verify:self": "node bin/attest.js verify self",
|
|
59
|
+
"cover:self": "node bin/attest.js cover self",
|
|
60
|
+
"render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
|
|
61
|
+
"render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
|
|
62
|
+
},
|
|
44
63
|
"keywords": [
|
|
45
64
|
"tdd",
|
|
46
65
|
"spec",
|
|
@@ -67,20 +86,15 @@
|
|
|
67
86
|
"vite": "^8.1.5",
|
|
68
87
|
"vitest": "^4.1.10"
|
|
69
88
|
},
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
"
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
"check:self": "node bin/attest.js check self",
|
|
81
|
-
"verify:self": "node bin/attest.js verify self",
|
|
82
|
-
"cover:self": "node bin/attest.js cover self",
|
|
83
|
-
"render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
|
|
84
|
-
"render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
|
|
89
|
+
"pnpm": {
|
|
90
|
+
"onlyBuiltDependencies": [
|
|
91
|
+
"esbuild"
|
|
92
|
+
],
|
|
93
|
+
"overrides": {
|
|
94
|
+
"brace-expansion@<5.0.9": ">=5.0.9",
|
|
95
|
+
"js-yaml@<4.3.1": ">=4.3.1",
|
|
96
|
+
"nanoid@<3.3.17": ">=3.3.17",
|
|
97
|
+
"postcss@<8.5.23": ">=8.5.23"
|
|
98
|
+
}
|
|
85
99
|
}
|
|
86
|
-
}
|
|
100
|
+
}
|