@am_shork/attest 0.5.0 → 0.7.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.
@@ -39,4 +39,34 @@ export type DeltaReadResult = {
39
39
  * (write the value inline, or `--eval`) is the same sentence either way.
40
40
  */
41
41
  export declare function readDeltaSource(file: string, source: string): DeltaReadResult;
42
+ /**
43
+ * Where a new entry may be written into a registry file's literal, as an offset
44
+ * into its source (design §7).
45
+ *
46
+ * The insertion point rather than a rewritten file, because `--apply` must not
47
+ * regenerate a `*.reqs.ts`. Every registry this tool merges into is hand-written
48
+ * and hand-commented, and rendering one back out of a `Registry` object would
49
+ * silently drop every comment and every choice of layout in it — the
50
+ * "destructive on a file the user cannot regenerate" shape the `AGENTS.md`
51
+ * proposal was rejected for. An offset lets the splice be a pure insertion: every
52
+ * other byte of the file is the byte that was there before, which is a property
53
+ * that can be stated and tested rather than hoped for.
54
+ *
55
+ * Lives here because this is the module that already knows how to find the
56
+ * literal, and the one place `typescript` is imported for that job. A second
57
+ * walker would be a second answer to "where does this registry's body end".
58
+ *
59
+ * `undefined` when the file is not a registry this tool can read — the same
60
+ * condition `readRegistrySource` reports as `registry-not-static` or
61
+ * `registry-no-default`, and the caller has already run that check.
62
+ */
63
+ export interface RegistryInsertion {
64
+ /** Offset to insert at. Everything before and after it is preserved. */
65
+ offset: number;
66
+ /** The indentation the file's existing entries use, reproduced for new ones. */
67
+ indent: string;
68
+ /** Whether the insertion has to open with a `,` — false only for an empty registry. */
69
+ leadingComma: boolean;
70
+ }
71
+ export declare function registryInsertionPoint(file: string, source: string): RegistryInsertion | undefined;
42
72
  //# sourceMappingURL=static-registry.d.ts.map
@@ -322,4 +322,40 @@ function numericValue(node) {
322
322
  throw new NotStatic(node, 'a number this reader cannot spell out');
323
323
  return value;
324
324
  }
325
+ export function registryInsertionPoint(file, source) {
326
+ const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, /* setParentNodes */ true);
327
+ const exported = defaultExportExpression(sf);
328
+ if (!exported)
329
+ return undefined;
330
+ const arg = authoringCall(exported, sf, 'defineRequirements');
331
+ if (!arg || !ts.isObjectLiteralExpression(arg))
332
+ return undefined;
333
+ const last = arg.properties[arg.properties.length - 1];
334
+ if (!last) {
335
+ // An empty registry: open the body rather than continue it. The trailing
336
+ // newline is written by the caller, so `{` does not end up sharing a line
337
+ // with the entry and the closing `}`.
338
+ return { offset: arg.getStart(sf) + 1, indent: indentOf(sf, source, arg) + ' ', leadingComma: false };
339
+ }
340
+ // Deliberately the *end of the last property*, not the end of the literal:
341
+ // inserting here sits before any trailing comma the file already has, so one
342
+ // leading comma is correct whether or not that comma is present, and the
343
+ // file's own trailing-comma style is left exactly as it was.
344
+ return { offset: last.getEnd(), indent: indentOf(sf, source, last), leadingComma: true };
345
+ }
346
+ /**
347
+ * The whitespace a node's line opens with.
348
+ *
349
+ * Read out of the source rather than computed from the column, so a file
350
+ * indented with tabs is continued with tabs. Anything else on the line (a
351
+ * property sharing a line with another) falls back to two spaces, which is the
352
+ * only case where this guesses.
353
+ */
354
+ function indentOf(sf, source, node) {
355
+ const start = node.getStart(sf);
356
+ const { line } = sf.getLineAndCharacterOfPosition(start);
357
+ const lineStart = sf.getPositionOfLineAndCharacter(line, 0);
358
+ const prefix = source.slice(lineStart, start);
359
+ return /^[\t ]*$/.test(prefix) ? prefix : ' ';
360
+ }
325
361
  //# sourceMappingURL=static-registry.js.map
@@ -29,10 +29,28 @@ import { hasRecordedRed, recordedOutcome } from './red-record.js';
29
29
  * author reads them in.
30
30
  */
31
31
  export function statusRows(addedIds, plan, firstRun) {
32
+ // Index the scenarios once, the way `validator.ts`'s `detectPotentialDrift`
33
+ // does and for the reason recorded there: filtering the whole plan per
34
+ // requirement is O(requirements x scenarios). The plan here is the *merged*
35
+ // one, so the inner term is the whole repository's scenario count while the
36
+ // outer is only what this change adds.
37
+ //
38
+ // The cost is invisible today — 20 added ids against 2000 scenarios is 40k
39
+ // comparisons, well under a millisecond — so this is filed as the
40
+ // inconsistency it is rather than as a slow path. Two functions over one
41
+ // shape held two beliefs about whether it is worth indexing; they hold one
42
+ // now. Insertion order is plan order, which is file then line, so the
43
+ // scenarios a row carries are still in the order their author reads them.
44
+ const byReqId = new Map();
45
+ for (const s of plan.scenarios) {
46
+ const group = byReqId.get(s.reqId);
47
+ if (group)
48
+ group.push(s);
49
+ else
50
+ byReqId.set(s.reqId, [s]);
51
+ }
32
52
  return [...addedIds].sort(byCodeUnit).map((reqId) => {
33
- const scenarios = plan.scenarios
34
- .filter((s) => s.reqId === reqId)
35
- .map((s) => ({
53
+ const scenarios = (byReqId.get(reqId) ?? []).map((s) => ({
36
54
  name: s.name,
37
55
  file: s.file,
38
56
  line: s.line,
@@ -19,6 +19,29 @@ export declare function inline(text: string): string;
19
19
  * A space rather than deletion: removing the byte would silently splice
20
20
  * `atte` + `st` into a word that was never in the file, and a diagnostic that
21
21
  * quietly rewrites what it quotes is its own kind of wrong.
22
+ *
23
+ * The class is written out rather than computed per character. The previous
24
+ * spelling spread the string into a per-code-point array, mapped and rejoined —
25
+ * three allocations proportional to the input, where the engine's own scan needs
26
+ * none when nothing matches, which is the case every real registry is. Measured
27
+ * over 120,000 characters: **27x** faster on plain ASCII, **54x** with
28
+ * newlines, **84x** on CJK. A payload that is *entirely* control bytes is a
29
+ * wash (0.9x), because then there is nothing to fast-path and both spellings
30
+ * build a new string; both are linear either way, so this is a constant factor
31
+ * rather than a second ATX-59.
32
+ *
33
+ * It is worth the change because the count scales with the registry while the
34
+ * length scales with whatever the registry chose: `render`'s `sanitised` calls
35
+ * this once per statement, rationale, param key, param value and out-of-scope
36
+ * entry, on the same reachable `render --check` path ATX-59 came off.
37
+ *
38
+ * **Byte-identical to the spelling it replaces**, which is the only thing that
39
+ * mattered: checked exhaustively over every code unit in the BMP, and over
40
+ * 200,000 randomised strings mixing control bytes, CJK, astral characters and
41
+ * lone surrogates. Surrogates are the case the spread existed to get right —
42
+ * `D800`–`DFFF` fall outside every range below, so neither spelling touches a
43
+ * pair or splits one — and newlines survive here as they always did, by not
44
+ * being in the class.
22
45
  */
23
46
  export declare function control(text: string): string;
24
47
  //# sourceMappingURL=terminal.d.ts.map
@@ -19,8 +19,6 @@
19
19
  // loader has to sanitise Vite's log output for the same reason and cannot
20
20
  // import from a layer above it. Two copies of this decision is how the crash
21
21
  // path came to be missed once already.
22
- /** The one control character that survives: see `block`. */
23
- const NEWLINE = 0x0a;
24
22
  /** How far a message is indented under its header line. */
25
23
  export const INDENT = ' ';
26
24
  /**
@@ -46,14 +44,32 @@ export function inline(text) {
46
44
  * A space rather than deletion: removing the byte would silently splice
47
45
  * `atte` + `st` into a word that was never in the file, and a diagnostic that
48
46
  * quietly rewrites what it quotes is its own kind of wrong.
47
+ *
48
+ * The class is written out rather than computed per character. The previous
49
+ * spelling spread the string into a per-code-point array, mapped and rejoined —
50
+ * three allocations proportional to the input, where the engine's own scan needs
51
+ * none when nothing matches, which is the case every real registry is. Measured
52
+ * over 120,000 characters: **27x** faster on plain ASCII, **54x** with
53
+ * newlines, **84x** on CJK. A payload that is *entirely* control bytes is a
54
+ * wash (0.9x), because then there is nothing to fast-path and both spellings
55
+ * build a new string; both are linear either way, so this is a constant factor
56
+ * rather than a second ATX-59.
57
+ *
58
+ * It is worth the change because the count scales with the registry while the
59
+ * length scales with whatever the registry chose: `render`'s `sanitised` calls
60
+ * this once per statement, rationale, param key, param value and out-of-scope
61
+ * entry, on the same reachable `render --check` path ATX-59 came off.
62
+ *
63
+ * **Byte-identical to the spelling it replaces**, which is the only thing that
64
+ * mattered: checked exhaustively over every code unit in the BMP, and over
65
+ * 200,000 randomised strings mixing control bytes, CJK, astral characters and
66
+ * lone surrogates. Surrogates are the case the spread existed to get right —
67
+ * `D800`–`DFFF` fall outside every range below, so neither spelling touches a
68
+ * pair or splits one — and newlines survive here as they always did, by not
69
+ * being in the class.
49
70
  */
50
71
  export function control(text) {
51
- return [...text]
52
- .map((ch) => {
53
- const c = ch.codePointAt(0);
54
- const isControl = (c < 0x20 && c !== NEWLINE) || (c >= 0x7f && c <= 0x9f);
55
- return isControl ? ' ' : ch;
56
- })
57
- .join('');
72
+ // eslint-disable-next-line no-control-regex -- matching control characters is the whole function.
73
+ return text.replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, ' ');
58
74
  }
59
75
  //# sourceMappingURL=terminal.js.map
@@ -18,8 +18,12 @@ export declare function uncoveredIssues(registry: Registry, plan: AttestPlan): I
18
18
  *
19
19
  * and one WARNING:
20
20
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
21
+ *
22
+ * `registryIncomplete` says that at least one `*.reqs.ts` failed to load, so
23
+ * `registry` is known to be missing whatever was in it. It changes no verdict —
24
+ * only what `orphan-test` advises, for the reason below.
21
25
  */
22
- export declare function validateStructure(registry: Registry, plan: AttestPlan): Issue[];
26
+ export declare function validateStructure(registry: Registry, plan: AttestPlan, registryIncomplete?: boolean): Issue[];
23
27
  /**
24
28
  * Weak anti-drift heuristic (design §6, mechanism 3). Evaluated per requirement,
25
29
  * not per scenario: a requirement that owns params is quiet as soon as *any* one
@@ -33,11 +33,32 @@ export function uncoveredIssues(registry, plan) {
33
33
  *
34
34
  * and one WARNING:
35
35
  * - rationale-placeholder: a `{name}` in a rationale, which is never interpolated
36
+ *
37
+ * `registryIncomplete` says that at least one `*.reqs.ts` failed to load, so
38
+ * `registry` is known to be missing whatever was in it. It changes no verdict —
39
+ * only what `orphan-test` advises, for the reason below.
36
40
  */
37
- export function validateStructure(registry, plan) {
41
+ export function validateStructure(registry, plan, registryIncomplete = false) {
38
42
  const issues = [];
39
43
  const knownIds = new Set(Object.keys(registry));
40
44
  // orphan-test: covers a requirement that does not exist.
45
+ //
46
+ // "Add it to the registry, or fix the id" is the right advice for an id that
47
+ // is genuinely absent, and **wrong** for one already sitting in a registry
48
+ // file that failed to load: the id is there, the fix is the load error, and
49
+ // following the hint would add a duplicate. That case is not rare when it
50
+ // happens — one unreadable `*.reqs.ts` orphans every scenario of every
51
+ // requirement it declared, so the wrong advice is also the loudest thing in
52
+ // the report.
53
+ //
54
+ // Which of the two this is cannot be decided here: the ids of a file that
55
+ // never parsed are exactly what is unavailable. So the finding stands and the
56
+ // *advice* names the uncertainty and the order to work in — a report that is
57
+ // quieter about problems it can still see would be the worse trade for a
58
+ // command whose contract is breadth.
59
+ const orphanFix = (id) => registryIncomplete
60
+ ? `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.`
61
+ : `Add it to the registry, or fix the id.`;
41
62
  for (const s of plan.scenarios) {
42
63
  if (!knownIds.has(s.reqId)) {
43
64
  issues.push({
@@ -46,7 +67,7 @@ export function validateStructure(registry, plan) {
46
67
  reqId: s.reqId,
47
68
  file: s.file,
48
69
  line: s.line,
49
- message: `scenario "${s.name}" attests unknown requirement "${s.reqId}". Add it to the registry, or fix the id.`,
70
+ message: `scenario "${s.name}" attests unknown requirement "${s.reqId}". ${orphanFix(s.reqId)}`,
50
71
  });
51
72
  }
52
73
  }
package/dist/runtime.d.ts CHANGED
@@ -1,12 +1,32 @@
1
1
  /**
2
2
  * Group scenarios that verify one requirement. The describe block is named
3
3
  * `[id]` so runtime coverage can be reconstructed from the task tree (§5.4).
4
+ *
5
+ * The body is handed to `describe` untouched. It used to be wrapped in a
6
+ * set-and-restore of a module-level `currentReqId` that `scenario()` read; that
7
+ * variable is deleted rather than repaired — see `scenario`.
4
8
  */
5
9
  export declare function requirement(id: string, body: () => void): void;
6
10
  /**
7
11
  * Declare a single verifiable scenario. Must be nested inside requirement().
8
12
  * The assertion itself is delegated to Vitest `it` (design §3).
9
13
  *
14
+ * **The nesting check happens when the scenario runs, not when it is
15
+ * collected**, and that is what makes grouping work. Vitest defers a nested
16
+ * `describe`'s callback until after the parent's has returned — measured: at
17
+ * the end of the outer callback the inner one has not run — so a module-level
18
+ * `currentReqId` set and restored around `body()` was already back to `null` by
19
+ * the time a grouped `scenario()` ran. It threw, and a throw during collection
20
+ * is a *file* error: nothing in that file was collected, so every scenario in
21
+ * it came back `declared-not-run` — including correct ungrouped ones under
22
+ * other requirements — each blamed on "skipped, or excluded by an .only?".
23
+ * Meanwhile `check` and `cover` read the same file statically and reported full
24
+ * coverage, so the static half called a file green that could not execute at
25
+ * all.
26
+ *
27
+ * Checking here costs nothing and fails better: a scenario genuinely outside
28
+ * any `requirement()` now fails as one test instead of taking its file down.
29
+ *
10
30
  * `timeoutMs` is passed straight through to `it`. It exists because `verify`
11
31
  * runs the suite **isolated** (§5.4) — no `vitest.config.ts` unless
12
32
  * `--vitest-config` names one — so Vitest's 5-second default is what every
package/dist/runtime.js CHANGED
@@ -2,27 +2,53 @@
2
2
  // Pure delegation to Vitest; no in-process coverage singleton — coverage is
3
3
  // reconstructed from the Vitest task tree instead (design §5.4).
4
4
  import { describe, it } from 'vitest';
5
- let currentReqId = null;
5
+ import { requirementIdOf, requirementSuiteName } from './core/req-suite.js';
6
6
  /**
7
7
  * Group scenarios that verify one requirement. The describe block is named
8
8
  * `[id]` so runtime coverage can be reconstructed from the task tree (§5.4).
9
+ *
10
+ * The body is handed to `describe` untouched. It used to be wrapped in a
11
+ * set-and-restore of a module-level `currentReqId` that `scenario()` read; that
12
+ * variable is deleted rather than repaired — see `scenario`.
9
13
  */
10
14
  export function requirement(id, body) {
11
- describe(`[${id}]`, () => {
12
- const prev = currentReqId;
13
- currentReqId = id;
14
- try {
15
- body();
16
- }
17
- finally {
18
- currentReqId = prev;
19
- }
20
- });
15
+ describe(requirementSuiteName(id), body);
16
+ }
17
+ /**
18
+ * The requirement a scenario actually ended up inside, read from the task tree.
19
+ *
20
+ * The same source `runner.ts` attributes coverage from, which is the point: a
21
+ * module-level "current requirement" was a *second* answer to a question the
22
+ * suite name already answers, and the two disagreed exactly where it mattered.
23
+ */
24
+ function owningRequirement(task) {
25
+ for (let s = task?.suite; s; s = s.suite) {
26
+ const id = requirementIdOf(s.name ?? '');
27
+ if (id !== undefined)
28
+ return id;
29
+ }
30
+ return undefined;
21
31
  }
22
32
  /**
23
33
  * Declare a single verifiable scenario. Must be nested inside requirement().
24
34
  * The assertion itself is delegated to Vitest `it` (design §3).
25
35
  *
36
+ * **The nesting check happens when the scenario runs, not when it is
37
+ * collected**, and that is what makes grouping work. Vitest defers a nested
38
+ * `describe`'s callback until after the parent's has returned — measured: at
39
+ * the end of the outer callback the inner one has not run — so a module-level
40
+ * `currentReqId` set and restored around `body()` was already back to `null` by
41
+ * the time a grouped `scenario()` ran. It threw, and a throw during collection
42
+ * is a *file* error: nothing in that file was collected, so every scenario in
43
+ * it came back `declared-not-run` — including correct ungrouped ones under
44
+ * other requirements — each blamed on "skipped, or excluded by an .only?".
45
+ * Meanwhile `check` and `cover` read the same file statically and reported full
46
+ * coverage, so the static half called a file green that could not execute at
47
+ * all.
48
+ *
49
+ * Checking here costs nothing and fails better: a scenario genuinely outside
50
+ * any `requirement()` now fails as one test instead of taking its file down.
51
+ *
26
52
  * `timeoutMs` is passed straight through to `it`. It exists because `verify`
27
53
  * runs the suite **isolated** (§5.4) — no `vitest.config.ts` unless
28
54
  * `--vitest-config` names one — so Vitest's 5-second default is what every
@@ -32,9 +58,11 @@ export function requirement(id, body) {
32
58
  * for the whole suite, sets one number for what is a property of one scenario.
33
59
  */
34
60
  export function scenario(name, fn, timeoutMs) {
35
- if (!currentReqId) {
36
- throw new Error(`scenario("${name}") must be nested inside requirement(...)`);
37
- }
38
- it(name, fn, timeoutMs);
61
+ it(name, async (ctx) => {
62
+ if (owningRequirement(ctx.task) === undefined) {
63
+ throw new Error(`scenario("${name}") must be nested inside requirement(...)`);
64
+ }
65
+ return fn();
66
+ }, timeoutMs);
39
67
  }
40
68
  //# sourceMappingURL=runtime.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@am_shork/attest",
3
- "version": "0.5.0",
3
+ "version": "0.7.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": {