mandrel 2.33.0 → 2.35.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.
Files changed (29) hide show
  1. package/.agents/docs/configuration.md +2 -2
  2. package/.agents/docs/quality-gates.md +30 -0
  3. package/.agents/docs/workflows.md +2 -1
  4. package/.agents/schemas/agentrc.schema.json +2 -2
  5. package/.agents/schemas/audit-rules.json +44 -0
  6. package/.agents/schemas/audit-rules.schema.json +1 -1
  7. package/.agents/scripts/coverage-capture.js +7 -1
  8. package/.agents/scripts/lib/baselines/kernel.js +20 -7
  9. package/.agents/scripts/lib/baselines/kinds/crap.js +7 -2
  10. package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
  11. package/.agents/scripts/lib/close-validation/projections/crap.js +8 -6
  12. package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
  13. package/.agents/scripts/lib/config/quality.js +7 -0
  14. package/.agents/scripts/lib/coverage-capture-fullscope.js +5 -2
  15. package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
  16. package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
  17. package/.agents/scripts/lib/coverage-capture.js +106 -41
  18. package/.agents/scripts/lib/findings/route-finding.js +98 -35
  19. package/.agents/scripts/lib/maintainability-utils.js +6 -14
  20. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +24 -7
  21. package/.agents/scripts/lib/source-extensions.js +76 -0
  22. package/.agents/skills/core/documentation-and-adrs/SKILL.md +1 -1
  23. package/.agents/workflows/audit-adrs.md +270 -0
  24. package/.agents/workflows/audit-documentation.md +17 -0
  25. package/.agents/workflows/helpers/deliver-light.md +5 -2
  26. package/.agents/workflows/helpers/deliver-story-reference.md +23 -1
  27. package/.agents/workflows/helpers/deliver-story.md +4 -2
  28. package/docs/CHANGELOG.md +25 -0
  29. package/package.json +1 -1
@@ -14,12 +14,20 @@ import { spawnSync } from 'node:child_process';
14
14
  import crypto from 'node:crypto';
15
15
  import fs from 'node:fs';
16
16
  import path from 'node:path';
17
+ import {
18
+ isScorableSourceFile,
19
+ SCORABLE_SOURCE_EXT_RE,
20
+ } from './source-extensions.js';
17
21
 
18
22
  /**
19
23
  * Walk a directory tree and return the newest mtime (ms since epoch) seen
20
- * across `.js` and `.mjs` files. Symlinks, missing dirs, and unreadable nodes
21
- * resolve to 0 so the caller treats "no sources" the same as "ancient sources"
22
- * both mean "any existing coverage is fresh enough".
24
+ * across the scorable source files (`source-extensions.js`) the same set
25
+ * the CRAP scanner walks, so freshness tracks exactly what the gate scores.
26
+ * Symlinks, missing dirs, and unreadable nodes resolve to 0.
27
+ *
28
+ * A 0 return means "discovery found nothing", which {@link isCoverageFresh}
29
+ * treats as an absence of evidence rather than as freshness — see its
30
+ * `no-sources` contract.
23
31
  *
24
32
  * Exported for unit testing.
25
33
  *
@@ -48,7 +56,7 @@ export function newestSourceMtime(cwd, targetDirs, io = {}) {
48
56
  continue;
49
57
  }
50
58
  if (!entry.isFile()) continue;
51
- if (!entry.name.endsWith('.js') && !entry.name.endsWith('.mjs')) continue;
59
+ if (!isScorableSourceFile(entry.name)) continue;
52
60
  try {
53
61
  const m = statSync(childAbs).mtimeMs;
54
62
  if (m > newest) newest = m;
@@ -83,17 +91,55 @@ export function captureStampPath(cwd, coveragePath) {
83
91
  );
84
92
  }
85
93
 
86
- const SOURCE_EXT_RE = /\.(?:js|mjs)$/;
94
+ /**
95
+ * Fold every dirty scorable working-tree file under the scanned dirs into
96
+ * `hash`, and report how many there were.
97
+ *
98
+ * Dirty files are not represented by their index blob SHA, so their on-disk
99
+ * bytes (or absence) go in explicitly. Split out of
100
+ * {@link computeContentDigest} to keep that function's complexity at its
101
+ * committed CRAP floor.
102
+ *
103
+ * @param {{
104
+ * hash: import('node:crypto').Hash,
105
+ * cwd: string,
106
+ * readFileSync: typeof fs.readFileSync,
107
+ * porcelain: string,
108
+ * }} opts `porcelain` is raw `git status --porcelain` output.
109
+ * @returns {number} Count of scorable dirty files folded in.
110
+ */
111
+ function foldDirtySources({ hash, cwd, readFileSync, porcelain }) {
112
+ let count = 0;
113
+ for (const line of porcelain.split('\n').filter((l) => l.length > 3)) {
114
+ let file = line.slice(3).trim();
115
+ if (file.includes(' -> ')) file = file.split(' -> ').pop();
116
+ file = file.replace(/^"|"$/g, '');
117
+ if (!SCORABLE_SOURCE_EXT_RE.test(file)) continue;
118
+ count += 1;
119
+ hash.update(`\0${file}\0`);
120
+ try {
121
+ hash.update(readFileSync(path.resolve(cwd, file)));
122
+ } catch {
123
+ hash.update('<absent>');
124
+ }
125
+ }
126
+ return count;
127
+ }
87
128
 
88
129
  /**
89
- * Compute a stable content digest of the `.js`/`.mjs` sources under
90
- * `targetDirs`: the `git ls-files -s` listing (mode + blob SHA + path) of
130
+ * Compute a stable content digest of the scorable sources
131
+ * (`source-extensions.js`) under `targetDirs`: the `git ls-files -s` listing (mode + blob SHA + path) of
91
132
  * tracked content, plus the on-disk bytes of any dirty working-tree files.
92
133
  * Checkout/branch churn leaves blob SHAs untouched, so the digest only moves
93
134
  * when content actually changes.
94
135
  *
95
136
  * Returns `null` when the digest cannot be computed (git unavailable, not a
96
137
  * repo, empty target list) so callers can fall back to the mtime heuristic.
138
+ * A target list that matches **no** scorable file is the same "unavailable"
139
+ * case, not a digest over zero files: this path is the primary freshness
140
+ * test, so returning a real hash of empty input would pin the artifact
141
+ * permanently fresh and make the mtime path's fail-closed verdict
142
+ * unreachable (Story #5076).
97
143
  *
98
144
  * @param {string} cwd Absolute repo root.
99
145
  * @param {string[]} targetDirs Repo-relative directories to digest.
@@ -120,26 +166,19 @@ export function computeContentDigest(cwd, targetDirs, io = {}) {
120
166
  const hash = crypto.createHash('sha256');
121
167
  const tracked = git('ls-files', '-s', '--', ...dirs)
122
168
  .split('\n')
123
- .filter((line) => SOURCE_EXT_RE.test(line.trimEnd()));
169
+ .filter((line) => SCORABLE_SOURCE_EXT_RE.test(line.trimEnd()));
124
170
  hash.update(tracked.join('\n'));
125
171
 
126
- // Dirty working-tree files are not represented by their index blob SHA,
127
- // so fold in their on-disk bytes (or absence) explicitly.
128
- const dirty = git('status', '--porcelain', '--', ...dirs)
129
- .split('\n')
130
- .filter((line) => line.length > 3);
131
- for (const line of dirty) {
132
- let file = line.slice(3).trim();
133
- if (file.includes(' -> ')) file = file.split(' -> ').pop();
134
- file = file.replace(/^"|"$/g, '');
135
- if (!SOURCE_EXT_RE.test(file)) continue;
136
- hash.update(`\0${file}\0`);
137
- try {
138
- hash.update(readFileSync(path.resolve(cwd, file)));
139
- } catch {
140
- hash.update('<absent>');
141
- }
142
- }
172
+ const scorableDirty = foldDirtySources({
173
+ hash,
174
+ cwd,
175
+ readFileSync,
176
+ porcelain: git('status', '--porcelain', '--', ...dirs),
177
+ });
178
+ // Discovery found nothing to digest: hashing the empty input would yield
179
+ // a constant that can never go stale, so report "unavailable" instead and
180
+ // let the caller's fail-closed mtime path decide.
181
+ if (tracked.length + scorableDirty === 0) return null;
143
182
  return hash.digest('hex');
144
183
  } catch {
145
184
  return null;
@@ -231,6 +270,13 @@ function readStampForScope(stamp, requireScope) {
231
270
  * under `targetDirs`. Missing files, missing target dirs, or any IO error
232
271
  * resolve to `false` so the caller captures rather than trusting stale data.
233
272
  *
273
+ * **Both paths fail closed on an empty source set (Story #5076).** Finding no
274
+ * scorable source under `targetDirs` means the check learned nothing, so it
275
+ * reports `{ fresh: false, reason: 'no-sources' }` and the caller captures.
276
+ * The alternative — treating "found nothing" as "nothing changed" — is how a
277
+ * `js|mjs`-only selector left the CRAP gate green while measuring nothing in
278
+ * every TypeScript consumer.
279
+ *
234
280
  * **Scope asymmetry (Story #4981, AC-4).** A stamp written by an incremental
235
281
  * capture (`scope: 'incremental'`) only covers the files the diff touched —
236
282
  * it must never satisfy a caller that requires the full-scope guarantee
@@ -299,12 +345,36 @@ export function isCoverageFresh({
299
345
  statSync,
300
346
  readdirSync,
301
347
  });
302
- if (newestSrc === 0) return { fresh: true, reason: 'no-sources' };
348
+ // Source discovery found nothing. That is an absence of evidence, never a
349
+ // freshness guarantee — trusting it silently disables the capture (and with
350
+ // it the CRAP gate) for any tree the walk cannot see (Story #5076).
351
+ if (newestSrc === 0) return { fresh: false, reason: 'no-sources' };
303
352
  return coverageMtime >= newestSrc
304
353
  ? { fresh: true, reason: 'fresh' }
305
354
  : { fresh: false, reason: 'stale' };
306
355
  }
307
356
 
357
+ /**
358
+ * Render a freshness verdict for the operator-facing capture log.
359
+ *
360
+ * Every reason but `no-sources` speaks for itself. That one does not: failing
361
+ * closed on an empty source walk is correct, but bare it reads as an
362
+ * unexplained full capture on every run, and the cause is far more often a
363
+ * `targetDirs` that does not name the project's sources than a genuine
364
+ * recapture — so the walked dirs and the key to fix are named inline
365
+ * (Story #5076).
366
+ *
367
+ * @param {{ reason?: string }} freshness Verdict from {@link isCoverageFresh}.
368
+ * @param {string[]} targetDirs The CRAP scan scope that was walked.
369
+ * @returns {string} The reason, annotated when it needs explaining.
370
+ */
371
+ export function describeFreshness(freshness, targetDirs) {
372
+ const reason = freshness?.reason;
373
+ if (reason !== 'no-sources') return String(reason);
374
+ const dirs = (targetDirs ?? []).join(', ');
375
+ return `${reason} — no scorable source file found under [${dirs}]; if that does not name this project's sources, fix quality.gates.crap.targetDirs`;
376
+ }
377
+
308
378
  /**
309
379
  * Narrow `changedFiles` to the subset that lives under one of `targetDirs`.
310
380
  *
@@ -363,20 +433,21 @@ export const COVERAGE_TIMEOUT_EXIT_CODE = 124;
363
433
  * `timeout(1)` convention exit code 124 so callers can pattern-match a
364
434
  * runaway runner without inspecting signal names.
365
435
  *
366
- * `files` (Story #4981) scopes the spawn to a file list — `npm run
367
- * test:coverage -- <files...>`, the standard npm convention for forwarding
368
- * argv to the underlying script (which most test runners, including Node's
369
- * own, treat as positional file filters). Omitted or empty means the
370
- * default full-scope invocation, byte-identical to the pre-#4981 argv
371
- * (AC-5); a non-empty list makes the scope observable on the emitted
372
- * command line (AC-1).
436
+ * The spawn takes **no positional file arguments**. Story #4981 forwarded the
437
+ * changed-file list as `npm run test:coverage -- <files...>` on the premise
438
+ * that a test runner treats trailing positionals as filters over the suite.
439
+ * Node's runner does not: it treats each path as a test file to execute, so a
440
+ * forwarded *source* file runs as a trivially-passing test and the real suite
441
+ * never runs. `run-coverage.js` discarded the list, which is the only reason
442
+ * that never bit; Story #5063 measured it and Story #5065 removed the
443
+ * plumbing rather than leave a parameter whose obvious "fix" empties the
444
+ * coverage artifact.
373
445
  *
374
446
  * @param {{
375
447
  * cwd: string,
376
448
  * timeoutMs?: number,
377
449
  * runner?: typeof spawnSync,
378
450
  * log?: (m: string) => void,
379
- * files?: string[] | null,
380
451
  * }} opts
381
452
  * @returns {number}
382
453
  */
@@ -385,14 +456,8 @@ export function runCapture({
385
456
  timeoutMs,
386
457
  runner = spawnSync,
387
458
  log = () => {},
388
- files = null,
389
459
  } = {}) {
390
- const scopedFiles = Array.isArray(files) && files.length > 0 ? files : null;
391
- const args = [
392
- 'run',
393
- 'test:coverage',
394
- ...(scopedFiles ? ['--', ...scopedFiles] : []),
395
- ];
460
+ const args = ['run', 'test:coverage'];
396
461
  log(`[coverage-capture] ▶ npm ${args.join(' ')}`);
397
462
  const spawnOpts = {
398
463
  cwd,
@@ -13,14 +13,15 @@
13
13
  * stamped into Issue bodies.
14
14
  * 3. `routeFinding(finding, { searchIssues, searchCandidates })` — classify a
15
15
  * finding against existing Issues into one of `new | update-existing |
16
- * duplicate | regression-of-closed`. Routing is a two-stage pass: a
17
- * meaning-first **semantic candidate** pass runs FIRST (when a
18
- * `searchCandidates` port is injected, e.g. wired to
19
- * `semantic-issue-search.js`), then the exact **fingerprint
20
- * confirmation** pass runs SECOND over that candidate pool. When no
21
- * semantic port is injected the helper falls back to a fingerprint-only
22
- * lookup via the `searchIssues` port. Either way the ports query BOTH
23
- * open and closed issues; a closed fingerprint match yields
16
+ * duplicate | regression-of-closed`. Routing gathers a candidate pool,
17
+ * then confirms identity against it. **Every wired port runs, and their
18
+ * results union** (Story #5079): the exact `searchIssues(sha)` lookup is
19
+ * what reliably retrieves an Issue by its footer sha, while the
20
+ * meaning-first `searchCandidates` pass (wired to
21
+ * `semantic-issue-search.js`) widens that pool to catch a reworded
22
+ * finding whose sha has drifted. The semantic pass **adds** to the
23
+ * fingerprint lookup; it never replaces it. Whichever ports are wired
24
+ * query BOTH open and closed issues; a closed fingerprint match yields
24
25
  * `regression-of-closed`.
25
26
  *
26
27
  * Pure orchestration: no network I/O lives here. The `searchIssues` /
@@ -455,36 +456,97 @@ function confirmCandidates(hits, { sha, semanticKey = '' }) {
455
456
  }
456
457
 
457
458
  /**
458
- * Route a finding against existing Issues with a two-stage pass.
459
- *
460
- * **Stage 1 — semantic candidate search (first).** When a `searchCandidates`
461
- * port is injected, it runs first to surface issues that *describe the same
462
- * problem by meaning* across BOTH open and closed issues (and, when the
463
- * caller wires it, an Epic's sub-issues). This widens the net beyond an exact
464
- * fingerprint so a reworded title or a moved file does not hide a real
465
- * duplicate. When no `searchCandidates` port is supplied the helper skips
466
- * straight to Stage 2 over the `searchIssues` lookup — the legacy
467
- * fingerprint-only behaviour, preserved verbatim.
468
- *
469
- * **Stage 2 — fingerprint confirmation (second).** Whatever candidates Stage 1
470
- * produced are filtered down to those that actually carry the finding's
471
- * fingerprint footer, then resolved:
459
+ * Union candidate pools into one flat pool, keeping first-seen order and
460
+ * dropping an issue number an earlier pool already contributed.
461
+ *
462
+ * The fingerprint pool is passed first, so when both ports return the same
463
+ * Issue it is that pool's record the one retrieved by exact identity — that
464
+ * survives into confirmation. Records without a usable number are left for
465
+ * {@link confirmCandidates} to reject, exactly as a single port's would be.
466
+ *
467
+ * @param {Array<unknown>} pools
468
+ * @returns {Array<object>}
469
+ */
470
+ function unionCandidatePools(pools) {
471
+ const seen = new Set();
472
+ return pools.flat().filter((issue) => {
473
+ const number = issue?.number;
474
+ if (typeof number !== 'number') return true;
475
+ const fresh = !seen.has(number);
476
+ seen.add(number);
477
+ return fresh;
478
+ });
479
+ }
480
+
481
+ /**
482
+ * Gather the candidate pool for a finding from every wired port.
483
+ *
484
+ * A run with a single wired port returns that port's result **verbatim**, so
485
+ * the fingerprint-only wiring (`qa-explore`, and every caller that injects no
486
+ * semantic port) keeps its behaviour exactly — including how a non-array
487
+ * return is handled downstream by {@link confirmCandidates}.
488
+ *
489
+ * Both ports are awaited together; a rejection from either propagates rather
490
+ * than degrading silently to a partial pool.
491
+ *
492
+ * @param {object} finding
493
+ * @param {string} sha — the finding's full fingerprint.
494
+ * @param {{ searchIssues?: Function, searchCandidates?: Function }} ports
495
+ * @returns {Promise<Array<object>|unknown>}
496
+ */
497
+ async function gatherCandidates(finding, sha, ports) {
498
+ const call = (port, arg) => (typeof port === 'function' ? [port(arg)] : []);
499
+ const pools = await Promise.all([
500
+ ...call(ports.searchIssues, sha),
501
+ ...call(ports.searchCandidates, finding),
502
+ ]);
503
+ return pools.length === 1 ? pools[0] : unionCandidatePools(pools);
504
+ }
505
+
506
+ /**
507
+ * Route a finding against existing Issues: gather candidates, then confirm.
508
+ *
509
+ * **Gather — every wired port runs, and their pools union (Story #5079).** The
510
+ * two ports answer different questions and neither subsumes the other:
511
+ *
512
+ * - `searchIssues(sha)` is the **exact** lookup. A fingerprint sha is one
513
+ * high-signal term, so it retrieves the Issue whose footer carries it.
514
+ * - `searchCandidates(finding)` is the **meaning-first** pass. It widens the
515
+ * pool to Issues describing the same problem under a different title, so a
516
+ * reworded finding or a moved file still confirms by semantic key.
517
+ *
518
+ * This was a ternary until Story #5079: an injected semantic port *replaced*
519
+ * the fingerprint lookup instead of widening it. Production always injects
520
+ * one, so `searchIssues` was dead code on the live path and dedup rested
521
+ * entirely on a ~20-token bag-of-words query that does not reliably retrieve
522
+ * the Issue. The audit loop consequently re-filed Stories it had already
523
+ * filed, against the workflow's "Never open a duplicate Issue" constraint.
524
+ * Running both ports and unioning their pools is what closes that loop.
525
+ *
526
+ * A port that rejects **propagates**. A pool gathered from only some of its
527
+ * sources is not a smaller pool, it is an unknown one, so the caller
528
+ * (`classifyGroupsAgainstGitHub`) must record a degraded lookup rather than
529
+ * report a confident `new`.
530
+ *
531
+ * **Confirm.** The pooled candidates are filtered down to those that actually
532
+ * carry the finding's fingerprint footer — or, when `semanticKeyConfirm` is
533
+ * on, its location-based semantic-key footer — then resolved:
472
534
  * - An open match → `update-existing` (or `duplicate` when more than one
473
535
  * open issue carries the fingerprint).
474
536
  * - A closed match (no open match) → `regression-of-closed`.
475
537
  * - No confirmed match → `new`.
476
538
  *
477
- * The decision enum is identical on both paths.
539
+ * The decision enum is identical however the candidates were gathered.
478
540
  *
479
541
  * @param {object} finding
480
542
  * @param {object} ports
481
543
  * @param {(sha: string) => Promise<Array<{ number: number, state: string, body?: string }>>} [ports.searchIssues]
482
- * Fingerprint-keyed lookup over open+closed issues. Required when
483
- * `searchCandidates` is not supplied.
544
+ * Fingerprint-keyed lookup over open+closed issues. Runs whenever it is
545
+ * supplied. Required when `searchCandidates` is not.
484
546
  * @param {(finding: object) => Promise<Array<{ number: number, state: string, title?: string, body?: string }>>} [ports.searchCandidates]
485
547
  * Meaning-first candidate search over open+closed issues (and Epic
486
- * sub-issues). When supplied, runs FIRST; its candidates are then
487
- * fingerprint-confirmed.
548
+ * sub-issues). Runs whenever it is supplied, alongside `searchIssues` rather
549
+ * than instead of it; the union is then confirmed by footer.
488
550
  * @param {object} [options]
489
551
  * @param {boolean} [options.semanticKeyConfirm=false] — also confirm a
490
552
  * candidate by the location-based semantic-key footer, not the fingerprint
@@ -510,15 +572,14 @@ export async function routeFinding(
510
572
  const { full: sha } = fingerprintFinding(finding);
511
573
  const semanticKey = options.semanticKeyConfirm ? semanticKeyFor(finding) : '';
512
574
 
513
- // Stage 1: semantic candidate pass first (when wired); else fingerprint
514
- // lookup. Both yield a candidate pool drawn from open AND closed issues.
515
- const hits =
516
- typeof searchCandidates === 'function'
517
- ? await searchCandidates(finding)
518
- : await searchIssues(sha);
575
+ // Gather: every wired port runs, and their pools union (Story #5079).
576
+ const hits = await gatherCandidates(finding, sha, {
577
+ searchIssues,
578
+ searchCandidates,
579
+ });
519
580
 
520
- // Stage 2: confirm identity by fingerprint footer (and, when opted in, the
521
- // location-based semantic-key footer) over the candidate pool.
581
+ // Confirm identity by fingerprint footer (and, when opted in, the
582
+ // location-based semantic-key footer) over the pooled candidates.
522
583
  const confirmed = confirmCandidates(hits, { sha, semanticKey });
523
584
 
524
585
  return decideFromConfirmed(confirmed, sha);
@@ -526,6 +587,8 @@ export async function routeFinding(
526
587
 
527
588
  export const __testing = {
528
589
  MARKER,
590
+ gatherCandidates,
591
+ unionCandidatePools,
529
592
  SEMANTIC_MARKER,
530
593
  SEP,
531
594
  confirmCandidates,
@@ -6,6 +6,7 @@ import { POOL_SERIAL_THRESHOLD, runOnPool } from './cpu-pool.js';
6
6
  import { Logger } from './Logger.js';
7
7
  import { scoreFile } from './maintainability-engine.js';
8
8
  import { isScored, reportUnscorable } from './maintainability-unscorable.js';
9
+ import { isScorableSourceFile } from './source-extensions.js';
9
10
 
10
11
  const MAINTAINABILITY_WORKER_URL = new URL(
11
12
  './workers/maintainability-worker.js',
@@ -16,17 +17,6 @@ const MAINTAINABILITY_WORKER_URL = new URL(
16
17
  // POOL_SERIAL_THRESHOLD docstring for the tuning rationale).
17
18
  const SERIAL_THRESHOLD = POOL_SERIAL_THRESHOLD;
18
19
 
19
- const JS_EXTS = new Set(['.js', '.mjs', '.cjs']);
20
- const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
21
- const SUPPORTED_EXTS = new Set([...JS_EXTS, ...TS_EXTS]);
22
-
23
- /**
24
- * @returns {boolean} True when the path's extension is one the engines score.
25
- */
26
- function isSupportedSourceFile(filePath) {
27
- return SUPPORTED_EXTS.has(path.extname(String(filePath)).toLowerCase());
28
- }
29
-
30
20
  const IGNORED_DIRS = new Set([
31
21
  'node_modules',
32
22
  '.git',
@@ -68,8 +58,10 @@ export function isIgnoredByGlobs(filePath, ignoreGlobs = [], cwd) {
68
58
  }
69
59
 
70
60
  /**
71
- * Recursively scans a directory for JS/TS source files. Accepts `.js`,
72
- * `.mjs`, `.cjs`, `.ts`, `.tsx`, `.mts`, and `.cts`. Directories listed
61
+ * Recursively scans a directory for JS/TS source files, selecting them by
62
+ * the shared `SCORABLE_SOURCE_EXTENSIONS` set (`source-extensions.js`) so the
63
+ * walk, the coverage-freshness check and the close-validation CRAP projection
64
+ * cannot drift apart. Directories listed
73
65
  * in `IGNORED_DIRS` (including `coverage` and `.next`, added in 5.29.0
74
66
  * to skip vitest's istanbul HTML scaffolding and Next.js build output)
75
67
  * are skipped.
@@ -101,7 +93,7 @@ export function scanDirectory(dir, fileList = [], opts = {}) {
101
93
  if (!IGNORED_DIRS.has(entry.name)) {
102
94
  scanDirectory(filePath, fileList, opts);
103
95
  }
104
- } else if (entry.isFile() && isSupportedSourceFile(entry.name)) {
96
+ } else if (entry.isFile() && isScorableSourceFile(entry.name)) {
105
97
  if (isIgnoredByGlobs(filePath, ignoreGlobs, matchCwd)) {
106
98
  continue;
107
99
  }
@@ -672,13 +672,30 @@ function renderStoryBodyForCreate(story, idBySlug) {
672
672
  const dependencyRefs = story.depends_on.map(
673
673
  (slug) => `#${idBySlug.get(slug)}`,
674
674
  );
675
- const base =
676
- dependencyRefs.length === 0
677
- ? story.body
678
- : serializeStoryBody(
679
- { ...story.bodyObject, depends_on: dependencyRefs },
680
- { includeFooter: true },
681
- );
675
+ let base = story.body;
676
+ if (dependencyRefs.length > 0) {
677
+ // Re-serializing from `bodyObject` is what resolves the sibling slugs to
678
+ // real issue ids — but `bodyObject` never held the provenance footers
679
+ // (`assembleOnePlanStory` appends those to the body *string*), so this
680
+ // branch drops them unless the carry is re-applied. That is the exact
681
+ // loss site Story #4935 diagnosed, #4939 fixed, and #4956 reverted
682
+ // wholesale hours later; Story #5056 restored it with a persist-side
683
+ // regression test that reads the POSTed body.
684
+ //
685
+ // `from: story.body` — not the seed — is load-bearing: it re-carries the
686
+ // identities *this* Story was stamped with under Story #5045 attribution
687
+ // rather than reintroducing the whole seed's union. `carryProvenanceFooters`
688
+ // is additive, union-preserving and idempotent, so re-applying is safe by
689
+ // construction.
690
+ const reserialized = serializeStoryBody(
691
+ { ...story.bodyObject, depends_on: dependencyRefs },
692
+ { includeFooter: true },
693
+ );
694
+ base = carryProvenanceFooters({
695
+ from: story.body,
696
+ into: reserialized,
697
+ }).body;
698
+ }
682
699
  return `${base}\n\n${planFingerprintMarker(story.fingerprint)}`;
683
700
  }
684
701
 
@@ -0,0 +1,76 @@
1
+ /**
2
+ * source-extensions.js — the single source of truth for which file extensions
3
+ * the CRAP and maintainability scanners score.
4
+ *
5
+ * Three surfaces select "the files the scanner scores" and must agree, because
6
+ * a selector narrower than the scanner's own walk makes a gate green while
7
+ * measuring nothing: the maintainability/CRAP directory walk
8
+ * (`maintainability-utils.js`), the coverage-freshness check
9
+ * (`coverage-capture.js`), and the close-validation CRAP projection's
10
+ * changed-file filter (`close-validation/projections/crap.js`). Story #5076
11
+ * folded all three onto the set below after a TypeScript consumer's entire
12
+ * source tree was filtered out of the freshness check by a `js|mjs`-only
13
+ * literal.
14
+ *
15
+ * **This module MUST import nothing outside `node:` builtins.**
16
+ * `coverage-capture.js` runs on the pre-push path; `maintainability-utils.js`
17
+ * transitively pulls `typhonjs-escomplex` and `typescript`, so the shared set
18
+ * cannot live there without dragging the scoring engines into every freshness
19
+ * probe.
20
+ *
21
+ * The set is deliberately *not* configurable. Freshness exists only to serve
22
+ * the CRAP gate, so the contract is "what the scanner walks" — a
23
+ * consumer-settable extension list would be a second way to mis-scope the same
24
+ * gate.
25
+ *
26
+ * Not to be confused with `transpile.js`'s `TS_EXTS`, which answers a
27
+ * different question ("does this file need transpiling before scoring?") and
28
+ * is correctly a subset of this set rather than a fork of it.
29
+ */
30
+ import path from 'node:path';
31
+
32
+ /**
33
+ * Extensions the CRAP and maintainability engines can score, lower-cased and
34
+ * dot-prefixed. Frozen so a caller cannot mutate the shared set in place.
35
+ *
36
+ * Formats the engines cannot parse — `.astro`, `.vue`, `.svelte` — are
37
+ * deliberately absent: this set selects what the scanner already scores, not
38
+ * every source file that exists in a consumer's tree.
39
+ *
40
+ * Module-private: production consumers select through {@link
41
+ * SCORABLE_SOURCE_EXT_RE} or {@link isScorableSourceFile}, so exporting the
42
+ * raw list would be a dead production export.
43
+ *
44
+ * @type {readonly string[]}
45
+ */
46
+ const SCORABLE_SOURCE_EXTENSIONS = Object.freeze([
47
+ '.js',
48
+ '.mjs',
49
+ '.cjs',
50
+ '.ts',
51
+ '.tsx',
52
+ '.mts',
53
+ '.cts',
54
+ ]);
55
+
56
+ /**
57
+ * Anchored pattern matching a scorable source path by extension, derived from
58
+ * {@link SCORABLE_SOURCE_EXTENSIONS} so the list stays the only definition.
59
+ * Use it where a path arrives as raw text (a `git ls-files` line, a porcelain
60
+ * status entry) and `path.extname` would be the wrong tool.
61
+ *
62
+ * @type {RegExp}
63
+ */
64
+ export const SCORABLE_SOURCE_EXT_RE = new RegExp(
65
+ `\\.(?:${SCORABLE_SOURCE_EXTENSIONS.map((ext) => ext.slice(1)).join('|')})$`,
66
+ );
67
+
68
+ /**
69
+ * @param {string} filePath Absolute or relative path; only its extension is read.
70
+ * @returns {boolean} True when the path's extension is one the engines score.
71
+ */
72
+ export function isScorableSourceFile(filePath) {
73
+ return SCORABLE_SOURCE_EXTENSIONS.includes(
74
+ path.extname(String(filePath)).toLowerCase(),
75
+ );
76
+ }
@@ -13,7 +13,7 @@ description:
13
13
  - Document the **why**, not the what. Capture context, constraints, alternatives considered, and trade-offs — code already shows what was built.
14
14
  - Write an ADR for any decision that would be expensive to reverse (framework choice, data model, auth strategy, API architecture, hosting platform).
15
15
  - Mandrel ships **two first-class decisions-log layouts** — pick one at onboarding (see [Decisions-log layouts](reference.md#decisions-log-layouts)): the **single-file dated-entry** `docs/decisions.md` (default; best for small projects) or the **index + `docs/decisions/` directory** (MADR-style, one file per ADR; best once the log outgrows a single file). Either way, the canonical ADR sections are **Status, Date, Deciders, Context, Decision, (Alternatives Considered), Consequences**.
16
- - Mark an ADR's status as `Accepted`, `Superseded by ADR-XXX`, or `Deprecated`. Never silently delete an ADR — supersede it.
16
+ - Mark an ADR's status as `Accepted`, `Superseded by ADR-XXX`, `Deprecated`, or `Reverted (<date>)` — a reverted decision was **undone** rather than replaced, so unlike a superseded one it has no successor to point at. Never silently delete an ADR — supersede it.
17
17
  - Do **not** document obvious code; do **not** restate what the code already says. Stale or redundant docs are worse than no docs.
18
18
  - Comments explain **non-obvious intent** (the why). If a comment describes what the code does, refactor the code instead.
19
19
  - Keep user-facing docs (README, API docs, changelog) updated as part of the change — out-of-date docs are bugs.