@am_shork/attest 0.4.3 → 0.5.0

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.
@@ -4,6 +4,8 @@
4
4
  // never crosses the process boundary; the serialized task tree does.
5
5
  import { startVitest } from 'vitest/node';
6
6
  import { configDefaults } from 'vitest/config';
7
+ import { relativePath } from './paths.js';
8
+ import { byCodeUnit } from './order.js';
7
9
  /** requirement() names each describe block `[reqId]`; recover the id from that. */
8
10
  const REQ_SUITE = /^\[(.+)\]$/;
9
11
  /**
@@ -17,8 +19,21 @@ const REQ_SUITE = /^\[(.+)\]$/;
17
19
  * command goes through — the boundary the gate exists to hold.
18
20
  */
19
21
  export const BASE_EXCLUDE = configDefaults.exclude;
20
- /** Proposed / archived changes are excluded from a normal run (design §7, §8). */
21
- const DEFAULT_EXCLUDE = [...BASE_EXCLUDE, '**/changes/**', '**/archive/**'];
22
+ /**
23
+ * Proposed / archived changes are excluded from a normal run (design §7, §8).
24
+ *
25
+ * The `.proposed.spec.ts` glob is the one carrying the weight now: a proposal's
26
+ * specs sit at their merged location, next to the code they attest, so nothing
27
+ * about *where* they are keeps them out of a run. They are red by construction
28
+ * until their change is implemented, and a default run that swept them up would
29
+ * report a project as broken for the whole life of every change in flight.
30
+ */
31
+ const DEFAULT_EXCLUDE = [
32
+ ...BASE_EXCLUDE,
33
+ '**/*.proposed.spec.ts',
34
+ '**/changes/**',
35
+ '**/archive/**',
36
+ ];
22
37
  /**
23
38
  * Build the child-run options. Attest owns the run *scope* — include/exclude/
24
39
  * root/watch always come from here, so the caller can neither widen the spec
@@ -88,20 +103,42 @@ export async function runAndCollect(options = {}) {
88
103
  // that notices if the task tree ever stops having a `type`, a `name` or a
89
104
  // `result.state`. An `as unknown as` here bought nothing and blinded it to
90
105
  // exactly the change that would silently empty the runtime coverage.
91
- for (const file of vitest.state.getFiles())
106
+ const unloadedFiles = [];
107
+ for (const file of vitest.state.getFiles()) {
92
108
  walk(file);
109
+ // Relative and POSIX for the reason everything derived from the root is
110
+ // (`paths.ts`): this becomes an `Issue.file`, which a `--json` consumer
111
+ // diffs across two CI runs that may not share an operating system.
112
+ if (failedToLoad(file)) {
113
+ unloadedFiles.push(relativePath(options.root ?? process.cwd(), file.filepath));
114
+ }
115
+ }
116
+ unloadedFiles.sort(byCodeUnit);
93
117
  // No optional call and no `?? 0` default: `getCountOfFailedTests` is a
94
118
  // required method on Vitest's state, so the guard was dead at the type
95
119
  // level — and had it ever become live it defaulted the wrong way, reading a
96
120
  // missing API as zero failures and turning a red suite green. This is the
97
121
  // one boolean in the engine that must never fail open.
98
122
  const passed = vitest.state.getCountOfFailedTests() === 0;
99
- return { passed, runtimeCoverage, outcomes };
123
+ return { passed, runtimeCoverage, outcomes, unloadedFiles };
100
124
  }
101
125
  finally {
102
126
  await vitest.close();
103
127
  }
104
128
  }
129
+ /**
130
+ * Did this file fail before any of its tests could exist?
131
+ *
132
+ * Errors recorded on the *file* rather than on a test are collection errors —
133
+ * the module threw while being imported. Measured rather than assumed: a spec
134
+ * whose assertion fails carries `errors: 0` at the file level and one failing
135
+ * test task, while a spec whose import throws carries `errors: 1` and no tasks
136
+ * at all. Both leave `result.state` at `fail`, which is why the state alone
137
+ * cannot tell them apart.
138
+ */
139
+ function failedToLoad(file) {
140
+ return (file.result?.errors?.length ?? 0) > 0;
141
+ }
105
142
  /** The outcome of a task that ran, or `undefined` when it did not run at all. */
106
143
  function executedOutcome(task) {
107
144
  const state = task.result?.state;
@@ -14,10 +14,18 @@ export const RequirementSchema = z.object({
14
14
  // blacklists, id sets) are the most drift-prone constants, so keeping them out
15
15
  // of params left the highest-risk values unguarded; an array still has exactly
16
16
  // one owner (the spec) read by exactly one place (the scenario).
17
+ // The union carries its own message because a union's default one is the
18
+ // word "Invalid input", which names neither what was given nor what is
19
+ // accepted. A nested object is the value that reaches it — a table of
20
+ // kind -> weight is the natural thing to try — and the constraint that
21
+ // refuses it is stated nowhere the author is looking, so the message is
22
+ // where they find it.
17
23
  params: z
18
24
  .record(z.string(), (() => {
19
25
  const scalar = z.union([z.number(), z.string(), z.boolean()]);
20
- return z.union([scalar, z.array(scalar)]);
26
+ return z.union([scalar, z.array(scalar)], {
27
+ errorMap: () => ({ message: 'expected a string, number, boolean, or an array of those' }),
28
+ });
21
29
  })())
22
30
  .default({}),
23
31
  outOfScope: z.array(z.string()).default([]),
@@ -74,12 +74,22 @@ promises is a two-stage workflow, and the stages are separate on purpose.
74
74
  a limit, a list of names — is written **once**, in the requirement's \`params\`,
75
75
  and the scenario reads it from there. The number then cannot drift between the
76
76
  spec and the assertion, because there is only one of it.
77
+ - **What earns a param: promises, not tuning.** A value belongs in \`params\` when
78
+ the requirement promises it — a timeout the user is owed, a budget someone would
79
+ file a bug about. A value that only tunes behaviour (a search depth, a cache
80
+ size) stays an ordinary constant: nothing is owed if it changes, and a registry
81
+ that accumulates every knob in the project is one of the ways intent bloats.
77
82
  - **What that does not buy.** \`attest check\` executes nothing, so editing a
78
83
  param value leaves it at \`✓ No issues\` — nothing became unbound or uncovered.
79
84
  The single source makes a value impossible to *diverge*; it does not announce
80
85
  that it *moved*. Only \`verify\` goes red, and only when a scenario asserts on
81
- the value it read from params. That is the reason to read params **inside** the
82
- assertion rather than beside it.
86
+ the value it read from params.
87
+ - **Reading the param is necessary, not sufficient.** An assertion that
88
+ recomputes its expectation from the same param the code under test just read
89
+ has no independent term: both sides move together, and the scenario stays green
90
+ through any edit. Pin the expectation to something that does not move with the
91
+ param — a fixture, a literal in the test, or a second independently derived
92
+ value.
83
93
 
84
94
  ## Rules the engine enforces
85
95
 
@@ -128,10 +138,31 @@ Then write the change folder. \`<name>\` is one directory name inside
128
138
  \`\`\`
129
139
  changes/<name>/
130
140
  ├── proposal.md # why, and what is out of scope. Prose, for humans.
131
- ├── requirements.delta.ts # the intent change
132
- └── specs/*.spec.ts # the scenarios that must pass for it to be done
141
+ └── requirements.delta.ts # the intent change
133
142
  \`\`\`
134
143
 
144
+ **The change's scenarios do not live in that folder.** Each goes where it will
145
+ live once the change is merged — beside the code it attests, in the same
146
+ directory as the specs already there — under the name \`*.proposed.spec.ts\`:
147
+
148
+ \`\`\`
149
+ lib/game/specs/
150
+ ├── board.spec.ts # already merged
151
+ └── fog.proposed.spec.ts # this change's, and ../fog already resolves
152
+ \`\`\`
153
+
154
+ That name is the whole mechanism. It keeps the file out of \`attest verify\` and
155
+ out of a plain \`vitest run\` while the change is in flight — the scenarios are
156
+ red by construction — and \`attest archive <name>\` pulls it in by the requirement
157
+ ids it covers: a change claims the proposed specs that declare a scenario for an
158
+ id it ADDs, renames to, or MODIFIEs. Nothing lists paths anywhere, so nothing can
159
+ fall out of step. A proposed spec no change claims is a \`proposed-spec-unclaimed\`
160
+ ERROR from \`attest check\`, because it would otherwise run nowhere at all.
161
+
162
+ Writing it at its merged location is what makes merging it a rename. Its relative
163
+ imports resolve now exactly as they will afterwards, so \`../fog\` never becomes
164
+ \`../../../lib/game/fog\` and back again.
165
+
135
166
  \`\`\`ts
136
167
  // changes/add-2fa/requirements.delta.ts
137
168
  import { delta } from '@am_shork/attest/define';
@@ -156,8 +187,22 @@ The delta applies \`RENAMED → REMOVED → MODIFIED → ADDED\`, idempotently:
156
187
  ADDED id that already exists with identical content is a no-op, with different
157
188
  content it is a conflict.
158
189
 
190
+ **A delta also reads as the registry of what it adds**, so a scenario written now
191
+ reads a proposed param exactly as it will after the merge:
192
+
193
+ \`\`\`ts
194
+ import reqs from '../../changes/add-2fa/requirements.delta.ts'; // ← only this line changes at merge
195
+ const window = reqs['AUTH-7'].params.totpWindowSec; // typed at 30, no cast
196
+ \`\`\`
197
+
198
+ Never hand-write an accessor for a proposed param, and never copy the literal
199
+ into the assertion: both are a second place the value lives, opened during the
200
+ one stage where the assertion is being authored against a value nobody has
201
+ implemented yet. MODIFIED ids are deliberately not readable this way — the end
202
+ state is the base entry with the patch applied, and the base is not in that file.
203
+
159
204
  **The scenarios must be red.** Every added requirement needs at least one
160
- scenario under \`changes/<name>/specs/\`, and at this stage they must fail —
205
+ scenario in a \`*.proposed.spec.ts\`, and at this stage they must fail —
161
206
  they describe behaviour that does not exist yet. Prove it:
162
207
 
163
208
  \`\`\`
@@ -227,6 +272,8 @@ once. Branch on \`issues[].code\`, never on \`message\`:
227
272
  | \`never-red\` | an added requirement's scenario has no recorded failing run |
228
273
  | \`uncovered-requirement\` | a requirement in the applied registry has no scenario |
229
274
  | \`declared-not-run\` | a scenario was declared but never executed (\`skip\` / \`only\`?) |
275
+ | \`proposed-spec-unclaimed\` | a \`*.proposed.spec.ts\` no change's delta claims |
276
+ | \`added-id-unmerged\` | a spec failed to load, and this change adds an id the registry on disk lacks |
230
277
  | \`unbound-param\` | a \`{placeholder}\` has no matching \`params\` key |
231
278
  | \`registry-not-static\` | a registry file is not a literal the engine can read |
232
279
  | \`add-conflict\` | the delta adds an id that already exists with different content |
@@ -247,6 +294,12 @@ failure this framework exists to make visible:
247
294
  is the drift the single source exists to prevent, and the param is typed at
248
295
  the value written in the registry, so a stale expectation stops compiling
249
296
  rather than silently passing.
297
+ *One thing to know before implementation code reads a param this change
298
+ ADDs:* the suite imports your \`*.reqs.ts\` from disk, while the gate applies
299
+ the delta in memory — so that read throws at import and the gate answers
300
+ \`added-id-unmerged\`. Merge the added requirement into the registry and run
301
+ again; the \`added:\` entry stays, because applying it a second time with
302
+ identical content is a no-op.
250
303
  4. **Do not write or edit \`first-run.json\`.** It is the one file here the gate
251
304
  trusts without being able to check it, so a hand-written \`"fail"\` clears
252
305
  \`never-red\` while proving nothing at all. Run the gate before implementing;
@@ -256,8 +309,10 @@ failure this framework exists to make visible:
256
309
 
257
310
  Then, and not before:
258
311
 
259
- 1. Merge \`requirements.delta.ts\` into the main registry, and the change's specs
260
- into the main suite.
312
+ 1. Merge \`requirements.delta.ts\` into the main registry, and rename each of the
313
+ change's \`*.proposed.spec.ts\` to \`*.spec.ts\` **in place**. Nothing moves and
314
+ no import changes; if you find yourself editing a specifier, the spec was not
315
+ written at its merged location and stage 1 was the place to fix that.
261
316
  2. Move \`changes/<name>/\` to \`archive/<date>-<name>/\`.
262
317
  3. If the project commits a rendering, regenerate it: \`attest render --out
263
318
  <file>\`. A committed document that no longer matches the registry is a
@@ -20,7 +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 { RegistryValidationError } from './registry.js';
23
+ import { RegistryValidationError, withProposedRequirements } from './registry.js';
24
24
  import { RegistrySchema } from './schema.js';
25
25
  /** The authoring function a registry file must default-export the result of. */
26
26
  const DEFINE = 'defineRequirements';
@@ -142,7 +142,11 @@ export function readDeltaSource(file, source) {
142
142
  // function deliberately does not make (see the note above on why a delta is
143
143
  // not schema-validated here), which is the worst thing an assertion can do —
144
144
  // look like verification.
145
- return { ok: true, delta: value };
145
+ // Augmented exactly as `delta()` augments the evaluated one, through the same
146
+ // function: a delta reads as the registry of what it adds (design §7), and the
147
+ // two readers must not disagree about that — the differential suite is the
148
+ // thing that would notice, and it should have nothing to notice.
149
+ return { ok: true, delta: withProposedRequirements(value) };
146
150
  }
147
151
  /**
148
152
  * The expression a file default-exports, following one level of local binding.
@@ -40,6 +40,16 @@ export interface RunResult {
40
40
  * it, which is what §6's mechanism 2 records.
41
41
  */
42
42
  outcomes: Map<string, Map<string, Outcome>>;
43
+ /**
44
+ * Spec files that failed to *load*, as project-relative POSIX paths.
45
+ *
46
+ * A file whose module threw at import has no tasks at all, so every other
47
+ * signal it produces is an absence: no coverage, no outcomes, and scenarios
48
+ * the static plan declared but the runtime never saw. Those absences are
49
+ * indistinguishable from a `skip` unless the load failure is carried
50
+ * separately, which is what this is.
51
+ */
52
+ unloadedFiles: string[];
43
53
  }
44
54
  /** Severity levels for graded reporting (design §5.3). */
45
55
  export type Level = 'ERROR' | 'WARNING' | 'INFO';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
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
6
  "engines": {