c488c7b4f9
Co-authored-by: Kandev Agent <agent@kandev.dev>
130 lines
5.5 KiB
JavaScript
130 lines
5.5 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Debt report for wall-clock waits in `apps/web/e2e`.
|
|
*
|
|
* **This is a report, not a gate.** It never exits non-zero on a count. The
|
|
* conversion effort is actively *adding* `unverified` dwells as it replaces raw
|
|
* sleeps with categorised ones — that is progress, because an `unverified`
|
|
* dwell states its ignorance where a bare `waitForTimeout(500)` hid it — so a
|
|
* ratchet on the number would fight the migration it exists to measure. The
|
|
* gate is `check-new-e2e-sleeps.mjs`, and it gates the *form*, not the count.
|
|
*
|
|
* Three numbers, deliberately kept apart:
|
|
*
|
|
* - **raw sleeps**, counted by the same eslint rule the gate uses, so the
|
|
* report and the gate cannot disagree about what a sleep is. This is what
|
|
* the conversion drives to zero, and reaching zero is what makes the
|
|
* graduation flip (rule to `"error"` on all of `e2e/**`) possible.
|
|
* - **dwell debt**, every categorised wait EXCEPT `negative-assertion`.
|
|
* `unverified` is called out inside it as the sub-total to attack first.
|
|
* - **injectLatency**, counted on its own. It is deliberate fixture
|
|
* configuration, correct by construction, and must never be converted; if
|
|
* it were folded into the sleep total, adding a legitimate latency
|
|
* simulation would look like a regression.
|
|
*
|
|
* `negative-assertion` is printed under a heading that says it is permanent.
|
|
* A test asserting something never happens has no event to wait on, and
|
|
* deleting its wait removes the window the regression needs in order to appear.
|
|
*
|
|
* Usage: node scripts/report-e2e-waits.mjs [--json]
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
|
|
import { ESLint } from "eslint";
|
|
|
|
import { SLEEP_EXEMPT_FILES, SLEEP_RULE_ID } from "../eslint-rules/no-unsanctioned-sleep.mjs";
|
|
import { inventorySource, readDwellCategories, summarize } from "./lib/e2e-wait-inventory.mjs";
|
|
import { WEB_DIR } from "./lib/git-base.mjs";
|
|
|
|
const E2E_DIR = path.join(WEB_DIR, "e2e");
|
|
const CAUSAL_WAITS = path.join(E2E_DIR, "helpers", "causal-waits.ts");
|
|
|
|
function collectTsFiles(dir) {
|
|
const out = [];
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (entry.name === "node_modules" || entry.name === "test-results") continue;
|
|
out.push(...collectTsFiles(full));
|
|
} else if (entry.name.endsWith(".ts")) {
|
|
out.push(full);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const categories = readDwellCategories(fs.readFileSync(CAUSAL_WAITS, "utf8"));
|
|
const files = collectTsFiles(E2E_DIR);
|
|
|
|
// The helper's own definitions and its unit test would otherwise be counted as
|
|
// usage sites, inflating every category by the examples that define it. Same
|
|
// list the eslint configs use, so "exempt from the rule" and "not a usage site"
|
|
// cannot drift apart.
|
|
const exempt = new Set(SLEEP_EXEMPT_FILES.map((file) => path.join(WEB_DIR, file)));
|
|
|
|
// Sanctioned waits: our own AST walk, since the rule never sees them.
|
|
const inventories = files
|
|
.filter((file) => !exempt.has(file))
|
|
.map((file) => inventorySource(fs.readFileSync(file, "utf8"), file, categories));
|
|
const dwell = summarize(inventories, categories);
|
|
|
|
// Raw sleeps: the gate's own rule, so there is one definition of "a sleep".
|
|
const eslint = new ESLint({
|
|
cwd: WEB_DIR,
|
|
overrideConfigFile: path.join(WEB_DIR, "eslint.e2e-sleeps.config.mjs"),
|
|
warnIgnored: false,
|
|
});
|
|
const results = await eslint.lintFiles(files);
|
|
|
|
const raw = { waitForTimeout: 0, promiseSleep: 0, byFile: {} };
|
|
for (const result of results) {
|
|
const messages = result.messages.filter((message) => message.ruleId === SLEEP_RULE_ID);
|
|
if (messages.length === 0) continue;
|
|
raw.byFile[path.relative(WEB_DIR, result.filePath)] = messages.length;
|
|
for (const message of messages) {
|
|
if (message.messageId === "waitForTimeout") raw.waitForTimeout += 1;
|
|
else raw.promiseSleep += 1;
|
|
}
|
|
}
|
|
const rawTotal = raw.waitForTimeout + raw.promiseSleep;
|
|
|
|
if (process.argv.includes("--json")) {
|
|
console.log(JSON.stringify({ raw: { ...raw, total: rawTotal }, dwell }, null, 2));
|
|
process.exit(0);
|
|
}
|
|
|
|
const pad = (label) => label.padEnd(22);
|
|
console.log("\nE2E wall-clock wait inventory\n");
|
|
|
|
console.log(` UNSANCTIONED — drive to zero, then graduate the rule to "error"`);
|
|
console.log(` ${pad("waitForTimeout")}${raw.waitForTimeout}`);
|
|
console.log(` ${pad("promise sleep")}${raw.promiseSleep}`);
|
|
console.log(` ${pad("total")}${rawTotal}`);
|
|
|
|
console.log(`\n DWELL DEBT — categorised, ${dwell.debt} of ${dwell.totalDwells} are debt`);
|
|
for (const category of categories) {
|
|
if (category === "negative-assertion") continue;
|
|
const count = dwell.byCategory[category] ?? 0;
|
|
const note = category === "unverified" ? " <- attack this first" : "";
|
|
console.log(` ${pad(category)}${count}${note}`);
|
|
}
|
|
if (dwell.unknown > 0) {
|
|
console.log(` ${pad("uncategorised")}${dwell.unknown} <- miscategorised or unparsed`);
|
|
}
|
|
|
|
console.log(`\n PERMANENT — not debt, do not drive down`);
|
|
console.log(` ${pad("negative-assertion")}${dwell.permanent}`);
|
|
|
|
console.log(`\n DELIBERATE STIMULUS — not a wait, counted apart from sleep debt`);
|
|
console.log(` ${pad("injectLatency")}${dwell.injectLatency}`);
|
|
|
|
if (rawTotal > 0) {
|
|
const worst = Object.entries(raw.byFile)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 10);
|
|
console.log(`\n Top files by remaining raw sleeps (${Object.keys(raw.byFile).length} total):`);
|
|
for (const [file, count] of worst) console.log(` ${String(count).padStart(3)} ${file}`);
|
|
}
|
|
console.log("");
|