mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user