@am_shork/attest 0.8.0 → 0.9.1

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/README.md CHANGED
@@ -125,7 +125,8 @@ attest verify # run tests + coverage + drift, graded report
125
125
  attest cover # which requirements lack a scenario
126
126
  attest render # the requirements as Markdown, for people who don't read TS
127
127
  attest archive <change> # gate a proposed change: green + covered + no drift
128
- attest status <change> # what that gate still wants, without running anything
128
+ attest status <change> # per added id: scenario written? seen red? (part of that
129
+ # gate, without running anything — never a verdict)
129
130
  ```
130
131
 
131
132
  Every command takes the project root as an optional last argument, and `--json`
@@ -166,7 +167,7 @@ Every diagnostic carries a `code`, and every code has a section in
166
167
  ```
167
168
  ERROR registry-not-static (requirements/upload.reqs.ts:5)
168
169
  Value is not a literal.
169
- → https://gitlab.com/Pseudorca/attest/-/blob/v0.8.0/docs/en/troubleshooting.md#registry-not-static
170
+ → https://gitlab.com/Pseudorca/attest/-/blob/v0.9.1/docs/en/troubleshooting.md#registry-not-static
170
171
  ```
171
172
 
172
173
  The anchor **is** the code, so the link cannot point somewhere the section
package/bin/attest.js CHANGED
@@ -1,3 +1,44 @@
1
1
  #!/usr/bin/env node
2
2
  // Thin launcher: delegates to the built CLI shell (src/cli -> dist/cli).
3
- import '../dist/cli/index.js';
3
+ //
4
+ // **The compile cache is enabled here because here is the only place it can
5
+ // be.** `typescript` is the fixed toll on every invocation: measured on this
6
+ // machine, the CLI's whole dependency graph costs ~790 ms to load and the
7
+ // compiler is ~672 ms of it, against ~28 ms for commander, ~17 ms for chalk and
8
+ // ~70 ms for zod. Nothing can move that by loading it later, either — every
9
+ // command except `init` reads a registry or a spec through the AST, so the
10
+ // import is not merely eager, it is *needed*. What a code cache changes is the
11
+ // price of the same load: V8 keeps its compilation output between runs, and the
12
+ // saving lands on the commands a pipeline actually repeats. Measured over two
13
+ // independent A/B rounds, nine interleaved runs each, median: **11% to 28% off
14
+ // every command** — `check self` 1153 -> 970 and 1106 -> 971 ms, `cover self`
15
+ // 1122 -> 951 and 1346 -> 1195 ms, `status` 1110 -> 923 ms, `init` 904 -> 711
16
+ // and 1365 -> 984 ms. Quoted as a range because the absolute figures move with
17
+ // whatever else the machine is doing; the direction did not, across either
18
+ // round or any command in them.
19
+ //
20
+ // **The import below has to stay dynamic, and this is the whole reason.** A
21
+ // static `import` is hoisted and its module graph is evaluated *before* any
22
+ // statement in this file runs — so with one, the call above happens after
23
+ // `typescript` has already been compiled and the cache does nothing at all. It
24
+ // still returns success, and every command still works, so there is nothing to
25
+ // notice: measured, the static spelling came back at 873 ms against an
26
+ // unpatched 897 ms, i.e. inside the noise, while the dynamic one is 669 ms.
27
+ // That is this repository's dominant failure shape — a defence that reads as
28
+ // present and is not — arriving in a three-line file.
29
+ //
30
+ // Optional-chained because `engines` allows Node 20 and this API arrived in
31
+ // 22.1: on an older runtime the speed-up is absent, which is the right
32
+ // behaviour for an optimisation and not a reason to refuse to start. It never
33
+ // throws either — a cache directory that cannot be written comes back as a
34
+ // status this deliberately ignores, since the only thing a failure costs is the
35
+ // saving above.
36
+ //
37
+ // The cache lands under the OS temp directory, never in the project: every
38
+ // other file Attest writes is one the user commits (see `core/write.ts`), and
39
+ // this is the one that must not be.
40
+ import module from 'node:module';
41
+
42
+ module.enableCompileCache?.();
43
+
44
+ await import('../dist/cli/index.js');
@@ -190,7 +190,15 @@ export function formatStatus(result) {
190
190
  }
191
191
  const { proven } = result.counts;
192
192
  lines.push(chalk.dim(`— ${plural(result.rows.length, 'added requirement')}, ${proven} ready to archive`));
193
- lines.push(chalk.dim(`Not a verdict: run \`attest archive ${inline(result.change)}\` to run the suite.`));
193
+ // Two pointers, because this command answers less than a reader assumes on
194
+ // both sides. `archive` is the verdict it deliberately cannot give. `check` is
195
+ // the half it deliberately does not duplicate: a proposed spec no delta
196
+ // claims, or one whose merged name is taken, is a static fact about this
197
+ // change that `check` already reports — and reporting it here too would put a
198
+ // second answer to one question in the tree, which is what this project takes
199
+ // apart everywhere else. Naming it is the whole cost of not absorbing it.
200
+ lines.push(chalk.dim(`Not a verdict: run \`attest archive ${inline(result.change)}\` to run the suite, ` +
201
+ `\`attest check\` for the proposed specs themselves.`));
194
202
  return lines.join('\n');
195
203
  }
196
204
  /**
@@ -242,10 +250,16 @@ export function formatCoverage(rows) {
242
250
  const detail = r.covered
243
251
  ? chalk.dim(`${r.scenarioCount} scenario${r.scenarioCount === 1 ? '' : 's'}`)
244
252
  : chalk.red('no scenario');
245
- // Sanitised, even though a registry id has been through the schema's
246
- // `^[A-Z]+-\d+$`: that is true of the *static* reader only. Under
247
- // `--eval` the registry is whatever the module exported, and `evalReader`
248
- // checks that it is an object and nothing further.
253
+ // Sanitised even though a registry id cannot carry a control character:
254
+ // `^[A-Z]+-\d+$` holds on both reader paths — the static one by
255
+ // construction, the evaluating one since ATX-38 so this can never fire.
256
+ // It stays because the grammar is held somewhere else, and a defence that
257
+ // lapses when the other one does is not a defence; `CoverageRow.reqId` is
258
+ // a `string`, and this function is exported. Same standard as
259
+ // `splice.ts`'s `keySource`, and the reason it is not an inconsistency
260
+ // with `render.ts` deciding the opposite: that one states it need *not*
261
+ // sanitise ids, which is true, and builds its container prototype-free
262
+ // anyway for exactly this reason.
249
263
  return ` ${mark} ${chalk.bold(inline(r.reqId))} ${detail}`;
250
264
  })
251
265
  .join('\n');
@@ -1,5 +1,5 @@
1
1
  import type { RegistryDelta } from './registry.js';
2
- import type { Issue, Registry } from './types.js';
2
+ import type { Issue, Registry, Requirement } from './types.js';
3
3
  export interface ApplyResult {
4
4
  registry: Registry;
5
5
  issues: Issue[];
@@ -12,6 +12,14 @@ export interface ApplyResult {
12
12
  * than off `delta.added`, so the two can never be scoped differently.
13
13
  */
14
14
  export declare function addedIds(d: RegistryDelta): string[];
15
+ /**
16
+ * The ids a delta MODIFIEs — the scope of `--apply`'s field-level write-back.
17
+ *
18
+ * Here for the reason `addedIds` is: two places ask, the merge that writes them
19
+ * and the refusal that checks their prefix is owned, and a delta's operations
20
+ * must not be enumerated twice.
21
+ */
22
+ export declare function modifiedIds(d: RegistryDelta): string[];
15
23
  /**
16
24
  * The ids a delta **claims**: what it adds, renames to, or modifies.
17
25
  *
@@ -33,4 +41,13 @@ export declare function claimedIds(d: RegistryDelta): string[];
33
41
  * base registry is never mutated. Applying the same delta twice is a no-op.
34
42
  */
35
43
  export declare function applyDelta(base: Registry, d: RegistryDelta): ApplyResult;
44
+ /**
45
+ * Content equality via canonical JSON (params key order does not matter).
46
+ *
47
+ * Exported because `--apply` asks the same question twice — whether a modified
48
+ * entry is already what the end state says, and whether the bytes it wrote read
49
+ * back as it — and a second spelling of "the same requirement" is a second
50
+ * answer the moment `Requirement` gains a field.
51
+ */
52
+ export declare function sameRequirement(a: Requirement, b: Requirement): boolean;
36
53
  //# sourceMappingURL=apply.d.ts.map
@@ -14,6 +14,16 @@ import { byCodeUnit, sortDeep } from './order.js';
14
14
  export function addedIds(d) {
15
15
  return Object.keys(d.added ?? {});
16
16
  }
17
+ /**
18
+ * The ids a delta MODIFIEs — the scope of `--apply`'s field-level write-back.
19
+ *
20
+ * Here for the reason `addedIds` is: two places ask, the merge that writes them
21
+ * and the refusal that checks their prefix is owned, and a delta's operations
22
+ * must not be enumerated twice.
23
+ */
24
+ export function modifiedIds(d) {
25
+ return Object.keys(d.modified ?? {}).sort(byCodeUnit);
26
+ }
17
27
  /**
18
28
  * The ids a delta **claims**: what it adds, renames to, or modifies.
19
29
  *
@@ -177,8 +187,15 @@ function firstMessage(error) {
177
187
  const path = first.path.map(String).join('.');
178
188
  return path ? `${path}: ${first.message}` : first.message;
179
189
  }
180
- /** Content equality via canonical JSON (params key order does not matter). */
181
- function sameRequirement(a, b) {
190
+ /**
191
+ * Content equality via canonical JSON (params key order does not matter).
192
+ *
193
+ * Exported because `--apply` asks the same question twice — whether a modified
194
+ * entry is already what the end state says, and whether the bytes it wrote read
195
+ * back as it — and a second spelling of "the same requirement" is a second
196
+ * answer the moment `Requirement` gains a field.
197
+ */
198
+ export function sameRequirement(a, b) {
182
199
  return canonical(a) === canonical(b);
183
200
  }
184
201
  function canonical(req) {
@@ -27,6 +27,20 @@ export declare const isSpecFile: (name: string) => boolean;
27
27
  * serial `for await` spent one filesystem round-trip per directory, which is
28
28
  * the dominant cost of `check` on a large repo. The result is sorted, so the
29
29
  * order never depends on which `readdir` happened to resolve first.
30
+ *
31
+ * Concurrency is bounded, for the reason `parseSpecs` bounds its own: the input
32
+ * size is not ours to choose, `check` being what this project tells people to
33
+ * run first on an untrusted fork MR. What must not come back is a fan-out whose
34
+ * peak is the shape of the tree rather than a constant — which is what
35
+ * recursing through `Promise.all(subdirs)` gave, and what
36
+ * `tests/locate-fanout.spec.ts` pins.
37
+ *
38
+ * A level at a time, rather than one pool over a queue that grows as directories
39
+ * are discovered: the pool would have to keep workers alive while the queue is
40
+ * momentarily empty but another worker may still push to it, and that
41
+ * termination condition is the part worth not owning. The cost is a barrier per
42
+ * depth, which is paid in tree *depth* — small, and bounded by the filesystem —
43
+ * while the fan-out being bounded is paid in tree *width*, which is not.
30
44
  */
31
45
  export declare function findFiles(root: string, match: (name: string) => boolean): Promise<string[]>;
32
46
  /** The file sets every command needs, collected in one pass. */
@@ -167,10 +181,11 @@ export declare function idPrefix(id: string): string;
167
181
  * avoid: input size is not ours to choose here, `check` being what this project
168
182
  * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
169
183
  *
170
- * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
171
- * failure it invites is descriptor exhaustion, which no measurement on either
172
- * development platform could produce (see CHANGELOG.md, `Under consideration`).
173
- * The memory here needed no such evidence it is arithmetic, and portable.
184
+ * `findFiles` above bounds its own fan-out the same way and through the same
185
+ * helper. It was left unbounded when this one was capped, on the grounds that
186
+ * the failure it invited — descriptor exhaustion — could not be produced on
187
+ * either development platform; what closed it is that the peak itself is
188
+ * portable arithmetic, which is the standard this half was accepted on.
174
189
  */
175
190
  export declare function parseSpecs(files: string[], displayRoot: string): Promise<{
176
191
  plan: AttestPlan;
@@ -40,6 +40,49 @@ export const isProposedSpecFile = (name) => name.endsWith('.proposed.spec.ts');
40
40
  * of them until its gate passes.
41
41
  */
42
42
  export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpecFile(name);
43
+ /**
44
+ * Run `fn` over every item with at most `limit` of them in flight.
45
+ *
46
+ * Extracted at the second call site rather than the first, because a shape
47
+ * written N times is one a later fix reaches only some copies of.
48
+ *
49
+ * **There are three, and the third is the one to look for.** `findFiles` and
50
+ * `parseSpecs` are named for what they fan out over; `loadRegistry` is named for
51
+ * merging, and its concurrency sits under a comment about *ordering* that
52
+ * answers a different question convincingly. Anything here that reads a list the
53
+ * project's tree decides the length of belongs in this pool, whatever the
54
+ * function around it is called.
55
+ *
56
+ * The order `fn` is *called* in is the input order; the order it *completes* in
57
+ * is not, so a caller that needs a stable result either indexes into a
58
+ * preallocated array by `index` or sorts afterwards. All three callers here do
59
+ * one of those, deliberately.
60
+ *
61
+ * No result is collected and none is needed — every caller writes into something
62
+ * it already owns, and a version returning `T[]` would have to choose an
63
+ * ordering on their behalf. A throw from `fn` propagates and abandons the rest,
64
+ * which is the existing behaviour at all three sites: `parseSpecs` and
65
+ * `loadRegistry` catch per file so that one hostile source scraps only itself
66
+ * (ATX-65), and a failed `readdir` really does end the walk.
67
+ */
68
+ async function forEachBounded(items, limit, fn) {
69
+ let cursor = 0;
70
+ const worker = async () => {
71
+ for (let i = cursor++; i < items.length; i = cursor++) {
72
+ await fn(items[i], i);
73
+ }
74
+ };
75
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
76
+ }
77
+ /**
78
+ * How many directories are open at once during the walk. The same figure as
79
+ * `PARSE_CONCURRENCY` and deliberately not the same constant: the two bound
80
+ * different resources, and sharing one would make either impossible to tune
81
+ * without moving the other. High enough that the walk stays I/O bound on any
82
+ * real project, low enough that what is in flight is a constant rather than the
83
+ * shape of the input.
84
+ */
85
+ const WALK_CONCURRENCY = 32;
43
86
  /**
44
87
  * Recursively find files under root whose basename matches `match`.
45
88
  *
@@ -47,25 +90,41 @@ export const isSpecFile = (name) => name.endsWith('.spec.ts') && !isProposedSpec
47
90
  * serial `for await` spent one filesystem round-trip per directory, which is
48
91
  * the dominant cost of `check` on a large repo. The result is sorted, so the
49
92
  * order never depends on which `readdir` happened to resolve first.
93
+ *
94
+ * Concurrency is bounded, for the reason `parseSpecs` bounds its own: the input
95
+ * size is not ours to choose, `check` being what this project tells people to
96
+ * run first on an untrusted fork MR. What must not come back is a fan-out whose
97
+ * peak is the shape of the tree rather than a constant — which is what
98
+ * recursing through `Promise.all(subdirs)` gave, and what
99
+ * `tests/locate-fanout.spec.ts` pins.
100
+ *
101
+ * A level at a time, rather than one pool over a queue that grows as directories
102
+ * are discovered: the pool would have to keep workers alive while the queue is
103
+ * momentarily empty but another worker may still push to it, and that
104
+ * termination condition is the part worth not owning. The cost is a barrier per
105
+ * depth, which is paid in tree *depth* — small, and bounded by the filesystem —
106
+ * while the fan-out being bounded is paid in tree *width*, which is not.
50
107
  */
51
108
  export async function findFiles(root, match) {
52
109
  const out = [];
53
- const walk = async (dir) => {
54
- const entries = await readdir(dir, { withFileTypes: true });
55
- const subdirs = [];
56
- for (const e of entries) {
57
- const full = join(dir, e.name);
58
- if (e.isDirectory()) {
59
- if (!SKIP_DIRS.has(e.name))
60
- subdirs.push(full);
110
+ let level = [root];
111
+ while (level.length > 0) {
112
+ const next = [];
113
+ await forEachBounded(level, WALK_CONCURRENCY, async (dir) => {
114
+ const entries = await readdir(dir, { withFileTypes: true });
115
+ for (const e of entries) {
116
+ const full = join(dir, e.name);
117
+ if (e.isDirectory()) {
118
+ if (!SKIP_DIRS.has(e.name))
119
+ next.push(full);
120
+ }
121
+ else if (e.isFile() && match(e.name)) {
122
+ out.push(full);
123
+ }
61
124
  }
62
- else if (e.isFile() && match(e.name)) {
63
- out.push(full);
64
- }
65
- }
66
- await Promise.all(subdirs.map(walk));
67
- };
68
- await walk(root);
125
+ });
126
+ level = next;
127
+ }
69
128
  return out.sort(byCodeUnit);
70
129
  }
71
130
  /**
@@ -245,6 +304,14 @@ async function declaredIds(absPath) {
245
304
  return [];
246
305
  }
247
306
  }
307
+ /**
308
+ * How many registry files are read at once. A third figure equal to the other
309
+ * two and, for the reason `WALK_CONCURRENCY` gives, a third constant: what this
310
+ * bounds is neither directories nor spec sources but whatever the *reader*
311
+ * holds — a source and a `SourceFile` under `staticReader`, a Vite SSR module
312
+ * evaluation under `evalReader` — and those are not tunable together.
313
+ */
314
+ const REGISTRY_CONCURRENCY = 32;
248
315
  /**
249
316
  * Read and merge every `*.reqs.ts` registry under root, with the given reader.
250
317
  * A registry the reader rejects becomes its own ERROR; a duplicate id across
@@ -263,7 +330,16 @@ export async function loadRegistry(root, reader, files) {
263
330
  const paths = files ?? (await scanProject(root)).reqsFiles;
264
331
  // Read the files concurrently, then fold the results in sorted file order:
265
332
  // the issue list stays deterministic regardless of which one finished first.
266
- const loaded = await Promise.all(paths.map(async (file) => ({ file, outcome: await readGuarded(reader, file) })));
333
+ //
334
+ // Indexed rather than appended, exactly as `parseSpecs` does it and for the
335
+ // same reason: `forEachBounded` *calls* in input order and *completes* in
336
+ // whatever order the reads finish, so the fold below is driven by position.
337
+ const outcomes = new Array(paths.length);
338
+ await forEachBounded(paths, REGISTRY_CONCURRENCY, async (file, i) => {
339
+ // `readGuarded` catches, so nothing here can throw and abandon the rest —
340
+ // which is the one thing the pool does not do for its caller.
341
+ outcomes[i] = await readGuarded(reader, file);
342
+ });
267
343
  const registry = {};
268
344
  const issues = [];
269
345
  const unreadable = [];
@@ -277,9 +353,10 @@ export async function loadRegistry(root, reader, files) {
277
353
  // have to turn a display path back into one; the messages below relativise at
278
354
  // the point they are built, which is where the reader's path belongs anyway.
279
355
  const prefixOwner = new Map();
280
- for (const entry of loaded) {
281
- const display = relativePath(root, entry.file);
282
- const { outcome } = entry;
356
+ for (let i = 0; i < paths.length; i += 1) {
357
+ const file = paths[i];
358
+ const outcome = outcomes[i];
359
+ const display = relativePath(root, file);
283
360
  if ('issue' in outcome) {
284
361
  issues.push({ ...outcome.issue, file: display });
285
362
  // Read the source again for the ids alone. Both readers land here, and the
@@ -288,7 +365,7 @@ export async function loadRegistry(root, reader, files) {
288
365
  // is already an ERROR is not a cost worth arranging around, and sharing
289
366
  // the reader's source instead would mean the two readers passing different
290
367
  // things to one diagnostic.
291
- unreadable.push({ file: display, ids: await declaredIds(entry.file) });
368
+ unreadable.push({ file: display, ids: await declaredIds(file) });
292
369
  continue;
293
370
  }
294
371
  // One issue per colliding prefix rather than per requirement: the fact is
@@ -299,9 +376,9 @@ export async function loadRegistry(root, reader, files) {
299
376
  const prefix = idPrefix(id);
300
377
  const owner = prefixOwner.get(prefix);
301
378
  if (owner === undefined) {
302
- prefixOwner.set(prefix, entry.file);
379
+ prefixOwner.set(prefix, file);
303
380
  }
304
- else if (owner !== entry.file && !reported.has(prefix)) {
381
+ else if (owner !== file && !reported.has(prefix)) {
305
382
  reported.add(prefix);
306
383
  // No `reqId`: this is about two files, not about any one of the
307
384
  // requirements that happen to reveal it.
@@ -362,10 +439,11 @@ const PARSE_CONCURRENCY = 32;
362
439
  * avoid: input size is not ours to choose here, `check` being what this project
363
440
  * tells people to run first on an untrusted fork MR. Measured in `[0.7.0]`.
364
441
  *
365
- * `findFiles` above is deliberately left unbounded: its fan-out is real, but the
366
- * failure it invites is descriptor exhaustion, which no measurement on either
367
- * development platform could produce (see CHANGELOG.md, `Under consideration`).
368
- * The memory here needed no such evidence it is arithmetic, and portable.
442
+ * `findFiles` above bounds its own fan-out the same way and through the same
443
+ * helper. It was left unbounded when this one was capped, on the grounds that
444
+ * the failure it invited — descriptor exhaustion — could not be produced on
445
+ * either development platform; what closed it is that the peak itself is
446
+ * portable arithmetic, which is the standard this half was accepted on.
369
447
  */
370
448
  export async function parseSpecs(files, displayRoot) {
371
449
  // Indexed rather than appended, so the merge below follows the input order
@@ -374,24 +452,19 @@ export async function parseSpecs(files, displayRoot) {
374
452
  // Same index space, so a file contributes either a parse or an issue and the
375
453
  // two lists cannot disagree about which file is which.
376
454
  const failures = new Array(files.length);
377
- let next = 0;
378
- const worker = async () => {
379
- for (let i = next++; i < files.length; i = next++) {
380
- const file = files[i];
381
- const display = relativePath(displayRoot, file);
382
- // Per file, for the reason `readGuarded` exists above: these run
383
- // concurrently, so one throw rejects the whole `Promise.all` and takes
384
- // the command with it. A hostile spec scraps only itself (ATX-65).
385
- try {
386
- parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
387
- }
388
- catch (err) {
389
- parsed[i] = { scenarios: [], paramRefs: [] };
390
- failures[i] = { ...unreadableIssue(err), file: display };
391
- }
455
+ await forEachBounded(files, PARSE_CONCURRENCY, async (file, i) => {
456
+ const display = relativePath(displayRoot, file);
457
+ // Per file, for the reason `readGuarded` exists above: these run
458
+ // concurrently, so one throw would abandon the rest and take the command
459
+ // with it. A hostile spec scraps only itself (ATX-65).
460
+ try {
461
+ parsed[i] = parseSpecFile(display, await readFile(file, 'utf8'));
392
462
  }
393
- };
394
- await Promise.all(Array.from({ length: Math.min(PARSE_CONCURRENCY, files.length) }, worker));
463
+ catch (err) {
464
+ parsed[i] = { scenarios: [], paramRefs: [] };
465
+ failures[i] = { ...unreadableIssue(err), file: display };
466
+ }
467
+ });
395
468
  const plan = { scenarios: [], paramRefs: [] };
396
469
  for (const one of parsed) {
397
470
  plan.scenarios.push(...one.scenarios);