mandrel 2.34.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/quality-gates.md +30 -0
- package/.agents/docs/workflows.md +2 -1
- package/.agents/schemas/audit-rules.json +44 -0
- package/.agents/schemas/audit-rules.schema.json +1 -1
- package/.agents/scripts/lib/baselines/kinds/crap.js +7 -2
- package/.agents/scripts/lib/close-validation/projections/crap.js +8 -6
- package/.agents/scripts/lib/coverage-capture-fullscope.js +5 -2
- package/.agents/scripts/lib/coverage-capture.js +96 -26
- package/.agents/scripts/lib/findings/route-finding.js +98 -35
- package/.agents/scripts/lib/maintainability-utils.js +6 -14
- 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/docs/CHANGELOG.md +15 -0
- package/package.json +1 -1
|
@@ -424,6 +424,36 @@ refresh-guardrail accepts it on the next PR.
|
|
|
424
424
|
If your test runner doesn't produce per-method coverage, see "Disabling the
|
|
425
425
|
gate" below.
|
|
426
426
|
|
|
427
|
+
### Coverage freshness — what triggers a capture
|
|
428
|
+
|
|
429
|
+
The CRAP scorer treats "no coverage" as "skip the method", so a missing or
|
|
430
|
+
stale `coverage/coverage-final.json` silently weakens the gate.
|
|
431
|
+
`coverage-capture.js` closes that hole by capturing coverage in-band, and
|
|
432
|
+
decides whether it needs to by two rules (Story #5076):
|
|
433
|
+
|
|
434
|
+
- **The source set is derived, not configured.** Freshness is measured over
|
|
435
|
+
exactly the extensions the CRAP scanner walks — `.js`, `.mjs`, `.cjs`,
|
|
436
|
+
`.ts`, `.tsx`, `.mts`, `.cts` — defined once in
|
|
437
|
+
`.agents/scripts/lib/source-extensions.js`. There is deliberately no
|
|
438
|
+
`.agentrc.json` key for this: a consumer-settable list would be a second
|
|
439
|
+
way to mis-scope the same gate. Formats the engines cannot parse
|
|
440
|
+
(`.astro`, `.vue`, `.svelte`) are not part of it — a project written in
|
|
441
|
+
those still has its `.ts`/`.tsx` measured.
|
|
442
|
+
- **Both freshness paths fail closed on an empty source set.** Finding no
|
|
443
|
+
scorable source under `crap.targetDirs` means the check learned nothing,
|
|
444
|
+
so it captures rather than assuming coverage is current, and warns naming
|
|
445
|
+
the configured dirs. If you see that warning, `targetDirs` almost
|
|
446
|
+
certainly does not point at your sources — fix it rather than living with
|
|
447
|
+
a full capture on every run.
|
|
448
|
+
|
|
449
|
+
**Upgrading from a version before this fix:** a TypeScript project's sources
|
|
450
|
+
matched neither path, so the capture was skipped on every run and
|
|
451
|
+
`crap:check` compared the committed baseline against itself. The first run
|
|
452
|
+
after upgrading captures for real and measures your committed floors for the
|
|
453
|
+
first time, which may surface breaches that were always there. That is a
|
|
454
|
+
one-off re-baseline (`npm run crap:update`, committed with a
|
|
455
|
+
`baseline-refresh:` subject), not a regression.
|
|
456
|
+
|
|
427
457
|
### Disabling the gate (single-flag opt-out)
|
|
428
458
|
|
|
429
459
|
If your repo doesn't run coverage, set `enabled: false` in your
|
|
@@ -32,11 +32,12 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
|
|
|
32
32
|
fails when it drifts from the on-disk workflow set. To change a command’s
|
|
33
33
|
description, edit the workflow file’s front-matter and regenerate.
|
|
34
34
|
|
|
35
|
-
## Commands (
|
|
35
|
+
## Commands (28)
|
|
36
36
|
|
|
37
37
|
| Command | Description |
|
|
38
38
|
| --- | --- |
|
|
39
39
|
| `/audit-accessibility` | Audit WCAG accessibility conformance (static-first) with an optional runtime verification pass, and produce a structured findings report |
|
|
40
|
+
| `/audit-adrs` | Audit the decisions log as a live contract — Accepted ADRs whose claims the tree has outgrown, broken supersede chains, structural gaps, and directional changes that landed with no decision recorded. A deliberately-global lens over whichever decisions-log layout the consumer ships. |
|
|
40
41
|
| `/audit-architecture` | Audit architectural boundaries, module coupling, layering violations, and shipped-but-uncalled seams; emit a structured findings report keyed to the canonical severity scale. |
|
|
41
42
|
| `/audit-baselines` | Audit the committed baseline surface — dead instruments, stale baselines, cross-gate hotspot clusters, trend drift, and floor-tightening headroom — and emit findings whose remediation burns the measured debt down and tightens the ratchet behind it. |
|
|
42
43
|
| `/audit-clean-code` | Audit code smells, dead code, complexity hotspots, and maintainability-index outliers; emit a structured findings report. |
|
|
@@ -312,6 +312,23 @@
|
|
|
312
312
|
"target": "web",
|
|
313
313
|
"scope": "global",
|
|
314
314
|
"substitutionKeys": []
|
|
315
|
+
},
|
|
316
|
+
"audit-adrs": {
|
|
317
|
+
"triggers": {
|
|
318
|
+
"gates": ["gate1", "gate3"],
|
|
319
|
+
"keywords": [
|
|
320
|
+
"adr",
|
|
321
|
+
"adrs",
|
|
322
|
+
"decision record",
|
|
323
|
+
"architecture decision",
|
|
324
|
+
"decisions log",
|
|
325
|
+
"supersede",
|
|
326
|
+
"superseded"
|
|
327
|
+
],
|
|
328
|
+
"filePatterns": ["**/decisions.md", "**/decisions/**"]
|
|
329
|
+
},
|
|
330
|
+
"scope": "global",
|
|
331
|
+
"substitutionKeys": []
|
|
315
332
|
}
|
|
316
333
|
},
|
|
317
334
|
"sensitivePaths": {
|
|
@@ -363,6 +380,33 @@
|
|
|
363
380
|
"**/openapi/**",
|
|
364
381
|
"**/graphql/**"
|
|
365
382
|
]
|
|
383
|
+
},
|
|
384
|
+
"deploy-runtime": {
|
|
385
|
+
"description": "Deployment and runtime topology: the CI/CD pipeline, infrastructure-as-code, container images, and the server/serverless entrypoint with its platform and build config. A change here alters how the product is built, bound, and run in production without necessarily touching application logic. Build configs are enumerated by name rather than matched with a blanket `**/*.config.*` glob, which would swallow the test and lint configs and flip nearly every change set to high.",
|
|
386
|
+
"filePatterns": [
|
|
387
|
+
".github/workflows/**",
|
|
388
|
+
".github/actions/**",
|
|
389
|
+
"**/Dockerfile",
|
|
390
|
+
"**/docker-compose*.yml",
|
|
391
|
+
"**/docker-compose*.yaml",
|
|
392
|
+
"infra/**",
|
|
393
|
+
"**/*.tf",
|
|
394
|
+
"**/*.tfvars",
|
|
395
|
+
"**/wrangler.json",
|
|
396
|
+
"**/wrangler.jsonc",
|
|
397
|
+
"**/wrangler.toml",
|
|
398
|
+
"**/worker-entry.ts",
|
|
399
|
+
"**/worker-entry.js",
|
|
400
|
+
"**/astro.config.*",
|
|
401
|
+
"**/next.config.*",
|
|
402
|
+
"**/nuxt.config.*",
|
|
403
|
+
"**/svelte.config.*",
|
|
404
|
+
"**/fly.toml",
|
|
405
|
+
"**/vercel.json",
|
|
406
|
+
"**/netlify.toml",
|
|
407
|
+
"**/serverless.yml",
|
|
408
|
+
"**/serverless.yaml"
|
|
409
|
+
]
|
|
366
410
|
}
|
|
367
411
|
}
|
|
368
412
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"sensitivePaths": {
|
|
24
24
|
"type": "object",
|
|
25
|
-
"description": "Map of sensitive-path class name to the globs that identify it. A change set touching any registered pattern derives a `high` change level, which resolves review depth to `deep` and the acceptance critic to `fresh` regardless of how narrow the diff is (deriveChangeLevel in lib/orchestration/review-depth.js). This is configuration, not code: an operator extends the classes or their globs here without editing a module, and the globs run through the same picomatch machinery as the audit-lens `filePatterns` triggers above. An absent or empty block means no path is sensitive and depth falls back to diff width alone.",
|
|
25
|
+
"description": "Map of sensitive-path class name to the globs that identify it. A change set touching any registered pattern derives a `high` change level, which resolves review depth to `deep` and the acceptance critic to `fresh` regardless of how narrow the diff is (deriveChangeLevel in lib/orchestration/review-depth.js). This is configuration, not code: an operator extends the classes or their globs here without editing a module, and the globs run through the same picomatch machinery as the audit-lens `filePatterns` triggers above. Those lens triggers are a SEPARATE block with a separate job — they select which lens runs, never the change level — so a path registered there is not sensitive here unless it is also registered in this block. An absent or empty block means no path is sensitive and depth falls back to diff width alone.",
|
|
26
26
|
"patternProperties": {
|
|
27
27
|
"^[a-z][a-z0-9-]*$": { "$ref": "#/definitions/sensitivePathClass" }
|
|
28
28
|
},
|
|
@@ -673,8 +673,13 @@ export function assessComparisonBasis(compareResult, opts = {}) {
|
|
|
673
673
|
*
|
|
674
674
|
* Story #791 retired the transitional `bootstrap` exit-0 path: a missing
|
|
675
675
|
* baseline still fails closed. Story #829 (5.29.0) softened `kernelVersion`
|
|
676
|
-
*
|
|
677
|
-
*
|
|
676
|
+
* drift to **warn**, not fail, and did the same for `tsTranspilerVersion` —
|
|
677
|
+
* but that second half was re-escalated to **fatal** once Story #4866 made a
|
|
678
|
+
* TS row's `startLine` an original-source coordinate resolved through the
|
|
679
|
+
* transpiler's sourcemap. `startLine` is half the row identity key, so a
|
|
680
|
+
* transpiler change makes the rows incomparable rather than merely stale; see
|
|
681
|
+
* the `ts-transpiler-drift` axis below for the two exemptions that bound it.
|
|
682
|
+
* `escomplexVersion` mismatch has always failed closed.
|
|
678
683
|
*/
|
|
679
684
|
/**
|
|
680
685
|
* The one re-seed recipe every coordinate-invalidating axis ends on. Three
|
|
@@ -36,6 +36,7 @@ import { loadCoverage } from '../../coverage-utils.js';
|
|
|
36
36
|
import { scanAndScore } from '../../crap-utils.js';
|
|
37
37
|
import { cachedGitFetchSync } from '../../git/cached-fetch.js';
|
|
38
38
|
import { gitSpawn as defaultGitSpawn } from '../../git-utils.js';
|
|
39
|
+
import { SCORABLE_SOURCE_EXT_RE } from '../../source-extensions.js';
|
|
39
40
|
import { MISSING_ARG_REASONS, validateProjectionInputs } from './inputs.js';
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -48,9 +49,6 @@ export const DEFAULT_CRAP_TOLERANCE = 0.001;
|
|
|
48
49
|
/** Framework default for the new-method ceiling (`gates.crap.newMethodCeiling`). */
|
|
49
50
|
export const DEFAULT_NEW_METHOD_CEILING = 30;
|
|
50
51
|
|
|
51
|
-
/** Extensions the CRAP scanner can score. */
|
|
52
|
-
const SCORABLE = /\.(?:js|mjs|cjs|ts|tsx)$/;
|
|
53
|
-
|
|
54
52
|
/**
|
|
55
53
|
* Map the shared predicate's fine-grained `missing-*` reason onto the
|
|
56
54
|
* `missing-args` skipped-reason the sibling MI projection reports, so both
|
|
@@ -155,8 +153,9 @@ function refreshBaseRef(cwd, baseBranch, git) {
|
|
|
155
153
|
}
|
|
156
154
|
|
|
157
155
|
/**
|
|
158
|
-
* Enumerate the Story branch's changed files, narrowed
|
|
159
|
-
*
|
|
156
|
+
* Enumerate the Story branch's changed files, narrowed by the shared
|
|
157
|
+
* scorable-source extension set (`source-extensions.js`) so the projection
|
|
158
|
+
* selects exactly the files the CRAP scanner walks.
|
|
160
159
|
*
|
|
161
160
|
* @param {{ cwd: string, baseBranch: string, storyBranch: string, git: { gitSpawn: typeof defaultGitSpawn } }} opts
|
|
162
161
|
* @returns {{ ok: true, files: string[] } | { ok: false, detail: string }}
|
|
@@ -168,7 +167,10 @@ function diffScorableFiles({ cwd, baseBranch, storyBranch, git }) {
|
|
|
168
167
|
cwd,
|
|
169
168
|
gitSpawn: git.gitSpawn,
|
|
170
169
|
});
|
|
171
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
files: files.filter((f) => SCORABLE_SOURCE_EXT_RE.test(f)),
|
|
173
|
+
};
|
|
172
174
|
} catch (err) {
|
|
173
175
|
return { ok: false, detail: err.message };
|
|
174
176
|
}
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* logic; behaviour is byte-for-byte the pre-#4981 body.
|
|
9
9
|
*/
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
anyChangedUnderTargets,
|
|
13
|
+
describeFreshness,
|
|
14
|
+
} from './coverage-capture.js';
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* Run the `--skip-when-no-crap-files` check (when requested), the
|
|
@@ -72,7 +75,7 @@ export function runFullScopeCapture({
|
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
logger.info(
|
|
75
|
-
`[coverage-capture] Coverage at ${crap.coveragePath} is ${freshness.
|
|
78
|
+
`[coverage-capture] Coverage at ${crap.coveragePath} is ${describeFreshness(freshness, crap.targetDirs)}; running npm run test:coverage…`,
|
|
76
79
|
);
|
|
77
80
|
const code = runCaptureImpl({
|
|
78
81
|
cwd: args.cwd,
|
|
@@ -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
|
*
|
|
@@ -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
|
}
|
|
@@ -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.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: >-
|
|
3
|
+
Audit the decisions log as a live contract — Accepted ADRs whose claims the
|
|
4
|
+
tree has outgrown, broken supersede chains, structural gaps, and directional
|
|
5
|
+
changes that landed with no decision recorded. A deliberately-global lens over
|
|
6
|
+
whichever decisions-log layout the consumer ships.
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Decisions-Log (ADR) Audit
|
|
10
|
+
|
|
11
|
+
You are a Staff Engineer & Decision Historian verifying that the consumer's
|
|
12
|
+
**decisions log still describes the system that exists**. An ADR is not prose
|
|
13
|
+
that merely rots — it is retrieved as *authority*: agents and humans read an
|
|
14
|
+
`Accepted` decision as the settled answer and do not re-litigate it. So an
|
|
15
|
+
Accepted ADR the tree has outgrown is worse than no ADR at all — it actively
|
|
16
|
+
teaches a wrong contract, and it keeps teaching it until someone supersedes it.
|
|
17
|
+
That is this lens's central target; structural tidiness is the cheap part.
|
|
18
|
+
|
|
19
|
+
The shared lens machinery — read-only constraint, scope interpretation, report
|
|
20
|
+
envelope + finding-block skeleton, severity scale, self-cross-check, and
|
|
21
|
+
execution strategy — lives in
|
|
22
|
+
[`helpers/audit-lens-core.md`](helpers/audit-lens-core.md). Write the report to
|
|
23
|
+
`{{auditOutputDir}}/audit-adrs-results.md`. Dimension values:
|
|
24
|
+
`Decision Drift | Supersede-Chain Integrity | Structure & Status Hygiene |
|
|
25
|
+
Missing Decision | Layout Conformance`; the report adds a **Decision Coverage**
|
|
26
|
+
table.
|
|
27
|
+
|
|
28
|
+
## Applicability & layout detection
|
|
29
|
+
|
|
30
|
+
**Mandrel ships two first-class decisions-log layouts** (the
|
|
31
|
+
[`core/documentation-and-adrs`](../skills/core/documentation-and-adrs/SKILL.md)
|
|
32
|
+
Policy Capsule is the SSOT), and this lens reads whichever one the consumer
|
|
33
|
+
adopted. **Both layouts keep the same entry file** — `decisions.md` under the
|
|
34
|
+
configured docs root — so its mere presence never identifies the layout. Detect
|
|
35
|
+
in this order, resolving `<docsRoot>` from `project.paths.docsRoot` in
|
|
36
|
+
`.agentrc.json` (default `docs`):
|
|
37
|
+
|
|
38
|
+
1. **Neither `<docsRoot>/decisions.md` nor `<docsRoot>/decisions/` exists** →
|
|
39
|
+
the project keeps no decisions log. Emit the not-applicable report below and
|
|
40
|
+
stop. Never invent a log, and never infer decisions from commit history.
|
|
41
|
+
2. **`decisions.md` only** → **single-file dated-entry layout** (the default).
|
|
42
|
+
Every ADR body lives in that one file as an append-only entry.
|
|
43
|
+
3. **`decisions.md` + a `decisions/` directory** → read the entry file to tell
|
|
44
|
+
the two apart. When it is predominantly an **index** (one row or link per
|
|
45
|
+
ADR pointing into `decisions/`), this is the **index + `decisions/`
|
|
46
|
+
directory** (MADR-style) layout, and each `decisions/NNNN-*.md` file is an
|
|
47
|
+
ADR body. When it instead carries full ADR bodies *and* a `decisions/`
|
|
48
|
+
directory holds further ADRs, the log is a hybrid — conformant **only** when
|
|
49
|
+
every file under `decisions/` is reachable from the entry file by an
|
|
50
|
+
index row or an in-entry pointer; otherwise it is a Layout Conformance
|
|
51
|
+
finding (an unreferenced ADR body is invisible to every reader who starts,
|
|
52
|
+
as they must, at the entry file).
|
|
53
|
+
4. **`decisions/` only, with no entry file** → a Layout Conformance finding:
|
|
54
|
+
the entry file is the mandatory-read surface both layouts guarantee, and
|
|
55
|
+
without it the directory's ADRs are unreachable from the docs context.
|
|
56
|
+
|
|
57
|
+
**Override.** An operator may pass `--paths <file ...>` (audit specific ADR
|
|
58
|
+
files) or `--dir <path>` (treat that directory as the decisions root) to point
|
|
59
|
+
the lens at a non-conventional location. These flags are the **only** override:
|
|
60
|
+
there is deliberately no `.agentrc.json` key for the decisions-log location, so
|
|
61
|
+
detection stays derived from the skill's two layouts rather than from
|
|
62
|
+
configuration a consumer must maintain.
|
|
63
|
+
|
|
64
|
+
## Whole-log scope (global lens)
|
|
65
|
+
|
|
66
|
+
Unlike the change-set-scoped lenses, this lens **always evaluates the whole
|
|
67
|
+
decisions log**, even when the change that triggered it touched one file.
|
|
68
|
+
Decision integrity is a global property: a supersede chain spans entries the
|
|
69
|
+
change set never names, and — the load-bearing case — a code change *elsewhere*
|
|
70
|
+
is exactly what invalidates an Accepted ADR's claims. Narrowing to the change
|
|
71
|
+
set would blind the lens to its primary finding class.
|
|
72
|
+
|
|
73
|
+
Accordingly this lens declares `"scope": "global"` in
|
|
74
|
+
[`audit-rules.json`](../schemas/audit-rules.json) — the single source of truth
|
|
75
|
+
`resolveLensTier` in
|
|
76
|
+
[`lib/audit-suite/selector.js`](../scripts/lib/audit-suite/selector.js) reads —
|
|
77
|
+
and is **exempt from the cross-epic-leak guard** that narrows every other
|
|
78
|
+
lens's evidence to its `changedFiles`. The exemption is scoped to this lens
|
|
79
|
+
only; the guard is not weakened for any other lens.
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
{{changedFiles}}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- For this lens, **ignore** the `{{changedFiles}}` block above even when it is
|
|
86
|
+
populated: the decisions log is evaluated whole regardless. The block is
|
|
87
|
+
rendered only for envelope-shape parity with the scoped lenses. Do use it,
|
|
88
|
+
when populated, as a **prioritization hint** — an Accepted ADR whose subject
|
|
89
|
+
the change set touches is the first one to claim-check — never as a filter on
|
|
90
|
+
what is audited or reported.
|
|
91
|
+
|
|
92
|
+
## Execution strategy
|
|
93
|
+
|
|
94
|
+
Run this lens as a single `subagent_type: auditor` dispatch returning the report
|
|
95
|
+
path + Executive Summary; sequential inline execution is the fallback (see the
|
|
96
|
+
core's Execution strategy). On a large log, the Decision Drift claim-check
|
|
97
|
+
(Step 2.1) is the one dimension worth fanning out per batch of entries under
|
|
98
|
+
parallel-tooling Rule 3 — merge under the shared self-cross-check.
|
|
99
|
+
|
|
100
|
+
## Step 1: Deterministic structure sweep first
|
|
101
|
+
|
|
102
|
+
Run the cheap exact checks before reading any ADR for meaning — they
|
|
103
|
+
de-duplicate the easy findings and give Step 2 its inventory. Adjust the paths
|
|
104
|
+
below to the detected layout (or the `--dir` / `--paths` override):
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Entry file + any ADR bodies (single-file layout yields just the entry file).
|
|
108
|
+
ls docs/decisions.md docs/decisions/*.md 2>/dev/null
|
|
109
|
+
|
|
110
|
+
# Status lines and their spelling — the vocabulary is Accepted / Superseded
|
|
111
|
+
# by … / Deprecated / Reverted (…); anything else is a Structure & Status
|
|
112
|
+
# Hygiene finding.
|
|
113
|
+
grep -rn '^\*\*Status:\*\*\|^- \*\*Status:\*\*' docs/decisions.md docs/decisions/ 2>/dev/null
|
|
114
|
+
|
|
115
|
+
# Entry headings, for the id/date/uniqueness checks in Step 1's list below.
|
|
116
|
+
grep -n '^## ' docs/decisions.md 2>/dev/null
|
|
117
|
+
|
|
118
|
+
# Link integrity across the docs surface, including every ADR cross-reference.
|
|
119
|
+
node .agents/scripts/check-doc-links.js
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
From that output, resolve deterministically — each of these is a
|
|
123
|
+
**Structure & Status Hygiene** finding except where noted:
|
|
124
|
+
|
|
125
|
+
1. **Canonical sections.** Every ADR carries the canonical set the skill's
|
|
126
|
+
Policy Capsule names — **Status, Date, Deciders, Context, Decision,
|
|
127
|
+
(Alternatives Considered), Consequences**. `Alternatives Considered` is
|
|
128
|
+
optional; a missing `Status`, `Context`, `Decision`, or `Consequences` is a
|
|
129
|
+
finding, and a missing `Status` is the most severe of them because every
|
|
130
|
+
other dimension keys off it.
|
|
131
|
+
2. **Status vocabulary.** Each status reads `Accepted`, `Superseded by <ref>`,
|
|
132
|
+
`Deprecated`, or `Reverted (<date>)` — a reverted decision was **undone**
|
|
133
|
+
rather than replaced, so unlike a superseded one it has no successor to
|
|
134
|
+
point at, and its missing `by <ref>` is correct rather than a defect. A
|
|
135
|
+
free-invented status word — anything outside those four — leaves the entry
|
|
136
|
+
unclassifiable by this lens and by every reader.
|
|
137
|
+
3. **Unique, stable ids.** No two entries share an id/anchor; MADR files use
|
|
138
|
+
zero-padded sequential numbering matching their heading id.
|
|
139
|
+
4. **Parseable dates.** Every `Date` parses, and no entry is dated in the
|
|
140
|
+
future.
|
|
141
|
+
5. **Link integrity.** Report what `check-doc-links.js` finds inside the
|
|
142
|
+
decisions surface; leave findings outside it to `audit-documentation`.
|
|
143
|
+
|
|
144
|
+
This lens orchestrates the existing checker only — it adds no new deterministic
|
|
145
|
+
checker script, and the sweep above is inline shell by design.
|
|
146
|
+
|
|
147
|
+
## Step 2: Evaluation dimensions
|
|
148
|
+
|
|
149
|
+
### 2.1 Decision Drift — **`Accepted` entries only**
|
|
150
|
+
|
|
151
|
+
This is the lens's primary value. For each **Accepted** entry, extract its
|
|
152
|
+
load-bearing claims — the scripts, files, directories, commands, flags, config
|
|
153
|
+
keys, contracts, and mechanisms it names as decided — and verify each against
|
|
154
|
+
the current tree, exactly as a documentation claim-check would. Flag an
|
|
155
|
+
Accepted decision whose subject the code has moved past: a named mechanism that
|
|
156
|
+
no longer exists, a contract the implementation has replaced, a path or command
|
|
157
|
+
that resolves to nothing, a constraint the tree now routinely violates.
|
|
158
|
+
|
|
159
|
+
**Scope this claim-check to Accepted entries and no others.** A `Superseded`
|
|
160
|
+
or `Deprecated` entry is *supposed* to describe a world that no longer exists —
|
|
161
|
+
claim-checking it manufactures findings out of correctly-retired history, and
|
|
162
|
+
on a long log it is also where the cost would go. Superseded and Deprecated
|
|
163
|
+
entries get the chain checks in 2.2 and the structure checks in Step 1, and
|
|
164
|
+
nothing else.
|
|
165
|
+
|
|
166
|
+
**Remediation is always supersede-or-amend, never silent edit or deletion** —
|
|
167
|
+
the skill's lifecycle rule is that an ADR is superseded in place, never pruned
|
|
168
|
+
or archived. Say which in the finding: amend when the decision still holds and
|
|
169
|
+
only its details moved; supersede when the decision itself no longer describes
|
|
170
|
+
what the project does.
|
|
171
|
+
|
|
172
|
+
Severity guidance: **High** — an Accepted ADR whose central decision the code
|
|
173
|
+
contradicts (it will be retrieved and believed); **Medium** — an Accepted ADR
|
|
174
|
+
whose supporting details drifted while its decision still holds; **Low** —
|
|
175
|
+
cosmetic staleness (an old path in an aside, a renamed tool in an example).
|
|
176
|
+
|
|
177
|
+
### 2.2 Supersede-Chain Integrity
|
|
178
|
+
|
|
179
|
+
The chain is what keeps a retired decision honest, so audit it as a graph:
|
|
180
|
+
|
|
181
|
+
- **Every supersede reference resolves** to an ADR that exists (a heading
|
|
182
|
+
anchor in the single-file layout, a file in the directory layout).
|
|
183
|
+
- **No cycles**, and no entry superseding itself.
|
|
184
|
+
- **Index ↔ entry agreement** (directory layout, and any single-file log
|
|
185
|
+
carrying a summary table): the status in the index row matches the status in
|
|
186
|
+
the ADR body. A row saying `Accepted` over a body saying `Superseded` is a
|
|
187
|
+
finding — readers stop at the index.
|
|
188
|
+
- **Partial supersessions name what survives.** An entry recording that *some*
|
|
189
|
+
rows or clauses are superseded while the rest stand MUST say precisely which;
|
|
190
|
+
an unscoped "partly superseded" leaves every clause ambiguous.
|
|
191
|
+
- **No two Accepted entries contradict each other** on the same subject. When
|
|
192
|
+
a later decision silently overrode an earlier one, the earlier is the finding:
|
|
193
|
+
it was never marked superseded.
|
|
194
|
+
|
|
195
|
+
### 2.3 Structure & Status Hygiene
|
|
196
|
+
|
|
197
|
+
Promote the Step 1 sweep's resolved items to findings here. Keep them terse —
|
|
198
|
+
each names the entry and the missing or malformed element.
|
|
199
|
+
|
|
200
|
+
### 2.4 Missing Decision
|
|
201
|
+
|
|
202
|
+
The inverse gap: a directional change that landed with **no** decision
|
|
203
|
+
recorded. **Bound the search by date** — inspect history since the newest
|
|
204
|
+
entry's `Date`, not the whole history, or this dimension dominates the lens's
|
|
205
|
+
cost on any mature repository:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
git log --since=<newest-ADR-date> --pretty='%h %s' -- . | head -50
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Flag only **directional** changes — a mechanism retired, a contract cut over, a
|
|
212
|
+
dependency or platform swapped, an architectural seam moved. Routine features,
|
|
213
|
+
fixes, and refactors are not decisions and must not be reported here. When the
|
|
214
|
+
newest entry is recent and nothing directional landed since, record that as a
|
|
215
|
+
single `Info` observation rather than straining for a finding.
|
|
216
|
+
|
|
217
|
+
### 2.5 Layout Conformance
|
|
218
|
+
|
|
219
|
+
Report the detection outcomes named in **Applicability & layout detection**: an
|
|
220
|
+
unreferenced ADR body under `decisions/`, a `decisions/` directory with no
|
|
221
|
+
entry file, or an index whose rows and directory contents disagree about which
|
|
222
|
+
ADRs exist.
|
|
223
|
+
|
|
224
|
+
## Not-applicable report
|
|
225
|
+
|
|
226
|
+
When the project keeps **no decisions log** (neither the entry file nor the
|
|
227
|
+
directory exists, and no `--paths` / `--dir` override was supplied), emit this
|
|
228
|
+
explicit report instead of empty findings — and stop:
|
|
229
|
+
|
|
230
|
+
```text
|
|
231
|
+
# Decisions-Log (ADR) Audit Report
|
|
232
|
+
|
|
233
|
+
## Executive Summary
|
|
234
|
+
|
|
235
|
+
**Not applicable** — this project keeps no decisions log (no `decisions.md`
|
|
236
|
+
entry file and no `decisions/` directory under the configured docs root), so
|
|
237
|
+
the ADR lens has nothing to inspect and was skipped.
|
|
238
|
+
|
|
239
|
+
## Detailed Findings
|
|
240
|
+
|
|
241
|
+
_None — lens not applicable._
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
## Constraint (lens-specific carve-out)
|
|
245
|
+
|
|
246
|
+
Read-only over the decisions log and the tree it makes claims about. The single
|
|
247
|
+
write is the report artifact: never edit, supersede, renumber, reformat, or
|
|
248
|
+
delete an ADR — recording that a decision needs superseding is the deliverable,
|
|
249
|
+
and performing the supersession is a separate, human-owned pass. Run
|
|
250
|
+
`check-doc-links.js` in its default read-only mode only. Generic documentation
|
|
251
|
+
staleness outside the decisions surface belongs to
|
|
252
|
+
[`audit-documentation`](audit-documentation.md); architectural boundary
|
|
253
|
+
violations belong to `audit-architecture`. This lens judges whether a *recorded
|
|
254
|
+
decision* still matches the tree — never whether the decision was a good one.
|
|
255
|
+
|
|
256
|
+
## Report additions
|
|
257
|
+
|
|
258
|
+
Beyond the shared skeleton (Executive Summary + Detailed Findings from the
|
|
259
|
+
core), this lens's report carries its own title and a Decision Coverage table,
|
|
260
|
+
so a reader can see what was claim-checked versus what was only chain-checked:
|
|
261
|
+
|
|
262
|
+
```markdown
|
|
263
|
+
# Decisions-Log (ADR) Audit Report
|
|
264
|
+
|
|
265
|
+
## Decision Coverage
|
|
266
|
+
|
|
267
|
+
| ADR | Status | Checked |
|
|
268
|
+
| ----------- | ----------------------------------------------- | ----------------------------- |
|
|
269
|
+
| [id, title] | [Accepted · Superseded · Deprecated · Reverted] | [Claims + chain · Chain only] |
|
|
270
|
+
```
|
|
@@ -211,6 +211,23 @@ findings land as actionable, tracked work rather than a report nobody reads.
|
|
|
211
211
|
Run the deterministic checkers in `--check` mode only; the single write is the
|
|
212
212
|
report artifact. Do not edit any documentation or code.
|
|
213
213
|
|
|
214
|
+
### Boundary with the ADR lens
|
|
215
|
+
|
|
216
|
+
**Decision-log semantics belong to [`audit-adrs`](audit-adrs.md)**, not to this
|
|
217
|
+
lens: whether an `Accepted` ADR's claims still match the tree, whether its
|
|
218
|
+
supersede chain resolves, whether the index and the entry bodies agree on a
|
|
219
|
+
status, and whether a directional change landed with no decision recorded. That
|
|
220
|
+
lens reads the whole decisions log as a graph; this one would only ever see the
|
|
221
|
+
decisions file as one more prose doc.
|
|
222
|
+
|
|
223
|
+
This lens keeps its **generic** coverage of that file — link integrity, command
|
|
224
|
+
and path claims, and the History Bloat / Contradiction / Authority Drift
|
|
225
|
+
categories above — and both lenses may legitimately touch `decisions.md`. When
|
|
226
|
+
a finding turns on an ADR's **status, chain, or decided contract**, leave it to
|
|
227
|
+
`audit-adrs` rather than reporting it here, so the two lenses do not
|
|
228
|
+
double-report the same defect. The History Bloat remediation is unchanged and
|
|
229
|
+
still applies: never prune an ADR by archiving — supersede it in place.
|
|
230
|
+
|
|
214
231
|
## Report additions
|
|
215
232
|
|
|
216
233
|
Beyond the shared skeleton (Executive Summary + Detailed Findings from the
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,21 @@ All notable changes to this project will be documented in this file.
|
|
|
15
15
|
-->
|
|
16
16
|
<!-- markdownlint-disable-file MD004 MD012 MD037 -->
|
|
17
17
|
|
|
18
|
+
## [2.35.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.34.0...mandrel-v2.35.0) (2026-08-28)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
* add /audit-adrs — global decisions-log audit lens (decision drift, supersede-chain integrity, structure, missing decisions) ([#5072](https://github.com/dsj1984/mandrel/issues/5072)) ([#5073](https://github.com/dsj1984/mandrel/issues/5073)) ([5757e6a](https://github.com/dsj1984/mandrel/commit/5757e6ae3da471713dcdb4ac8b6d8093ea36d6f4))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
* **adrs:** add Reverted to the ADR status vocabulary and pin it to the skill (refs [#5078](https://github.com/dsj1984/mandrel/issues/5078)) ([#5083](https://github.com/dsj1984/mandrel/issues/5083)) ([d498575](https://github.com/dsj1984/mandrel/commit/d49857573725d8241d7083ad4579583b47af4935))
|
|
29
|
+
* **audit-rules:** register a deploy-runtime sensitive-path class (refs [#5069](https://github.com/dsj1984/mandrel/issues/5069)) ([#5070](https://github.com/dsj1984/mandrel/issues/5070)) ([da7f9f3](https://github.com/dsj1984/mandrel/commit/da7f9f3969aa14afa45a89d1ee4973248268a7cd))
|
|
30
|
+
* **findings:** union the fingerprint lookup with the semantic pass in routeFinding (refs [#5079](https://github.com/dsj1984/mandrel/issues/5079)) ([#5082](https://github.com/dsj1984/mandrel/issues/5082)) ([fd82de3](https://github.com/dsj1984/mandrel/commit/fd82de3496b4fe02cf29bead27957ce82ef7a0b2))
|
|
31
|
+
* fix coverage-capture freshness: derive the scorable-source extension set from the CRAP scanner and fail closed when discovery finds nothing ([#5076](https://github.com/dsj1984/mandrel/issues/5076)) ([#5081](https://github.com/dsj1984/mandrel/issues/5081)) ([09f89c0](https://github.com/dsj1984/mandrel/commit/09f89c0bd39adde9936ebd6c47fb4c2a5c3ee4cd))
|
|
32
|
+
|
|
18
33
|
## [2.34.0](https://github.com/dsj1984/mandrel/compare/mandrel-v2.33.0...mandrel-v2.34.0) (2026-08-27)
|
|
19
34
|
|
|
20
35
|
|
package/package.json
CHANGED