@am_shork/attest 0.7.2 → 0.7.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.
@@ -20,6 +20,7 @@
20
20
  // value is fixed by the source text — a second walker would be a second answer
21
21
  // to "is this a literal", and those two answers must not be able to disagree.
22
22
  import ts from 'typescript';
23
+ import { parseSource } from './compiler.js';
23
24
  import { RegistryValidationError, withProposedRequirements } from './registry.js';
24
25
  import { RegistrySchema } from './schema.js';
25
26
  /** The authoring function a registry file must default-export the result of. */
@@ -45,7 +46,20 @@ class NotStatic extends Error {
45
46
  * has to be run — and only the prose around it differs.
46
47
  */
47
48
  function extractLiteralExport(file, source, fn, noun) {
48
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
49
+ // Strict: a source that does not compile is refused here rather than read out
50
+ // of whatever the parser recovered from it. Before this, a registry truncated
51
+ // before its closing `});` came back as a complete registry.
52
+ const parsed = parseSource(file, source);
53
+ if ('error' in parsed) {
54
+ return {
55
+ ok: false,
56
+ reason: 'syntax',
57
+ message: `This ${noun} does not compile (${parsed.error.message}), so nothing in it was checked. ` +
58
+ `Fix the syntax error and run again.`,
59
+ ...(parsed.error.line === undefined ? {} : { line: parsed.error.line }),
60
+ };
61
+ }
62
+ const sf = parsed.sf;
49
63
  const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
50
64
  const exported = defaultExportExpression(sf);
51
65
  if (!exported) {
@@ -91,10 +105,25 @@ function failure(extracted, code) {
91
105
  ...(extracted.line === undefined ? {} : { line: extracted.line }),
92
106
  };
93
107
  }
108
+ /**
109
+ * What this reader calls the three ways extraction fails; `DELTA_CODE` below is
110
+ * the other half.
111
+ *
112
+ * A table per reader rather than a ternary in each, because only one row
113
+ * differs in kind: `syntax` is the same answer for both — a file that does not
114
+ * compile is not a registry problem or a delta problem, it is not a file — and
115
+ * two readers spelling that differently is exactly the disagreement this module
116
+ * opens by refusing to allow.
117
+ */
118
+ const REGISTRY_CODE = {
119
+ syntax: 'unreadable-file',
120
+ 'no-default': 'registry-no-default',
121
+ 'not-static': 'registry-not-static',
122
+ };
94
123
  export function readRegistrySource(file, source) {
95
124
  const extracted = extractLiteralExport(file, source, DEFINE, 'registry');
96
125
  if (!extracted.ok) {
97
- return failure(extracted, extracted.reason === 'no-default' ? 'registry-no-default' : 'registry-not-static');
126
+ return failure(extracted, REGISTRY_CODE[extracted.reason]);
98
127
  }
99
128
  const result = RegistrySchema.safeParse(extracted.value);
100
129
  if (!result.success) {
@@ -109,6 +138,12 @@ export function readRegistrySource(file, source) {
109
138
  }
110
139
  return { ok: true, registry: result.data };
111
140
  }
141
+ /** The delta reader's half of the table above `readRegistrySource`. */
142
+ const DELTA_CODE = {
143
+ syntax: 'unreadable-file',
144
+ 'no-default': 'change-not-found',
145
+ 'not-static': 'registry-not-static',
146
+ };
112
147
  /**
113
148
  * Read a change's `delta({ … })` from its source (design §7).
114
149
  *
@@ -127,7 +162,7 @@ export function readRegistrySource(file, source) {
127
162
  export function readDeltaSource(file, source) {
128
163
  const extracted = extractLiteralExport(file, source, DELTA, 'delta');
129
164
  if (!extracted.ok) {
130
- return failure(extracted, extracted.reason === 'no-default' ? 'change-not-found' : 'registry-not-static');
165
+ return failure(extracted, DELTA_CODE[extracted.reason]);
131
166
  }
132
167
  const value = extracted.value;
133
168
  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
@@ -148,6 +183,78 @@ export function readDeltaSource(file, source) {
148
183
  // thing that would notice, and it should have nothing to notice.
149
184
  return { ok: true, delta: withProposedRequirements(value) };
150
185
  }
186
+ /**
187
+ * The requirement ids a source *declares*, read from a file that could not be
188
+ * read as a registry (design §5.3).
189
+ *
190
+ * This is not a second registry reader and cannot become one: it returns ids and
191
+ * nothing else, it validates none of them, and no command builds a `Registry`
192
+ * from what it finds. What it exists for is the question `orphan-test` cannot
193
+ * otherwise answer — is this scenario attesting an id that a *broken* file
194
+ * declares, or one that genuinely does not exist — and the answer decides
195
+ * whether a finding is a fact or fallout.
196
+ *
197
+ * It reads the ids rather than the file's id *prefix*, which is what this was
198
+ * first framed as needing. The prefix cannot be recovered from a path: this
199
+ * repository's own registry is `attest.reqs.ts` holding `ATX-*`, which is the
200
+ * evidence that killed the prefix-matches-filename rule (see CHANGELOG,
201
+ * `Considered and rejected`). The ids are in the source, so nothing has to be
202
+ * inferred from a naming convention that nothing enforces.
203
+ *
204
+ * Recall is partial by construction, and the direction of the miss is the point:
205
+ * an id it does not find keeps its `orphan-test`, so the report stays noisy —
206
+ * never wrong. Measured over the failure modes that produce an unreadable
207
+ * registry: a truncated file, a missing default export, a non-literal *value*, a
208
+ * schema-invalid entry, a module that throws at import, and a spread of ids from
209
+ * another module all yield the ids written in this file; only a registry built
210
+ * by a call — `export default buildReqs()` — yields none, and that file contains
211
+ * no id to find.
212
+ *
213
+ * `ts.createSourceFile` is deliberately error-tolerant, which is what lets the
214
+ * first of those cases work at all: the parser recovers an object literal from a
215
+ * file that does not compile.
216
+ */
217
+ export function declaredIdsFromSource(file, source) {
218
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
219
+ const names = localNames(sf, DEFINE);
220
+ const ids = new Set();
221
+ // Any `defineRequirements({ … })` in the file, not only the exported one: the
222
+ // failure being diagnosed is often that the call is not where it should be —
223
+ // `export const registry = …` is a whole failure mode — and the ids are no
224
+ // less declared for it. Scoped to that call's argument all the same, so an
225
+ // id-shaped key in unrelated data is not mistaken for a requirement.
226
+ const visit = (node) => {
227
+ // `arguments.length > 0` rather than `=== 1`, which is what the reader
228
+ // demands: this runs on files the reader has already refused, so being
229
+ // stricter than it here could only lose ids it is not deciding anything by.
230
+ if (ts.isCallExpression(node) && node.arguments.length > 0 && callsAuthoringFn(node, names, DEFINE)) {
231
+ const arg = unwrap(node.arguments[0]);
232
+ if (ts.isObjectLiteralExpression(arg)) {
233
+ for (const prop of arg.properties) {
234
+ const name = prop.name;
235
+ if (!name)
236
+ continue;
237
+ if (!ts.isStringLiteral(name) && !ts.isIdentifier(name))
238
+ continue;
239
+ if (REQUIREMENT_ID.test(name.text))
240
+ ids.add(name.text);
241
+ }
242
+ }
243
+ }
244
+ ts.forEachChild(node, visit);
245
+ };
246
+ visit(sf);
247
+ return [...ids];
248
+ }
249
+ /**
250
+ * The id shape, as a bare regex rather than through `RequirementIdSchema`.
251
+ *
252
+ * Nothing is being validated here — a key that does not look like an id is
253
+ * simply not one, and there is no author to tell. Kept identical to the schema's
254
+ * pattern by `tests/static-registry.spec.ts`, which is the only place the two
255
+ * spellings can be held together.
256
+ */
257
+ const REQUIREMENT_ID = /^[A-Z]+-\d+$/;
151
258
  /**
152
259
  * The expression a file default-exports, following one level of local binding.
153
260
  *
@@ -191,10 +298,20 @@ function constInitializer(sf, name) {
191
298
  function authoringCall(expr, sf, fn) {
192
299
  if (!ts.isCallExpression(expr) || expr.arguments.length !== 1)
193
300
  return undefined;
194
- const callee = expr.expression;
195
- const named = (ts.isIdentifier(callee) && localNames(sf, fn).has(callee.text)) ||
196
- (ts.isPropertyAccessExpression(callee) && callee.name.text === fn);
197
- return named ? expr.arguments[0] : undefined;
301
+ return callsAuthoringFn(expr, localNames(sf, fn), fn) ? expr.arguments[0] : undefined;
302
+ }
303
+ /**
304
+ * Whether a call is a call of `fn`, by the local names the imports bound to it.
305
+ *
306
+ * One predicate for both readers of this question — the extraction above and the
307
+ * id recovery below — because they must not be able to disagree about what
308
+ * counts as the authoring call. An alias rule taught to one and not the other
309
+ * would make recovery silently miss exactly the files the reader refuses.
310
+ */
311
+ function callsAuthoringFn(call, names, fn) {
312
+ const callee = call.expression;
313
+ return ((ts.isIdentifier(callee) && names.has(callee.text)) ||
314
+ (ts.isPropertyAccessExpression(callee) && callee.name.text === fn));
198
315
  }
199
316
  /** Local names bound to the imported `fn`, including aliases. */
200
317
  function localNames(sf, fn) {
@@ -329,7 +446,15 @@ function numericValue(node) {
329
446
  return value;
330
447
  }
331
448
  export function registryInsertionPoint(file, source) {
332
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
449
+ // The write site, and the one where a recovered AST does damage rather than
450
+ // merely misreports: an offset taken from a file that does not compile would
451
+ // splice a new requirement into it. `undefined` is this function's documented
452
+ // refusal to guess, and a source that will not parse is the clearest case of
453
+ // one there is.
454
+ const parsed = parseSource(file, source);
455
+ if ('error' in parsed)
456
+ return undefined;
457
+ const sf = parsed.sf;
333
458
  const exported = defaultExportExpression(sf);
334
459
  if (!exported)
335
460
  return undefined;
@@ -1,3 +1,4 @@
1
+ import type { UnreadableRegistry } from './locate.js';
1
2
  import type { AttestPlan, Issue, ParamRef, Registry } from './types.js';
2
3
  /**
3
4
  * Uncovered requirements: intent exists but no scenario attests it (design §5.3).
@@ -20,11 +21,15 @@ export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): I
20
21
  * and one WARNING:
21
22
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
22
23
  *
23
- * `registryIncomplete` says that at least one `*.reqs.ts` failed to load, so
24
- * `registry` is known to be missing whatever was in it. It changes no verdict —
25
- * only what `orphan-test` advises, for the reason below.
24
+ * and one more WARNING, which exists only when a registry file failed to load:
25
+ * - orphan-from-failed-registry: the scenarios attesting ids that file declares
26
+ *
27
+ * `unreadable` is the `*.reqs.ts` files that failed to load, each with the ids
28
+ * its source still names (see `loadRegistry`). It changes no verdict — only
29
+ * which findings are stated per scenario and which are stated once, for the
30
+ * reason below.
26
31
  */
27
- export declare function validateStructure(registry: Registry, plan: AttestPlan, registryIncomplete?: boolean): Issue[];
32
+ export declare function validateStructure(registry: Registry, plan: AttestPlan, unreadable?: readonly UnreadableRegistry[]): Issue[];
28
33
  /**
29
34
  * Weak anti-drift heuristic (design §6, mechanism 3). Evaluated per requirement,
30
35
  * not per scenario: a requirement that owns params is quiet as soon as *any* one
@@ -1,5 +1,6 @@
1
1
  // Structural validation + anti-drift heuristic (design §5.3, §6).
2
2
  // Graded reporting: ERROR / WARNING / INFO, each with file + line + fix hint.
3
+ import { byCodeUnit } from './order.js';
3
4
  /**
4
5
  * Uncovered requirements: intent exists but no scenario attests it (design §5.3).
5
6
  *
@@ -35,11 +36,15 @@ export function uncoveredIssues(registry, plan) {
35
36
  * and one WARNING:
36
37
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
37
38
  *
38
- * `registryIncomplete` says that at least one `*.reqs.ts` failed to load, so
39
- * `registry` is known to be missing whatever was in it. It changes no verdict —
40
- * only what `orphan-test` advises, for the reason below.
39
+ * and one more WARNING, which exists only when a registry file failed to load:
40
+ * - orphan-from-failed-registry: the scenarios attesting ids that file declares
41
+ *
42
+ * `unreadable` is the `*.reqs.ts` files that failed to load, each with the ids
43
+ * its source still names (see `loadRegistry`). It changes no verdict — only
44
+ * which findings are stated per scenario and which are stated once, for the
45
+ * reason below.
41
46
  */
42
- export function validateStructure(registry, plan, registryIncomplete = false) {
47
+ export function validateStructure(registry, plan, unreadable = []) {
43
48
  const issues = [];
44
49
  const knownIds = new Set(Object.keys(registry));
45
50
  // orphan-test: covers a requirement that does not exist.
@@ -50,27 +55,75 @@ export function validateStructure(registry, plan, registryIncomplete = false) {
50
55
  // following the hint would add a duplicate. That case is not rare when it
51
56
  // happens — one unreadable `*.reqs.ts` orphans every scenario of every
52
57
  // requirement it declared, so the wrong advice is also the loudest thing in
53
- // the report.
58
+ // the report. Measured on one broken registry of three requirements: six
59
+ // ERRORs, of which one was the cause and five were its shadow.
60
+ //
61
+ // Which of the two a given orphan is *can* be decided here, because
62
+ // `loadRegistry` brings back the ids the broken file's source still names. So
63
+ // the scenarios attesting those ids are not reported one by one: they are
64
+ // fallout from a finding already in the report, and the fallout is stated once
65
+ // per file that caused it. That is the same move `duplicate-prefix` makes —
66
+ // the fact is about the file, not about each requirement that reveals it — and
67
+ // the same order ATX-62 records for `spec-load-failed`: the replacement comes
68
+ // first, the withdrawal follows it, because suppressing with nothing in its
69
+ // place trades a wrong message for silence.
54
70
  //
55
- // Which of the two this is cannot be decided here: the ids of a file that
56
- // never parsed are exactly what is unavailable. So the finding stands and the
57
- // *advice* names the uncertainty and the order to work in a report that is
58
- // quieter about problems it can still see would be the worse trade for a
59
- // command whose contract is breadth.
60
- const orphanFix = (id) => registryIncomplete
61
- ? `A registry file failed to load, so ids it declares are missing here — fix that first, and add "${id}" only if it is still unknown afterwards.`
71
+ // Nothing is lost by collapsing them. The replacement names the file, the
72
+ // count and the ids, so a reader can still see which scenarios are affected;
73
+ // the load failure is already an ERROR, so the verdict cannot move; and the
74
+ // scenarios themselves are not defective they will be verified normally the
75
+ // moment the registry loads.
76
+ const swallowed = new Map(unreadable.map((u) => [u.file, []]));
77
+ const ownerOf = new Map();
78
+ for (const u of unreadable)
79
+ for (const id of u.ids)
80
+ ownerOf.set(id, u.file);
81
+ // An unreadable file whose ids could not be recovered leaves the question open
82
+ // for every orphan that is not claimed by another one, and the hedged advice
83
+ // is what an open question sounds like. When every unreadable file gave up its
84
+ // ids, an orphan none of them claims is genuinely unknown — and gets the plain
85
+ // advice it deserves, which the hedge had been withdrawing from correct
86
+ // findings too.
87
+ const someFileOpaque = unreadable.some((u) => u.ids.length === 0);
88
+ const orphanFix = (id) => someFileOpaque
89
+ ? `A registry file failed to load and its ids could not be read, so "${id}" may be one of them — fix that first, and add it only if it is still unknown afterwards.`
62
90
  : `Add it to the registry, or fix the id.`;
63
91
  for (const s of plan.scenarios) {
64
- if (!knownIds.has(s.reqId)) {
65
- issues.push({
66
- level: 'ERROR',
67
- code: 'orphan-test',
68
- reqId: s.reqId,
69
- file: s.file,
70
- line: s.line,
71
- message: `scenario "${s.name}" attests unknown requirement "${s.reqId}". ${orphanFix(s.reqId)}`,
72
- });
92
+ if (knownIds.has(s.reqId))
93
+ continue;
94
+ const owner = ownerOf.get(s.reqId);
95
+ if (owner !== undefined) {
96
+ swallowed.get(owner).push(s.reqId);
97
+ continue;
73
98
  }
99
+ issues.push({
100
+ level: 'ERROR',
101
+ code: 'orphan-test',
102
+ reqId: s.reqId,
103
+ file: s.file,
104
+ line: s.line,
105
+ message: `scenario "${s.name}" attests unknown requirement "${s.reqId}". ${orphanFix(s.reqId)}`,
106
+ });
107
+ }
108
+ // WARNING, not ERROR: the defect is the load failure, which is an ERROR of its
109
+ // own and always present when this is. Reporting it a second time at the same
110
+ // level would say a broken registry is two problems.
111
+ //
112
+ // No `reqId`: the finding is about a file and the set of ids it took down with
113
+ // it, and no single requirement is implicated — the same reason
114
+ // `duplicate-prefix` and `spec-load-failed` carry none.
115
+ for (const u of unreadable) {
116
+ const ids = swallowed.get(u.file);
117
+ if (ids.length === 0)
118
+ continue;
119
+ const unique = [...new Set(ids)].sort(byCodeUnit);
120
+ issues.push({
121
+ level: 'WARNING',
122
+ code: 'orphan-from-failed-registry',
123
+ file: u.file,
124
+ message: `${ids.length} ${ids.length === 1 ? 'scenario attests' : 'scenarios attest'} ids ${u.file} declares (${unique.join(', ')}), and it failed to load — so those ids are missing from this run. ` +
125
+ `They are not orphans: fix the load failure reported above and they will be verified as usual.`,
126
+ });
74
127
  }
75
128
  // uncovered-requirement: intent exists but no scenario attests it. Shared
76
129
  // with `cover`, so the two commands cannot drift apart.
package/package.json CHANGED
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.7.2",
3
+ "version": "0.7.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",
7
6
  "engines": {
8
7
  "node": ">=20.19"
9
8
  },
@@ -37,24 +36,6 @@
37
36
  "publishConfig": {
38
37
  "access": "public"
39
38
  },
40
- "scripts": {
41
- "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
42
- "build": "pnpm run clean && tsc -p tsconfig.json",
43
- "typecheck": "tsc -p tsconfig.json --noEmit",
44
- "typecheck:all": "tsc -p tsconfig.typecheck.json",
45
- "test": "vitest run",
46
- "test:watch": "vitest",
47
- "test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
48
- "lint": "eslint .",
49
- "prepack": "pnpm run build",
50
- "prepublishOnly": "pnpm run test:consumer",
51
- "attest": "node bin/attest.js",
52
- "check:self": "node bin/attest.js check self",
53
- "verify:self": "node bin/attest.js verify self",
54
- "cover:self": "node bin/attest.js cover self",
55
- "render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
56
- "render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
57
- },
58
39
  "keywords": [
59
40
  "tdd",
60
41
  "spec",
@@ -81,13 +62,20 @@
81
62
  "vite": "^8.1.5",
82
63
  "vitest": "^4.1.10"
83
64
  },
84
- "pnpm": {
85
- "onlyBuiltDependencies": [
86
- "esbuild"
87
- ],
88
- "overrides": {
89
- "brace-expansion@<5.0.9": ">=5.0.9",
90
- "js-yaml@<4.3.1": ">=4.3.1"
91
- }
65
+ "scripts": {
66
+ "clean": "node -e \"require('fs').rmSync('dist', {recursive: true, force: true})\"",
67
+ "build": "pnpm run clean && tsc -p tsconfig.json",
68
+ "typecheck": "tsc -p tsconfig.json --noEmit",
69
+ "typecheck:all": "tsc -p tsconfig.typecheck.json",
70
+ "test": "vitest run",
71
+ "test:watch": "vitest",
72
+ "test:consumer": "pnpm run build && vitest run --config vitest.consumer.config.ts",
73
+ "lint": "eslint .",
74
+ "attest": "node bin/attest.js",
75
+ "check:self": "node bin/attest.js check self",
76
+ "verify:self": "node bin/attest.js verify self",
77
+ "cover:self": "node bin/attest.js cover self",
78
+ "render:self": "node bin/attest.js render self --out self/requirements/SPEC.md",
79
+ "render:self:check": "node bin/attest.js render self --out self/requirements/SPEC.md --check"
92
80
  }
93
- }
81
+ }