fix: Populate mutation-health ledger churn/fix_density from setup (#32903)

Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
n8n-cat-bot[bot]
2026-06-24 08:14:28 +01:00
committed by GitHub
parent 1a7b48dcc4
commit 0ef6044fbb
3 changed files with 259 additions and 19 deletions
+27 -1
View File
@@ -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:
+74 -18
View File
@@ -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 <path>] # 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 <path>] # default: <pkg>/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,
@@ -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);
});
});