mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-19 07:36:52 +00:00
7e5c5c4eac
Co-authored-by: n8n-cat-bot[bot] <n8n-cat-bot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
301 lines
10 KiB
JavaScript
301 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Walk a package's source tree, merge with the live BQ ledger snapshot,
|
|
* return the next pair to mutate.
|
|
*
|
|
* Files present in src/ but absent from the live ledger are synthesised as
|
|
* status='new'. No separate seed step needed — the ledger fills in
|
|
* organically as files get scored.
|
|
*
|
|
* Stored statuses (from BQ): new | red | green
|
|
* Effective statuses (computed at pick time): new | red | stale | green
|
|
*
|
|
* Picker priority: new → red → stale → skip green
|
|
* Tiebreaks within each bucket:
|
|
* - new: alphabetical by source_file_path
|
|
* - red: lowest score first (focus on weakest tests)
|
|
* - stale: oldest last_checked_at first (natural cycling)
|
|
*
|
|
* "Stale" is an in-memory promotion of green rows older than
|
|
* STALE_AFTER_WEEKS (default 4). Not stored.
|
|
*
|
|
* Inputs:
|
|
* --package-dir <path> Required. Repo-relative path to the package, e.g. packages/workflow
|
|
* --ledger-file <path> Required. Live ledger JSON: { "ledger": [ ... ] }
|
|
* --mode <baseline|coverage> Optional. Restrict the picker to one bucket:
|
|
* baseline → only `new` (establish first scores)
|
|
* coverage → only `red`/`stale` (revisit weakest, lowest-first)
|
|
* omitted → combined new → red → stale (default)
|
|
* --stale-after-weeks <n> Optional. Default 4.
|
|
*
|
|
* Output (stdout): { picked: { source_file_path, package, prior_status, effective_status } }
|
|
* OR { picked: null, reason: "all-green" | "empty-source-tree"
|
|
* | "no-new-files" | "nothing-below-threshold" }.
|
|
*
|
|
* Exit codes:
|
|
* 0 — picked a row OR nothing to do (with picked: null sentinel)
|
|
* 2 — usage / config error
|
|
*/
|
|
|
|
import { readdir, readFile } from 'node:fs/promises';
|
|
import { existsSync, readFileSync } from 'node:fs';
|
|
import { execFileSync } from 'node:child_process';
|
|
import path from 'node:path';
|
|
|
|
function die(code, msg) {
|
|
process.stderr.write(`${msg}\n`);
|
|
process.exit(code);
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const out = {};
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (!a.startsWith('--')) continue;
|
|
const key = a.slice(2);
|
|
const next = argv[i + 1];
|
|
if (next === undefined || next.startsWith('--')) {
|
|
out[key] = true;
|
|
} else {
|
|
out[key] = next;
|
|
i++;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Files with no useful mutation surface: barrels, declarations, type-only modules.
|
|
// Matched against either the full basename (`types.ts`) or the trailing
|
|
// dot-segment (`foo.types.ts` → `types`), so dotted-suffix declaration files
|
|
// are caught the same as their plain-named counterparts. `methods` covers
|
|
// NativeDoc `*.methods.ts` descriptor files; `schemas` covers pure Zod
|
|
// declaration files; `message-event-bus` is an exact-basename entry for a
|
|
// bulk enum + interfaces + Zod module with no function logic.
|
|
const LOW_VALUE_BASENAMES = new Set([
|
|
'interfaces',
|
|
'index',
|
|
'constants',
|
|
'types',
|
|
'methods',
|
|
'schemas',
|
|
'message-event-bus',
|
|
]);
|
|
|
|
// Files with fewer than this many non-blank, non-import lines have so little
|
|
// surface area they're not worth mutating — e.g. trivial error subclasses with
|
|
// empty bodies or one hardcoded super() call. This catches what an exact-name
|
|
// list can't (`error` can't be skipped wholesale because files like
|
|
// `workflow-activation.error.ts` have real branching logic).
|
|
const MIN_MEANINGFUL_LINES = 15;
|
|
|
|
// Directories whose contents are tests/fixtures/mocks, not production code.
|
|
// Pruned in `walkSources` so we don't descend; `isMutationWorthy` re-checks as a
|
|
// safety net for packages that co-locate tests as siblings (e.g. `foo.test.ts`).
|
|
const NON_SOURCE_DIRS = new Set(['__tests__', '__mocks__', 'fixtures']);
|
|
|
|
function countMeaningfulLines(content) {
|
|
let count = 0;
|
|
for (const raw of content.split('\n')) {
|
|
const line = raw.trim();
|
|
if (!line) continue;
|
|
if (line.startsWith('import ')) continue;
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function isMutationWorthy(absPath) {
|
|
if (absPath.endsWith('.d.ts')) return false;
|
|
if (/\.(test|spec)\.ts$/.test(absPath)) return false;
|
|
if (absPath.includes(`${path.sep}__tests__${path.sep}`)) return false;
|
|
if (absPath.includes(`${path.sep}__mocks__${path.sep}`)) return false;
|
|
const base = path.basename(absPath, '.ts');
|
|
const lastSegment = base.split('.').at(-1);
|
|
if (LOW_VALUE_BASENAMES.has(base) || LOW_VALUE_BASENAMES.has(lastSegment)) return false;
|
|
if (countMeaningfulLines(readFileSync(absPath, 'utf8')) < MIN_MEANINGFUL_LINES) return false;
|
|
return true;
|
|
}
|
|
|
|
async function walkSources(dir) {
|
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
const out = [];
|
|
for (const e of entries) {
|
|
const full = path.join(dir, e.name);
|
|
if (e.isDirectory()) {
|
|
if (NON_SOURCE_DIRS.has(e.name)) continue;
|
|
out.push(...(await walkSources(full)));
|
|
} else if (e.isFile() && e.name.endsWith('.ts')) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
|
|
const STALE_AFTER_WEEKS_DEFAULT = 4;
|
|
const staleArg = args['stale-after-weeks'];
|
|
const parsedStale = Number(staleArg);
|
|
let STALE_AFTER_WEEKS;
|
|
if (staleArg === undefined) {
|
|
STALE_AFTER_WEEKS = STALE_AFTER_WEEKS_DEFAULT;
|
|
} else if (Number.isFinite(parsedStale) && parsedStale > 0) {
|
|
STALE_AFTER_WEEKS = parsedStale;
|
|
} else {
|
|
process.stderr.write(
|
|
`Invalid --stale-after-weeks=${staleArg}, falling back to ${STALE_AFTER_WEEKS_DEFAULT}.\n`,
|
|
);
|
|
STALE_AFTER_WEEKS = STALE_AFTER_WEEKS_DEFAULT;
|
|
}
|
|
|
|
const pkgDirArg = args['package-dir'];
|
|
const ledgerFile = args['ledger-file'];
|
|
if (!pkgDirArg) die(2, 'Missing required --package-dir <relative-path-to-package>');
|
|
if (!ledgerFile) die(2, 'Missing required --ledger-file <path>');
|
|
|
|
const repoRoot = path.resolve(
|
|
execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(),
|
|
);
|
|
const pkgDir = path.isAbsolute(pkgDirArg) ? pkgDirArg : path.join(repoRoot, pkgDirArg);
|
|
if (!existsSync(pkgDir)) die(2, `Package dir not found: ${pkgDir}`);
|
|
|
|
const pkgJsonPath = path.join(pkgDir, 'package.json');
|
|
if (!existsSync(pkgJsonPath)) die(2, `No package.json at ${pkgJsonPath}`);
|
|
const pkgName = JSON.parse(await readFile(pkgJsonPath, 'utf8')).name;
|
|
|
|
const srcDir = path.join(pkgDir, 'src');
|
|
if (!existsSync(srcDir)) die(2, `No src/ in ${pkgDir}`);
|
|
|
|
const ledgerPath = path.isAbsolute(ledgerFile) ? ledgerFile : path.join(process.cwd(), ledgerFile);
|
|
if (!existsSync(ledgerPath)) die(2, `Ledger file not found: ${ledgerPath}`);
|
|
|
|
// The reader webhook returns an empty body (not `{"ledger":[]}`) for packages
|
|
// it has never scored. Treat that as a zero-row ledger so the picker can still
|
|
// synthesise `new` rows from the source tree.
|
|
const ledgerRaw = (await readFile(ledgerPath, 'utf8')).trim();
|
|
const ledgerPayload = ledgerRaw === '' ? { ledger: [] } : JSON.parse(ledgerRaw);
|
|
const liveLedger = ledgerPayload.ledger;
|
|
if (!Array.isArray(liveLedger)) die(2, 'Ledger payload missing `ledger` array.');
|
|
|
|
const allSources = (await walkSources(srcDir)).sort();
|
|
const worthy = allSources.filter(isMutationWorthy).map((abs) => path.relative(repoRoot, abs));
|
|
|
|
if (worthy.length === 0) {
|
|
process.stderr.write('No mutation-worthy source files found under src/.\n');
|
|
process.stdout.write(JSON.stringify({ picked: null, reason: 'empty-source-tree' }) + '\n');
|
|
process.exit(0);
|
|
}
|
|
|
|
// Merge: live ledger row wins over synthesised "new" row.
|
|
const byPath = new Map();
|
|
for (const row of liveLedger) {
|
|
byPath.set(row.source_file_path, row);
|
|
}
|
|
const merged = worthy.map(
|
|
(p) =>
|
|
byPath.get(p) ?? {
|
|
source_file_path: p,
|
|
package: pkgName,
|
|
last_score: null,
|
|
threshold_at_run: null,
|
|
last_checked_at: null,
|
|
status: 'new',
|
|
},
|
|
);
|
|
|
|
const NOW = Date.now();
|
|
const STALE_AFTER_MS = STALE_AFTER_WEEKS * 7 * 24 * 60 * 60 * 1000;
|
|
|
|
function computeEffectiveStatus(row) {
|
|
if (row.status === 'new') return 'new';
|
|
if (row.status === 'red') return 'red';
|
|
// status === 'green' — promote to 'stale' if old enough
|
|
if (row.last_checked_at) {
|
|
const age = NOW - Date.parse(row.last_checked_at);
|
|
if (age > STALE_AFTER_MS) return 'stale';
|
|
}
|
|
return 'green';
|
|
}
|
|
|
|
const PRIORITY = { new: 0, red: 1, stale: 2, green: 3 };
|
|
|
|
const annotated = merged.map((row) => ({ ...row, effective_status: computeEffectiveStatus(row) }));
|
|
|
|
annotated.sort((a, b) => {
|
|
const pa = PRIORITY[a.effective_status] ?? 99;
|
|
const pb = PRIORITY[b.effective_status] ?? 99;
|
|
if (pa !== pb) return pa - pb;
|
|
|
|
if (a.effective_status === 'new') {
|
|
return a.source_file_path.localeCompare(b.source_file_path);
|
|
}
|
|
|
|
if (a.effective_status === 'red') {
|
|
const sa = a.last_score == null ? Infinity : Number(a.last_score);
|
|
const sb = b.last_score == null ? Infinity : Number(b.last_score);
|
|
if (sa !== sb) return sa - sb;
|
|
return a.source_file_path.localeCompare(b.source_file_path);
|
|
}
|
|
|
|
// stale: oldest last_checked_at first
|
|
const ta = a.last_checked_at ? Date.parse(a.last_checked_at) : 0;
|
|
const tb = b.last_checked_at ? Date.parse(b.last_checked_at) : 0;
|
|
if (ta !== tb) return ta - tb;
|
|
return a.source_file_path.localeCompare(b.source_file_path);
|
|
});
|
|
|
|
const counts = annotated.reduce((acc, r) => {
|
|
acc[r.effective_status] = (acc[r.effective_status] ?? 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
process.stderr.write(
|
|
`Source files: ${worthy.length} • ` +
|
|
`new=${counts.new ?? 0} red=${counts.red ?? 0} stale=${counts.stale ?? 0} green=${counts.green ?? 0}\n`,
|
|
);
|
|
|
|
// --mode restricts the candidate set to one bucket; omitted = combined.
|
|
const MODE_BUCKETS = {
|
|
baseline: new Set(['new']),
|
|
coverage: new Set(['red', 'stale']),
|
|
};
|
|
const mode = args.mode;
|
|
if (mode !== undefined && !Object.hasOwn(MODE_BUCKETS, mode)) {
|
|
die(2, `Invalid --mode=${mode}. Use 'baseline' or 'coverage' (omit for combined new→red→stale).`);
|
|
}
|
|
const candidates = mode
|
|
? annotated.filter((r) => MODE_BUCKETS[mode].has(r.effective_status))
|
|
: annotated;
|
|
|
|
const top = candidates[0];
|
|
|
|
// Nothing to do: an empty mode-filtered set, or (combined mode) the best row is green.
|
|
if (!top || (!mode && top.effective_status === 'green')) {
|
|
const reason =
|
|
mode === 'baseline'
|
|
? 'no-new-files'
|
|
: mode === 'coverage'
|
|
? 'nothing-below-threshold'
|
|
: 'all-green';
|
|
process.stderr.write(`Nothing to do for mode=${mode ?? 'combined'} (${reason}).\n`);
|
|
process.stdout.write(JSON.stringify({ picked: null, reason }) + '\n');
|
|
process.exit(0);
|
|
}
|
|
|
|
process.stderr.write(
|
|
`Picked: ${top.source_file_path}\n` +
|
|
` priority=${top.effective_status} ` +
|
|
`(was ${top.status}, last_checked_at=${top.last_checked_at ?? 'never'})\n`,
|
|
);
|
|
|
|
process.stdout.write(
|
|
JSON.stringify({
|
|
picked: {
|
|
source_file_path: top.source_file_path,
|
|
package: top.package,
|
|
prior_status: top.status,
|
|
effective_status: top.effective_status,
|
|
},
|
|
}) + '\n',
|
|
);
|