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.
- package/.agents/docs/configuration.md +2 -2
- package/.agents/docs/quality-gates.md +30 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/agentrc.schema.json +2 -2
- package/.agents/schemas/audit-rules.json +44 -0
- package/.agents/schemas/audit-rules.schema.json +1 -1
- package/.agents/scripts/coverage-capture.js +7 -1
- package/.agents/scripts/lib/baselines/kernel.js +20 -7
- package/.agents/scripts/lib/baselines/kinds/crap.js +7 -2
- package/.agents/scripts/lib/baselines/kinds/mutation.js +144 -14
- package/.agents/scripts/lib/close-validation/projections/crap.js +8 -6
- package/.agents/scripts/lib/config/gates/crap-incremental-coverage.schema.js +10 -7
- package/.agents/scripts/lib/config/quality.js +7 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +5 -2
- package/.agents/scripts/lib/coverage-capture-incremental.js +9 -2
- package/.agents/scripts/lib/coverage-capture-usage.js +55 -0
- package/.agents/scripts/lib/coverage-capture.js +106 -41
- package/.agents/scripts/lib/findings/route-finding.js +98 -35
- package/.agents/scripts/lib/maintainability-utils.js +6 -14
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +24 -7
- package/.agents/scripts/lib/source-extensions.js +76 -0
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +1 -1
- package/.agents/workflows/audit-adrs.md +270 -0
- package/.agents/workflows/audit-documentation.md +17 -0
- package/.agents/workflows/helpers/deliver-light.md +5 -2
- package/.agents/workflows/helpers/deliver-story-reference.md +23 -1
- package/.agents/workflows/helpers/deliver-story.md +4 -2
- package/docs/CHANGELOG.md +25 -0
- 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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
169
|
+
.filter((line) => SCORABLE_SOURCE_EXT_RE.test(line.trimEnd()));
|
|
124
170
|
hash.update(tracked.join('\n'));
|
|
125
171
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
367
|
-
* test:coverage -- <files
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
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
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* semantic
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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.
|
|
483
|
-
* `searchCandidates` is not
|
|
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).
|
|
487
|
-
*
|
|
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
|
-
//
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
//
|
|
521
|
-
// location-based semantic-key footer) over the
|
|
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
|
|
72
|
-
*
|
|
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() &&
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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 `
|
|
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.
|