mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 18:22:48 +03:00
294 lines
9.8 KiB
JavaScript
294 lines
9.8 KiB
JavaScript
#!/usr/bin/env node
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { globSync } from "tinyglobby";
|
|
|
|
import { resolveImport } from "../quality/build-test-impact-map.mjs";
|
|
|
|
const DEFAULT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const SOURCE_ROOTS = ["src/", "open-sse/", "bin/"];
|
|
const SOURCE_GLOBS = [
|
|
"src/**/*.{ts,tsx,mts,js,mjs}",
|
|
"open-sse/**/*.{ts,tsx,mts,js,mjs}",
|
|
"bin/**/*.{ts,tsx,mts,js,mjs}",
|
|
];
|
|
const IGNORE = [
|
|
"**/__tests__/**",
|
|
"**/*.test.*",
|
|
"**/*.spec.*",
|
|
"**/fixtures/**",
|
|
"**/generated/**",
|
|
];
|
|
const STATIC_IMPORT_RE =
|
|
/(?:import|export)[^'"()]*from\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
const DYNAMIC_IMPORT_RE = /import\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
const TEST_MASK_RE =
|
|
/^\+.*(?:\b(?:it|test|describe)\.(?:skip|todo)\b|\b(?:xit|xtest|xdescribe)\s*\()/;
|
|
const REFERENCE_RE = /^(?:#\d+|https:\/\/github\.com\/[^/]+\/[^/]+\/(?:issues|pull)\/\d+)$/;
|
|
|
|
function normalize(file) {
|
|
return file.split(path.sep).join("/");
|
|
}
|
|
|
|
function isProduction(file) {
|
|
return (
|
|
SOURCE_ROOTS.some((root) => file.startsWith(root)) &&
|
|
!IGNORE.some((pattern) => {
|
|
const token = pattern.replaceAll("**/", "").replaceAll("/**", "").replaceAll("*", "");
|
|
return token && file.includes(token);
|
|
})
|
|
);
|
|
}
|
|
|
|
function isBarrel(file, code) {
|
|
return /(?:^|\/)index\.[cm]?[jt]sx?$/.test(file) && /\bexport\s+(?:\*|\{)/.test(code);
|
|
}
|
|
|
|
function importEdges(root) {
|
|
const edges = [];
|
|
const files = globSync(SOURCE_GLOBS, { cwd: root, absolute: true, ignore: IGNORE });
|
|
for (const absolute of files) {
|
|
const consumer = normalize(path.relative(root, absolute));
|
|
const code = fs.readFileSync(absolute, "utf8");
|
|
for (const match of code.matchAll(STATIC_IMPORT_RE)) {
|
|
const resolved = resolveImport(match[1] || match[2], absolute, root);
|
|
if (resolved) {
|
|
edges.push({
|
|
module: normalize(path.relative(root, resolved)),
|
|
consumer,
|
|
kind: isBarrel(consumer, code) ? "barrel" : "static",
|
|
});
|
|
}
|
|
}
|
|
for (const match of code.matchAll(DYNAMIC_IMPORT_RE)) {
|
|
const resolved = resolveImport(match[1], absolute, root);
|
|
if (resolved) {
|
|
edges.push({
|
|
module: normalize(path.relative(root, resolved)),
|
|
consumer,
|
|
kind: "dynamic-import",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return edges.sort((a, b) =>
|
|
`${a.module}\0${a.consumer}\0${a.kind}`.localeCompare(`${b.module}\0${b.consumer}\0${b.kind}`)
|
|
);
|
|
}
|
|
|
|
export function validateAllowlist(value) {
|
|
const entries = Array.isArray(value) ? value : value?.entries;
|
|
if (!Array.isArray(entries))
|
|
throw new Error("forgotten-sibling allowlist must contain an entries array");
|
|
return entries.map((entry, index) => {
|
|
for (const field of ["consumer", "candidateTest", "rationale", "reference"]) {
|
|
if (typeof entry?.[field] !== "string" || !entry[field].trim()) {
|
|
throw new Error(`forgotten-sibling allowlist entry ${index} requires ${field}`);
|
|
}
|
|
}
|
|
if (entry.rationale.trim().length < 20) {
|
|
throw new Error(`forgotten-sibling allowlist entry ${index} rationale must be specific`);
|
|
}
|
|
if (!REFERENCE_RE.test(entry.reference.trim())) {
|
|
throw new Error(
|
|
`forgotten-sibling allowlist entry ${index} reference must be a GitHub issue or PR`
|
|
);
|
|
}
|
|
return {
|
|
consumer: normalize(entry.consumer.trim()),
|
|
candidateTest: normalize(entry.candidateTest.trim()),
|
|
rationale: entry.rationale.trim(),
|
|
reference: entry.reference.trim(),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function analyzeForgottenSiblingTests({
|
|
root = DEFAULT_ROOT,
|
|
changedEntries,
|
|
impactMap,
|
|
allowlist,
|
|
changedSymbolsByFile = {},
|
|
addedTestLines = [],
|
|
}) {
|
|
const changed = new Map(changedEntries.map((entry) => [normalize(entry.file), entry.status]));
|
|
const changedModules = [...changed.keys()].filter(isProduction).sort();
|
|
const maskingAdded = addedTestLines.some((line) => TEST_MASK_RE.test(line));
|
|
const allow = new Map(
|
|
allowlist.map((entry) => [`${entry.consumer}\0${entry.candidateTest}`, entry])
|
|
);
|
|
const findings = [];
|
|
const diagnostics = [];
|
|
const suppressed = [];
|
|
const maskingRisks = [];
|
|
|
|
for (const edge of importEdges(root)) {
|
|
if (!changedModules.includes(edge.module)) continue;
|
|
const tests = [...new Set(impactMap.sources?.[edge.consumer] || [])].sort();
|
|
if (edge.kind !== "static") {
|
|
diagnostics.push({
|
|
changedModule: edge.module,
|
|
consumer: edge.consumer,
|
|
kind: edge.kind,
|
|
message: `${edge.kind} resolution is advisory and never blocks`,
|
|
});
|
|
continue;
|
|
}
|
|
for (const candidateTest of tests) {
|
|
const status = changed.get(candidateTest);
|
|
const masking = status === "D" || (status && maskingAdded);
|
|
if (masking) {
|
|
maskingRisks.push({
|
|
changedModule: edge.module,
|
|
consumer: edge.consumer,
|
|
candidateTest,
|
|
reason:
|
|
status === "D"
|
|
? "candidate sibling test was deleted"
|
|
: "candidate sibling test adds skip/todo masking",
|
|
});
|
|
continue;
|
|
}
|
|
if (status) continue;
|
|
const finding = {
|
|
changedModule: edge.module,
|
|
changedSymbols: [...(changedSymbolsByFile[edge.module] || [])].sort(),
|
|
consumer: edge.consumer,
|
|
candidateTest,
|
|
reason: "candidate sibling test is absent from the PR diff",
|
|
};
|
|
const exception = allow.get(`${edge.consumer}\0${candidateTest}`);
|
|
if (exception) suppressed.push({ ...finding, exception });
|
|
else findings.push(finding);
|
|
}
|
|
}
|
|
return { mode: "advisory", findings, diagnostics, suppressed, maskingRisks };
|
|
}
|
|
|
|
function arg(name, fallback = "") {
|
|
const index = process.argv.indexOf(name);
|
|
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
|
}
|
|
|
|
function git(root, args) {
|
|
return execFileSync("git", args, { cwd: root, encoding: "utf8" });
|
|
}
|
|
|
|
function changedEntries(root, base) {
|
|
return git(root, ["diff", "--name-status", "--diff-filter=ACMRD", `${base}...HEAD`])
|
|
.trim()
|
|
.split(/\r?\n/)
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const [status, ...files] = line.split("\t");
|
|
return { status: status[0], file: files.at(-1) };
|
|
});
|
|
}
|
|
|
|
function changedSymbols(root, base, entries) {
|
|
const result = {};
|
|
const declaration =
|
|
/^\+\s*(?:export\s+)?(?:async\s+)?(?:function|class|const|let|var|interface|type|enum)\s+([A-Za-z_$][\w$]*)/;
|
|
for (const entry of entries.filter(({ file }) => isProduction(file))) {
|
|
const diff = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", entry.file]);
|
|
result[entry.file] = [
|
|
...new Set(
|
|
diff
|
|
.split(/\r?\n/)
|
|
.map((line) => line.match(declaration)?.[1])
|
|
.filter(Boolean)
|
|
),
|
|
];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function markdown(result, base) {
|
|
const lines = [
|
|
"## Forgotten sibling tests (advisory)",
|
|
"",
|
|
`Base: \`${base}\``,
|
|
`Unallowlisted findings: ${result.findings.length}`,
|
|
`Reviewed exceptions: ${result.suppressed.length}`,
|
|
`Resolution diagnostics: ${result.diagnostics.length}`,
|
|
`Masking/deletion risks (owned by blocking sibling gates): ${result.maskingRisks.length}`,
|
|
"",
|
|
];
|
|
if (result.findings.length) {
|
|
lines.push("### Candidate tests absent from this diff", "");
|
|
for (const item of result.findings) {
|
|
const symbol = item.changedSymbols.length ? ` (${item.changedSymbols.join(", ")})` : "";
|
|
lines.push(
|
|
`- \`${item.changedModule}\`${symbol} -> \`${item.consumer}\` -> \`${item.candidateTest}\``
|
|
);
|
|
}
|
|
lines.push("", "> Report-only calibration: these findings do not fail the job.", "");
|
|
}
|
|
for (const [heading, items] of [
|
|
["Resolution diagnostics", result.diagnostics],
|
|
["Test masking/deletion risks", result.maskingRisks],
|
|
]) {
|
|
if (!items.length) continue;
|
|
lines.push(`### ${heading}`, "");
|
|
for (const item of items)
|
|
lines.push(
|
|
`- \`${item.changedModule}\` -> \`${item.consumer}\`${item.candidateTest ? ` -> \`${item.candidateTest}\`` : ""}: ${item.reason || item.message}`
|
|
);
|
|
lines.push("");
|
|
}
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
function main() {
|
|
const root = DEFAULT_ROOT;
|
|
const base = arg(
|
|
"--base",
|
|
process.env.GITHUB_BASE_SHA ||
|
|
(process.env.GITHUB_BASE_REF ? `origin/${process.env.GITHUB_BASE_REF}` : "HEAD~1")
|
|
);
|
|
const mapPath = arg("--impact-map", path.join(root, "config/quality/test-impact-map.json"));
|
|
const allowlistPath = arg(
|
|
"--allowlist",
|
|
path.join(root, "config/quality/forgotten-sibling-allowlist.json")
|
|
);
|
|
const summaryPath = arg("--summary-file", "");
|
|
const jsonPath = arg("--json-file", "");
|
|
const entries = changedEntries(root, base);
|
|
const impactMap = JSON.parse(fs.readFileSync(mapPath, "utf8"));
|
|
const allowlist = validateAllowlist(JSON.parse(fs.readFileSync(allowlistPath, "utf8")));
|
|
const addedTestLines = git(root, ["diff", "--unified=0", `${base}...HEAD`, "--", "tests/"])
|
|
.split(/\r?\n/)
|
|
.filter((line) => line.startsWith("+") && !line.startsWith("+++"));
|
|
const result = analyzeForgottenSiblingTests({
|
|
root,
|
|
changedEntries: entries,
|
|
impactMap,
|
|
allowlist,
|
|
changedSymbolsByFile: changedSymbols(root, base, entries),
|
|
addedTestLines,
|
|
});
|
|
const report = markdown(result, base);
|
|
process.stdout.write(report);
|
|
for (const [target, contents] of [
|
|
[summaryPath, report],
|
|
[jsonPath, `${JSON.stringify(result, null, 2)}\n`],
|
|
]) {
|
|
if (!target) continue;
|
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
fs.writeFileSync(target, contents);
|
|
}
|
|
}
|
|
|
|
if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(
|
|
`forgotten-sibling-tests: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|