diff --git a/.github/workflows/mutation-health-nightly.yml b/.github/workflows/mutation-health-nightly.yml index afdc8033cda..06c34344b29 100644 --- a/.github/workflows/mutation-health-nightly.yml +++ b/.github/workflows/mutation-health-nightly.yml @@ -139,6 +139,19 @@ jobs: - name: Setup Environment uses: ./.github/actions/setup-nodejs + - name: Download picker artefacts (signals.json) + # The setup job computed churn/fix-density over full history and uploaded + # signals.json. This shallow checkout can't recompute them from git, so + # emit-payload reads them from here instead — keeping the ledger's churn + # and fix_density columns populated and consistent with what the picker + # ranked on. Best-effort: the on-demand source_file path skips the gather + # step, so signals.json may be absent; emit-payload falls back gracefully. + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + continue-on-error: true + with: + name: mutation-health-picker-${{ github.run_id }} + path: .mutation-health + - name: Mutate the picked source file # Exit code semantics from mutate.mjs: # 0 — gate passed: score >= threshold AND no unjustified survivors (green) @@ -162,7 +175,20 @@ jobs: echo "Mutate exit $rc ($([ "$rc" = "0" ] && echo green || echo red))." - name: Emit BQ payload - run: node scripts/mutation-health/emit-payload.mjs --summary "$REPORTS_DIR/summary.json" --package "$PACKAGE_NAME" + # Pass --signals only when the setup job produced it (skipped on the + # source_file re-score path). Without it emit-payload falls back to git, + # which yields null churn/fix_density on this shallow clone. + run: | + signals_arg="" + if [ -f .mutation-health/signals.json ]; then + signals_arg="--signals .mutation-health/signals.json" + else + echo "::notice::signals.json not present — churn/fix_density will fall back to git (null on shallow clone)." + fi + node scripts/mutation-health/emit-payload.mjs \ + --summary "$REPORTS_DIR/summary.json" \ + --package "$PACKAGE_NAME" \ + $signals_arg - name: POST result payload env: diff --git a/scripts/mutation-health/emit-payload.mjs b/scripts/mutation-health/emit-payload.mjs index c0227f6480a..15d6acda07d 100644 --- a/scripts/mutation-health/emit-payload.mjs +++ b/scripts/mutation-health/emit-payload.mjs @@ -30,9 +30,16 @@ * node scripts/mutation-health/emit-payload.mjs \ * --summary packages/workflow/reports/mutation/summary.json \ * --package n8n-workflow \ - * [--churn-window "90 days"] # git approxidate for the churn count - * [--fix-density-window "1 year"] # git approxidate bounding the fix-density log read - * [--fix-density-half-life 90] # half-life in days for the fix-density decay + * [--signals ] # signals.json from `signals.mjs gatherSignals` + * # (the setup job's full-history signals). When + * # present, churn/fix_density are read from it per + * # file instead of computed from git — the only + * # path that works under the mutate job's shallow + * # clone, and keeps stored values consistent with + * # what the picker ranked on. + * [--churn-window "90 days"] # git approxidate for the churn count (git fallback only) + * [--fix-density-window "1 year"] # git approxidate bounding the fix-density log read (git fallback only) + * [--fix-density-half-life 90] # half-life in days for the fix-density decay (git fallback only) * [--out ] # default: /reports/mutation/bq-payload.json */ @@ -187,6 +194,42 @@ export function makeFixDensityFor({ }; } +/** + * Build `{ churnFor, fixDensityFor }` that read from the setup job's + * `signals.json` (the full-history signals `signals.mjs gatherSignals` wrote and + * the picker ranked on) instead of from git. + * + * This is the path the nightly `mutate` job uses: its checkout is shallow, so + * the git-derived factories above would emit `null` for every file. Reusing the + * already-computed signals keeps the stored ledger columns consistent with the + * exact churn/fix-density the global picker scored — and matches how the picker + * reads them (`extractSignals` in `pick-next.mjs`): churn is the per-file commit + * count, fix-density the decayed score. + * + * A file absent from the signals map had no commits (churn) / no fix commits + * (fix-density) in the gather window, so both lookups return 0 — known-zero, not + * the `null` (unknown) the shallow-clone git path emits. The whole map is keyed + * by repo-relative path, the same key `buildPayload` joins on. + */ +export function makeSignalLookups(signals) { + const churn = signals?.churn ?? {}; + const fixDensity = signals?.fixDensity ?? {}; + return { + churnFor: (sourceRel) => { + const entry = churn[sourceRel]; + if (typeof entry === 'number') return Number.isFinite(entry) ? entry : 0; + if (typeof entry?.commits === 'number' && Number.isFinite(entry.commits)) { + return entry.commits; + } + return 0; + }, + fixDensityFor: (sourceRel) => { + const v = fixDensity[sourceRel]; + return typeof v === 'number' && Number.isFinite(v) ? +v.toFixed(4) : 0; + }, + }; +} + /** * Build the `{ ledger, events }` payload from a parsed summary. Pure given its * signal dependencies: takes the summary plus run metadata, returns the rows @@ -276,22 +319,35 @@ async function main() { ); const pkgRelToRepo = path.relative(repoRoot, pkgRoot); - const churnFor = makeChurnFor({ - cwd: repoRoot, - since: typeof args['churn-window'] === 'string' ? args['churn-window'] : DEFAULT_CHURN_WINDOW, - }); + // Prefer the setup job's full-history signals.json when provided: the nightly + // mutate job's checkout is shallow, so the git-derived factories below would + // emit null for every file. Falls back to git for local/standalone runs that + // have full history and no signals file. + let churnFor; + let fixDensityFor; + const signalsPath = typeof args.signals === 'string' ? args.signals : undefined; + if (signalsPath) { + if (!existsSync(signalsPath)) die(2, `Signals file not found: ${signalsPath}`); + const signals = JSON.parse(await readFile(signalsPath, 'utf8')); + ({ churnFor, fixDensityFor } = makeSignalLookups(signals)); + } else { + churnFor = makeChurnFor({ + cwd: repoRoot, + since: typeof args['churn-window'] === 'string' ? args['churn-window'] : DEFAULT_CHURN_WINDOW, + }); - const halfLifeArg = Number(args['fix-density-half-life']); - const fixDensityFor = makeFixDensityFor({ - cwd: repoRoot, - since: - typeof args['fix-density-window'] === 'string' - ? args['fix-density-window'] - : DEFAULT_FIX_DENSITY_WINDOW, - halfLifeDays: - Number.isFinite(halfLifeArg) && halfLifeArg > 0 ? halfLifeArg : DEFAULT_HALF_LIFE_DAYS, - pathspec: pkgRelToRepo, - }); + const halfLifeArg = Number(args['fix-density-half-life']); + fixDensityFor = makeFixDensityFor({ + cwd: repoRoot, + since: + typeof args['fix-density-window'] === 'string' + ? args['fix-density-window'] + : DEFAULT_FIX_DENSITY_WINDOW, + halfLifeDays: + Number.isFinite(halfLifeArg) && halfLifeArg > 0 ? halfLifeArg : DEFAULT_HALF_LIFE_DAYS, + pathspec: pkgRelToRepo, + }); + } const { ledger, events } = buildPayload(summary, { pkg, diff --git a/scripts/mutation-health/emit-payload.test.mjs b/scripts/mutation-health/emit-payload.test.mjs new file mode 100644 index 00000000000..bfa16807b19 --- /dev/null +++ b/scripts/mutation-health/emit-payload.test.mjs @@ -0,0 +1,158 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildPayload, + coverageForLedger, + makeChurnFor, + makeFixDensityFor, + makeSignalLookups, +} from './emit-payload.mjs'; + +const summaryFixture = () => ({ + threshold: 60, + generatedAt: '2026-06-24T03:30:00.000Z', + files: [ + { + file: 'src/hot.ts', + score: 42, + thresholdMet: false, + coverage: 0.5, + counts: { killed: 3, survived: 2, noCoverage: 1, timeout: 0, runtimeError: 0 }, + }, + { + file: 'src/cold.ts', + score: 90, + thresholdMet: true, + coverage: 0.95, + counts: { killed: 9, survived: 0, noCoverage: 0, timeout: 1, runtimeError: 0 }, + }, + ], +}); + +describe('coverageForLedger', () => { + it('prefers the written-back coverage, clamped to [0,1]', () => { + assert.equal(coverageForLedger({ coverage: 0.5 }), 0.5); + assert.equal(coverageForLedger({ coverage: 1.7 }), 1); + assert.equal(coverageForLedger({ coverage: -0.3 }), 0); + }); + + it('derives from mutant counts when coverage is absent', () => { + assert.equal( + coverageForLedger({ counts: { killed: 3, survived: 1, timeout: 0, noCoverage: 0 } }), + 1, + ); + assert.equal( + coverageForLedger({ counts: { killed: 1, survived: 0, timeout: 0, noCoverage: 1 } }), + 0.5, + ); + }); + + it('returns null when neither source is usable', () => { + assert.equal(coverageForLedger({}), null); + }); +}); + +describe('makeSignalLookups', () => { + const signals = { + churn: { + 'packages/workflow/src/hot.ts': { commits: 7, linesChanged: 120 }, + 'packages/workflow/src/bare.ts': 4, + }, + fixDensity: { + 'packages/workflow/src/hot.ts': 3.14159, + }, + }; + + it('reads the per-file commit count for churn', () => { + const { churnFor } = makeSignalLookups(signals); + assert.equal(churnFor('packages/workflow/src/hot.ts'), 7); + }); + + it('accepts a bare numeric churn entry', () => { + const { churnFor } = makeSignalLookups(signals); + assert.equal(churnFor('packages/workflow/src/bare.ts'), 4); + }); + + it('reads and rounds fix-density', () => { + const { fixDensityFor } = makeSignalLookups(signals); + assert.equal(fixDensityFor('packages/workflow/src/hot.ts'), 3.1416); + }); + + it('returns known-zero (not null) for a file absent from the signals map', () => { + const { churnFor, fixDensityFor } = makeSignalLookups(signals); + assert.equal(churnFor('packages/workflow/src/cold.ts'), 0); + assert.equal(fixDensityFor('packages/workflow/src/cold.ts'), 0); + }); + + it('tolerates an empty or partial signals object', () => { + const { churnFor, fixDensityFor } = makeSignalLookups({}); + assert.equal(churnFor('any.ts'), 0); + assert.equal(fixDensityFor('any.ts'), 0); + }); +}); + +describe('buildPayload with signal lookups', () => { + it('lands non-null churn/fix_density when signals carry the file (DEVP-552)', () => { + const signals = { + churn: { 'packages/workflow/src/hot.ts': { commits: 7, linesChanged: 120 } }, + fixDensity: { 'packages/workflow/src/hot.ts': 2.5 }, + }; + const { churnFor, fixDensityFor } = makeSignalLookups(signals); + + const { ledger, events } = buildPayload(summaryFixture(), { + pkg: 'n8n-workflow', + sha: 'deadbeef', + pkgRelToRepo: 'packages/workflow', + churnFor, + fixDensityFor, + }); + + const hot = ledger.find((r) => r.source_file_path === 'packages/workflow/src/hot.ts'); + assert.equal(hot.churn, 7); + assert.equal(hot.fix_density, 2.5); + assert.notEqual(hot.churn, null); + assert.notEqual(hot.fix_density, null); + + // A scored file with no signal entry is known-zero, never null. + const cold = ledger.find((r) => r.source_file_path === 'packages/workflow/src/cold.ts'); + assert.equal(cold.churn, 0); + assert.equal(cold.fix_density, 0); + + // Same values flow into the perf-metric event dimensions. + const hotEvent = events.find( + (e) => e.dimensions.source_file === 'packages/workflow/src/hot.ts', + ); + assert.equal(hotEvent.dimensions.churn, 7); + assert.equal(hotEvent.dimensions.fix_density, 2.5); + }); + + it('defaults to null churn/fix_density without signal lookups', () => { + const { ledger } = buildPayload(summaryFixture(), { + pkg: 'n8n-workflow', + sha: 'deadbeef', + pkgRelToRepo: 'packages/workflow', + }); + assert.equal(ledger[0].churn, null); + assert.equal(ledger[0].fix_density, null); + }); +}); + +describe('git fallback factories degrade to null on a shallow clone', () => { + // The shallow-clone signature emit-payload originally hit in the mutate job: + // both factories must emit null rather than a misleadingly low signal. + const shallowRunGit = (args) => { + if (args[0] === 'rev-parse' && args.includes('--is-shallow-repository')) return 'true\n'; + return ''; + }; + + it('makeChurnFor returns null for every file under a shallow clone', () => { + const churnFor = makeChurnFor({ runGit: shallowRunGit }); + assert.equal(churnFor('packages/workflow/src/hot.ts'), null); + }); + + it('makeFixDensityFor returns null for every file under a shallow clone', () => { + const fixDensityFor = makeFixDensityFor({ runGit: shallowRunGit }); + assert.equal(fixDensityFor('packages/workflow/src/hot.ts'), null); + }); +});