mandrel 2.24.0 → 2.26.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/agents/acceptance-critic.md +19 -21
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
- package/.agents/schemas/baselines/crap.schema.json +13 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +124 -15
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +121 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -7
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +124 -27
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +86 -13
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
- package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
- package/.agents/scripts/lib/workers/crap-worker.js +8 -13
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +137 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +36 -36
- package/.agents/scripts/single-story-close.js +34 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +308 -47
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +92 -97
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +95 -4
- package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +49 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +62 -75
- package/README.md +13 -6
- package/docs/CHANGELOG.md +93 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* outliers.js — bounded top-N outlier extraction per gate (Story #4902).
|
|
3
|
+
*
|
|
4
|
+
* `baselines/crap.json` alone is ~650KB of per-method rows. The engine must
|
|
5
|
+
* never embed a whole baseline in its envelope, so every kind is narrowed to
|
|
6
|
+
* at most `topN` rows here, before anything downstream sees them.
|
|
7
|
+
*
|
|
8
|
+
* Narrowing happens in two steps:
|
|
9
|
+
*
|
|
10
|
+
* 1. **Aggregate to the cluster grain.** Baseline rows are per-method
|
|
11
|
+
* (crap) or per-symbol (dead exports); hotspots are per-file. Each id
|
|
12
|
+
* keeps its single worst value, plus how many rows it contributed.
|
|
13
|
+
* 2. **Score, then cut.** `severityWeight` is the row's position in its
|
|
14
|
+
* own kind's distribution, from 0 (the best value present) to 1 (the
|
|
15
|
+
* worst). Scoring within the kind is what makes CRAP 29 and MI 74
|
|
16
|
+
* comparable at all — the two axes share no unit, and the whole point
|
|
17
|
+
* of a cluster is to add them up.
|
|
18
|
+
*
|
|
19
|
+
* @module lib/audit-baselines/outliers
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { KIND_SPECS } from './kinds.js';
|
|
23
|
+
|
|
24
|
+
/** Default bound on rows extracted per gate. */
|
|
25
|
+
export const DEFAULT_TOP_N = 20;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Fold `{ id, value }` rows to one entry per id, keeping the worst value.
|
|
29
|
+
*
|
|
30
|
+
* @param {Array<{ id: string, value: number }>} rows
|
|
31
|
+
* @param {'higher' | 'lower'} worse
|
|
32
|
+
* @returns {Array<{ id: string, value: number, rowCount: number }>}
|
|
33
|
+
*/
|
|
34
|
+
function aggregateById(rows, worse) {
|
|
35
|
+
const byId = new Map();
|
|
36
|
+
for (const { id, value } of rows) {
|
|
37
|
+
const prev = byId.get(id);
|
|
38
|
+
if (prev === undefined) {
|
|
39
|
+
byId.set(id, { id, value, rowCount: 1 });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
prev.rowCount += 1;
|
|
43
|
+
const isWorse =
|
|
44
|
+
worse === 'higher' ? value > prev.value : value < prev.value;
|
|
45
|
+
if (isWorse) prev.value = value;
|
|
46
|
+
}
|
|
47
|
+
return [...byId.values()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Position of `value` in `[min, max]` normalized so 1 is always the worst
|
|
52
|
+
* end. A degenerate distribution (every value identical) scores 1 for every
|
|
53
|
+
* row: they are all equally the worst, and equally the best.
|
|
54
|
+
*
|
|
55
|
+
* @param {number} value
|
|
56
|
+
* @param {number} min
|
|
57
|
+
* @param {number} max
|
|
58
|
+
* @param {'higher' | 'lower'} worse
|
|
59
|
+
* @returns {number} 0..1
|
|
60
|
+
*/
|
|
61
|
+
function normalizeSeverity(value, min, max, worse) {
|
|
62
|
+
if (!(max > min)) return 1;
|
|
63
|
+
const ratio = (value - min) / (max - min);
|
|
64
|
+
return worse === 'higher' ? ratio : 1 - ratio;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extract the bounded worst-N rows for one kind.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ kind: string, baseline: object | null, topN?: number }} args
|
|
71
|
+
* @returns {Array<{
|
|
72
|
+
* kind: string, id: string, metric: string, value: number,
|
|
73
|
+
* rowCount: number, severityWeight: number,
|
|
74
|
+
* }>} worst first
|
|
75
|
+
*/
|
|
76
|
+
export function extractOutliers({ kind, baseline, topN = DEFAULT_TOP_N }) {
|
|
77
|
+
const spec = KIND_SPECS[kind];
|
|
78
|
+
if (!spec || !baseline) return [];
|
|
79
|
+
const aggregated = aggregateById(spec.rows(baseline), spec.worse);
|
|
80
|
+
if (aggregated.length === 0) return [];
|
|
81
|
+
let min = Number.POSITIVE_INFINITY;
|
|
82
|
+
let max = Number.NEGATIVE_INFINITY;
|
|
83
|
+
for (const row of aggregated) {
|
|
84
|
+
if (row.value < min) min = row.value;
|
|
85
|
+
if (row.value > max) max = row.value;
|
|
86
|
+
}
|
|
87
|
+
return aggregated
|
|
88
|
+
.map((row) => ({
|
|
89
|
+
kind,
|
|
90
|
+
id: row.id,
|
|
91
|
+
metric: spec.metric,
|
|
92
|
+
value: row.value,
|
|
93
|
+
rowCount: row.rowCount,
|
|
94
|
+
severityWeight: normalizeSeverity(row.value, min, max, spec.worse),
|
|
95
|
+
}))
|
|
96
|
+
.sort(
|
|
97
|
+
(a, b) => b.severityWeight - a.severityWeight || a.id.localeCompare(b.id),
|
|
98
|
+
)
|
|
99
|
+
.slice(0, Math.max(0, topN));
|
|
100
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* read.js — tolerant, strictly read-only filesystem access for the baseline
|
|
3
|
+
* hotspot engine (Story #4902).
|
|
4
|
+
*
|
|
5
|
+
* The engine's job is to *report on* the baseline surface, including the
|
|
6
|
+
* parts of it that are missing or malformed. So no read here throws: a
|
|
7
|
+
* missing file, an unreadable directory, or a JSON parse error is evidence
|
|
8
|
+
* the envelope carries, not a crash. Nothing in this module opens a file for
|
|
9
|
+
* writing — the read-only invariant over `baselines/` starts here.
|
|
10
|
+
*
|
|
11
|
+
* @module lib/audit-baselines/read
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read and parse a JSON file without throwing.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} absolutePath
|
|
21
|
+
* @returns {{ exists: boolean, parsed: object | null, parseError: string | null }}
|
|
22
|
+
*/
|
|
23
|
+
export function readJsonFile(absolutePath) {
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = fs.readFileSync(absolutePath, 'utf8');
|
|
27
|
+
} catch {
|
|
28
|
+
return { exists: false, parsed: null, parseError: null };
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
return { exists: true, parsed: JSON.parse(raw), parseError: null };
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return {
|
|
34
|
+
exists: true,
|
|
35
|
+
parsed: null,
|
|
36
|
+
parseError: err?.message ?? String(err),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* List every file under `rootDir` as a posix repo-relative path, skipping
|
|
43
|
+
* `node_modules`, `.git`, and `.worktrees`. Returns `[]` for an absent or
|
|
44
|
+
* unreadable root rather than throwing.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} repoRoot
|
|
47
|
+
* @param {string} rootDir repo-relative directory to walk
|
|
48
|
+
* @returns {string[]} sorted repo-relative posix paths
|
|
49
|
+
*/
|
|
50
|
+
export function listFilesUnder(repoRoot, rootDir) {
|
|
51
|
+
const skip = new Set(['node_modules', '.git', '.worktrees']);
|
|
52
|
+
const out = [];
|
|
53
|
+
const walk = (abs) => {
|
|
54
|
+
let entries;
|
|
55
|
+
try {
|
|
56
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
if (skip.has(entry.name)) continue;
|
|
62
|
+
const child = path.join(abs, entry.name);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
walk(child);
|
|
65
|
+
} else if (entry.isFile()) {
|
|
66
|
+
out.push(path.relative(repoRoot, child).split(path.sep).join('/'));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
walk(path.resolve(repoRoot, rootDir));
|
|
71
|
+
return out.sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Age in whole days between `generatedAt` and `now`. Null when the stamp is
|
|
76
|
+
* absent or unparseable — the engine reports "unknown", never a fabricated 0.
|
|
77
|
+
*
|
|
78
|
+
* @param {unknown} generatedAt
|
|
79
|
+
* @param {Date} now
|
|
80
|
+
* @returns {number | null}
|
|
81
|
+
*/
|
|
82
|
+
export function ageInDays(generatedAt, now) {
|
|
83
|
+
if (typeof generatedAt !== 'string') return null;
|
|
84
|
+
const then = Date.parse(generatedAt);
|
|
85
|
+
if (Number.isNaN(then)) return null;
|
|
86
|
+
return Math.floor((now.getTime() - then) / 86_400_000);
|
|
87
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* staleness.js — the two clocks a baseline is stale against (Story #4962).
|
|
3
|
+
*
|
|
4
|
+
* Wall-clock age answers the wrong question on a busy repository.
|
|
5
|
+
* `coverage.json` and `maintainability.json` both read `staleDays: 0` while
|
|
6
|
+
* already predating merges that had rescored files they still carry rows for:
|
|
7
|
+
* refreshed an hour ago, and behind already. The second clock is the one that
|
|
8
|
+
* matters — how many commits have touched the **measured surface** since the
|
|
9
|
+
* baseline was last committed. A baseline older than the surface it scores is
|
|
10
|
+
* stale at zero wall-clock days.
|
|
11
|
+
*
|
|
12
|
+
* Read-only and degrading, like the rest of this engine: a shallow clone, an
|
|
13
|
+
* untracked baseline, or a surface that is not path-keyed all report `null`.
|
|
14
|
+
* Unknown is never rounded down to a reassuring 0.
|
|
15
|
+
*
|
|
16
|
+
* @module lib/audit-baselines/staleness
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import { KIND_SPECS } from './kinds.js';
|
|
21
|
+
import { ageInDays } from './read.js';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run a git command under `cwd`, returning trimmed stdout, or `null` on any
|
|
25
|
+
* failure or empty result.
|
|
26
|
+
*
|
|
27
|
+
* @param {string[]} args
|
|
28
|
+
* @param {{ cwd: string, run?: Function }} io
|
|
29
|
+
* @returns {string | null}
|
|
30
|
+
*/
|
|
31
|
+
function git(args, { cwd, run = execFileSync }) {
|
|
32
|
+
try {
|
|
33
|
+
const stdout = String(
|
|
34
|
+
run('git', args, {
|
|
35
|
+
cwd,
|
|
36
|
+
encoding: 'utf8',
|
|
37
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
38
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
39
|
+
}),
|
|
40
|
+
).trim();
|
|
41
|
+
return stdout.length > 0 ? stdout : null;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The repo-relative paths whose commits would invalidate this baseline: the
|
|
49
|
+
* gate's declared `targetDirs` when it has them, else the baseline's own row
|
|
50
|
+
* ids — which are file paths for every kind but `lighthouse` (routes) and
|
|
51
|
+
* `bundle-size` (bundle names). Those two get an empty surface rather than a
|
|
52
|
+
* route name handed to git as a pathspec.
|
|
53
|
+
*
|
|
54
|
+
* @param {{ kind: string, gateBlock: object | null, rows: Array<{id: string}> }} args
|
|
55
|
+
* @returns {string[]}
|
|
56
|
+
*/
|
|
57
|
+
function measuredSurfaceOf({ kind, gateBlock, rows }) {
|
|
58
|
+
const dirs = (gateBlock?.targetDirs ?? []).filter(
|
|
59
|
+
(dir) => typeof dir === 'string' && dir.length > 0,
|
|
60
|
+
);
|
|
61
|
+
if (dirs.length > 0) return dirs;
|
|
62
|
+
if (KIND_SPECS[kind]?.idKind !== 'path') return [];
|
|
63
|
+
return [...new Set(rows.map((row) => row.id))];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Commits touching `surfacePaths` since `relPath` was last committed. An
|
|
68
|
+
* empty surface yields `null` too: nothing was checked, so nothing can be
|
|
69
|
+
* claimed.
|
|
70
|
+
*
|
|
71
|
+
* @param {{ relPath: string, surfacePaths: string[], io: object }} args
|
|
72
|
+
* @returns {number | null}
|
|
73
|
+
*/
|
|
74
|
+
function commitsSinceBaseline({ relPath, surfacePaths, io }) {
|
|
75
|
+
if (surfacePaths.length === 0) return null;
|
|
76
|
+
const writtenAt = git(['log', '-n1', '--format=%H', '--', relPath], io);
|
|
77
|
+
if (!writtenAt) return null;
|
|
78
|
+
const counted = git(
|
|
79
|
+
['rev-list', '--count', `${writtenAt}..HEAD`, '--', ...surfacePaths],
|
|
80
|
+
io,
|
|
81
|
+
);
|
|
82
|
+
const commits = Number.parseInt(counted ?? '', 10);
|
|
83
|
+
return Number.isInteger(commits) ? commits : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The four staleness fields of one `gateSurface[]` entry, on both clocks.
|
|
88
|
+
*
|
|
89
|
+
* @param {{
|
|
90
|
+
* kind: string, gateBlock: object | null, rows: Array<{id: string}>,
|
|
91
|
+
* relPath: string, baseline: object | null, now: Date,
|
|
92
|
+
* io: { cwd: string, run?: Function },
|
|
93
|
+
* }} args
|
|
94
|
+
* @returns {{
|
|
95
|
+
* generatedAt: string | null, staleDays: number | null,
|
|
96
|
+
* staleCommits: number | null, surfaceStale: boolean | null,
|
|
97
|
+
* }}
|
|
98
|
+
*/
|
|
99
|
+
export function stalenessOf({
|
|
100
|
+
kind,
|
|
101
|
+
gateBlock,
|
|
102
|
+
rows,
|
|
103
|
+
relPath,
|
|
104
|
+
baseline,
|
|
105
|
+
now,
|
|
106
|
+
io,
|
|
107
|
+
}) {
|
|
108
|
+
const generatedAt =
|
|
109
|
+
typeof baseline?.generatedAt === 'string' ? baseline.generatedAt : null;
|
|
110
|
+
const staleCommits = baseline
|
|
111
|
+
? commitsSinceBaseline({
|
|
112
|
+
relPath,
|
|
113
|
+
surfacePaths: measuredSurfaceOf({ kind, gateBlock, rows }),
|
|
114
|
+
io,
|
|
115
|
+
})
|
|
116
|
+
: null;
|
|
117
|
+
return {
|
|
118
|
+
generatedAt,
|
|
119
|
+
staleDays: ageInDays(generatedAt, now),
|
|
120
|
+
staleCommits,
|
|
121
|
+
surfaceStale: staleCommits === null ? null : staleCommits > 0,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* surface-entry.js — the health report for one measuring instrument
|
|
3
|
+
* (Story #4902, split out in #4962).
|
|
4
|
+
*
|
|
5
|
+
* A baseline review that only reads the numbers cannot see the failure mode
|
|
6
|
+
* that matters most: an instrument that is not measuring anything. A gate can
|
|
7
|
+
* be unconfigured, its baseline file can be missing, it can be a **stub**
|
|
8
|
+
* (committed with no rows and an all-zero rollup, so it passes every run
|
|
9
|
+
* vacuously), it can be stale on either clock, and its `ignoreGlobs` can name
|
|
10
|
+
* paths that no longer exist — each of which reads as "green" from the gate's
|
|
11
|
+
* exit code. This module turns all of that into declarative fields for one
|
|
12
|
+
* kind; [`gate-surface.js`](gate-surface.js) walks the kinds and
|
|
13
|
+
* [`staleness.js`](staleness.js) owns the two clocks.
|
|
14
|
+
*
|
|
15
|
+
* @module lib/audit-baselines/surface-entry
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import picomatch from 'picomatch';
|
|
19
|
+
import { GATE_KINDS, KIND_SPECS, measuredTotalOf, rollupOf } from './kinds.js';
|
|
20
|
+
import { stalenessOf } from './staleness.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* True when every numeric leaf of the rollup is zero. A rollup with no
|
|
24
|
+
* numeric leaves at all is not all-zero — it carries no measurement to call
|
|
25
|
+
* zero, and treating it as such would flag shapes this engine cannot read.
|
|
26
|
+
*
|
|
27
|
+
* @param {object | null} rollup
|
|
28
|
+
* @returns {boolean}
|
|
29
|
+
*/
|
|
30
|
+
function isAllZeroRollup(rollup) {
|
|
31
|
+
if (!rollup || typeof rollup !== 'object') return false;
|
|
32
|
+
const numbers = Object.values(rollup).filter((v) => typeof v === 'number');
|
|
33
|
+
return numbers.length > 0 && numbers.every((v) => v === 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A **stub instrument**: zero rows AND an all-zero rollup. Both halves are
|
|
38
|
+
* required. Ratchet baselines carry no rollup, so a clean `arch-cycles`
|
|
39
|
+
* allowlist — genuinely zero cycles, the success state — is never mistaken
|
|
40
|
+
* for a dead instrument.
|
|
41
|
+
*
|
|
42
|
+
* @param {{ rowCount: number, rollup: object | null }} args
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
function isStubInstrument({ rowCount, rollup }) {
|
|
46
|
+
return rowCount === 0 && isAllZeroRollup(rollup);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Which of a gate's configured `ignoreGlobs` match zero files on disk.
|
|
51
|
+
*
|
|
52
|
+
* @param {string[]} ignoreGlobs
|
|
53
|
+
* @param {string[]} files repo-relative posix paths
|
|
54
|
+
* @returns {string[]}
|
|
55
|
+
*/
|
|
56
|
+
function findDeadIgnoreGlobs(ignoreGlobs, files) {
|
|
57
|
+
const dead = [];
|
|
58
|
+
for (const glob of ignoreGlobs ?? []) {
|
|
59
|
+
if (typeof glob !== 'string' || glob.length === 0) continue;
|
|
60
|
+
const isMatch = picomatch(glob, { dot: true });
|
|
61
|
+
if (!files.some((f) => isMatch(f))) dead.push(glob);
|
|
62
|
+
}
|
|
63
|
+
return dead;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Assemble one `gateSurface[]` entry from an already-read baseline.
|
|
68
|
+
*
|
|
69
|
+
* `rowCount` and `measured` both appear because they legitimately disagree:
|
|
70
|
+
* rows are counted after the kind's per-file aggregation, while `measured` is
|
|
71
|
+
* the quantity the instrument reports in its own unit — 589 dead-export
|
|
72
|
+
* symbols sit in 187 files.
|
|
73
|
+
*
|
|
74
|
+
* @param {{
|
|
75
|
+
* kind: string, quality: object, read: object, relPath: string,
|
|
76
|
+
* files: string[], now: Date, io: { cwd: string, run?: Function },
|
|
77
|
+
* }} args
|
|
78
|
+
* @returns {object}
|
|
79
|
+
*/
|
|
80
|
+
export function surfaceEntryFor({
|
|
81
|
+
kind,
|
|
82
|
+
quality,
|
|
83
|
+
read,
|
|
84
|
+
relPath,
|
|
85
|
+
files,
|
|
86
|
+
now,
|
|
87
|
+
io,
|
|
88
|
+
}) {
|
|
89
|
+
const gateBlock = quality?.gates?.[kind] ?? null;
|
|
90
|
+
const { exists, parsed: baseline, parseError } = read;
|
|
91
|
+
const rows = baseline ? KIND_SPECS[kind].rows(baseline) : [];
|
|
92
|
+
const rollup = rollupOf(baseline);
|
|
93
|
+
return {
|
|
94
|
+
kind,
|
|
95
|
+
surface: GATE_KINDS.includes(kind) ? 'gate' : 'ratchet',
|
|
96
|
+
baselinePath: relPath,
|
|
97
|
+
configured: gateBlock !== null && typeof gateBlock === 'object',
|
|
98
|
+
baselineExists: exists,
|
|
99
|
+
stub: isStubInstrument({ rowCount: rows.length, rollup }),
|
|
100
|
+
rowCount: rows.length,
|
|
101
|
+
measured: measuredTotalOf(kind, baseline),
|
|
102
|
+
...stalenessOf({ kind, gateBlock, rows, relPath, baseline, now, io }),
|
|
103
|
+
deadIgnoreGlobs: findDeadIgnoreGlobs(gateBlock?.ignoreGlobs, files),
|
|
104
|
+
parseError,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* trend.js — per-kind rollup deltas read out of each baseline file's own git
|
|
3
|
+
* history (Story #4902).
|
|
4
|
+
*
|
|
5
|
+
* A ratchet only tells you whether today is worse than yesterday. The
|
|
6
|
+
* question a baseline review asks is the other one: which direction has this
|
|
7
|
+
* number been moving, and is the ratchet actually ratcheting? That answer is
|
|
8
|
+
* already committed — every baseline refresh is a commit against the same
|
|
9
|
+
* path — so it is read with `git log` + `git show`, never recomputed by
|
|
10
|
+
* re-running an instrument.
|
|
11
|
+
*
|
|
12
|
+
* Degradation: any git failure (shallow clone, no history for the path, not
|
|
13
|
+
* a work tree) yields an empty `trend[]` and exit 0. A missing history is
|
|
14
|
+
* missing evidence, not an error.
|
|
15
|
+
*
|
|
16
|
+
* @module lib/audit-baselines/trend
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFileSync } from 'node:child_process';
|
|
20
|
+
import { trendRollupOf } from './kinds.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* List the most recent commits touching `relPath`, newest first.
|
|
24
|
+
*
|
|
25
|
+
* @param {{ cwd: string, relPath: string, limit: number, run?: Function }} args
|
|
26
|
+
* @returns {Array<{ sha: string, committedAt: string }>}
|
|
27
|
+
*/
|
|
28
|
+
function listCommits({ cwd, relPath, limit, run = execFileSync }) {
|
|
29
|
+
let stdout;
|
|
30
|
+
try {
|
|
31
|
+
stdout = run(
|
|
32
|
+
'git',
|
|
33
|
+
['log', `-n${limit}`, '--format=%H %cI', '--', relPath],
|
|
34
|
+
{ cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
return String(stdout)
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((line) => line.trim())
|
|
42
|
+
.filter((line) => line.includes(' '))
|
|
43
|
+
.map((line) => {
|
|
44
|
+
const [sha, committedAt] = line.split(' ');
|
|
45
|
+
return { sha, committedAt };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read the comparable rollup of a baseline as it existed at `sha`.
|
|
51
|
+
*
|
|
52
|
+
* @param {{ cwd: string, kind: string, sha: string, relPath: string, run?: Function }} args
|
|
53
|
+
* @returns {object | null}
|
|
54
|
+
*/
|
|
55
|
+
function rollupAt({ cwd, kind, sha, relPath, run = execFileSync }) {
|
|
56
|
+
let stdout;
|
|
57
|
+
try {
|
|
58
|
+
stdout = run('git', ['show', `${sha}:${relPath}`], {
|
|
59
|
+
cwd,
|
|
60
|
+
encoding: 'utf8',
|
|
61
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
62
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
63
|
+
});
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return trendRollupOf(kind, JSON.parse(stdout));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Numeric axis-by-axis difference `to - from`. Axes missing from either side
|
|
76
|
+
* are omitted rather than defaulted — a rollup that gained an axis has no
|
|
77
|
+
* delta on it, and inventing one would read as a regression.
|
|
78
|
+
*
|
|
79
|
+
* @param {object} from
|
|
80
|
+
* @param {object} to
|
|
81
|
+
* @returns {Record<string, number>}
|
|
82
|
+
*/
|
|
83
|
+
function rollupDelta(from, to) {
|
|
84
|
+
const deltas = {};
|
|
85
|
+
for (const [axis, current] of Object.entries(to ?? {})) {
|
|
86
|
+
const previous = from?.[axis];
|
|
87
|
+
if (typeof current === 'number' && typeof previous === 'number') {
|
|
88
|
+
deltas[axis] = current - previous;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return deltas;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build the `trend[]` section: newest-vs-previous rollup deltas per kind.
|
|
96
|
+
*
|
|
97
|
+
* @param {{
|
|
98
|
+
* cwd: string, kinds: string[], pathFor: (kind: string) => string,
|
|
99
|
+
* depth?: number, run?: Function,
|
|
100
|
+
* }} args
|
|
101
|
+
* @returns {Array<object>}
|
|
102
|
+
*/
|
|
103
|
+
export function buildTrend({ cwd, kinds, pathFor, depth = 5, run }) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const kind of kinds) {
|
|
106
|
+
const relPath = pathFor(kind);
|
|
107
|
+
const commits = listCommits({ cwd, relPath, limit: depth, run });
|
|
108
|
+
const samples = [];
|
|
109
|
+
for (const commit of commits) {
|
|
110
|
+
const rollup = rollupAt({ cwd, kind, sha: commit.sha, relPath, run });
|
|
111
|
+
if (rollup) samples.push({ ...commit, rollup });
|
|
112
|
+
}
|
|
113
|
+
if (samples.length < 2) continue;
|
|
114
|
+
const [current, previous] = samples;
|
|
115
|
+
out.push({
|
|
116
|
+
kind,
|
|
117
|
+
baselinePath: relPath,
|
|
118
|
+
sampleCount: samples.length,
|
|
119
|
+
from: { sha: previous.sha, committedAt: previous.committedAt },
|
|
120
|
+
to: { sha: current.sha, committedAt: current.committedAt },
|
|
121
|
+
deltas: rollupDelta(previous.rollup, current.rollup),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|