@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.
@@ -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 `outOfScope` are omitted when empty rather than written as `{}`
152
- * and `[]`. The schema defaults both, so the two spellings mean the same thing,
153
- * and the shorter one is what a person writing this entry by hand would have
154
- * produced — which is the standard for a file `--apply` is merging into rather
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: ${outOfScopeSource(req.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
- /** One `outOfScope` list as source. Shared with the modification writer below. */
181
- function outOfScopeSource(entries) {
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, outOfScopeSource(after.outOfScope));
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', outOfScopeSource(after.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;
@@ -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 one WARNING:
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
@@ -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 one WARNING:
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.2",
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
- "scripts": {
71
- "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
72
- "build": "pnpm run clean && tsc -p tsconfig.json",
73
- "typecheck": "tsc -p tsconfig.json --noEmit",
74
- "typecheck:all": "tsc -p tsconfig.typecheck.json",
75
- "test": "vitest run",
76
- "test:watch": "vitest",
77
- "test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
78
- "lint": "eslint .",
79
- "attest": "node bin/attest.js",
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
+ }