Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

561 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* Diff-scoped guard against NEW raw `process.env` / `import.meta.env` reads of a
* brand-env-alias key (#13422).
*
* The brand-env migration routes every read of an aliased key
* (`ELIZA_STATE_DIR`, `ELIZA_API_TOKEN`, the ports, CORS/host allow-lists, the
* mobile-platform flag, …) through the alias-aware reader
* (`resolveAliasedEnvValue` / the `runtime-env` resolvers) so a white-label
* distribution's `<PREFIX>_*` variable resolves WITHOUT materializing the
* `ELIZA_*` mirror in `process.env`. A raw `process.env.ELIZA_STATE_DIR` read
* bypasses that reader: it only sees the canonical key, so a `MILADY_STATE_DIR`
* that was never mirrored silently reads as unset. The migration removes the
* existing raw reads over time; this guard stops NEW ones from regrowing.
*
* The guarded key set is read from the single source of truth
* (`packages/shared/src/config/brand-env-aliases.ts`) — every `elizaKey` /
* `syncElizaKey` in `BRAND_ENV_ALIAS_DEFINITIONS` — so the guard tracks the
* table automatically as aliases are added or removed.
*
* The repo already carries dozens of pre-existing raw reads; a repo-wide count
* vs a static baseline cannot work (any unrelated `develop` merge that adds one
* reds `verify` for everyone who rebases). So enforcement is scoped to the diff,
* exactly like `error-policy-ratchet.mjs`:
*
* base = git merge-base <base-ref> HEAD (default base-ref origin/develop)
* for each guarded source file the branch touches (base..HEAD):
* fail iff its CURRENT (working-tree) raw-aliased-read count is GREATER than
* the same file's count at `base`.
*
* Immune to drift in files the PR does not touch; a no-op that passes on
* `develop` itself and when no base ref is resolvable. The reader / alias-table
* / sync files that legitimately name the canonical keys are allowlisted.
*
* Reads are classified via the TypeScript AST so a key inside a string or
* comment is never miscounted. Three read shapes are detected, on either a
* `process.env` or an `import.meta.env` container:
* - member access `process.env.ELIZA_STATE_DIR`
* - element access `process.env["ELIZA_STATE_DIR"]`
* - object destructuring `const { ELIZA_STATE_DIR } = process.env`
*
* The repo-wide total is available via `--report` (informational only — it never
* affects the exit code) to track the migration burn-down.
*/
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
const ts = require("typescript");
const ROOT = path.resolve(import.meta.dirname, "../..");
const ALIAS_TABLE_FILE = "packages/shared/src/config/brand-env-aliases.ts";
/**
* Reader / alias-table / sync files that legitimately name the canonical
* `ELIZA_*` keys: the alias table itself, the non-mutating reader, the sync
* mirror, and the alias-aware runtime-env resolver. Raw reads here are the
* reader implementation, not a bypass of it.
*/
const ALLOWLIST = new Set([
"packages/shared/src/config/brand-env-aliases.ts",
"packages/shared/src/config/boot-config.ts",
"packages/shared/src/config/boot-config-store.ts",
"packages/shared/src/utils/env.ts",
"packages/shared/src/runtime-env.ts",
"packages/core/src/boot-env.ts",
]);
const EXCLUDED_SEGMENTS = new Set([
"__fixtures__",
"__mocks__",
"__tests__",
"fixtures",
"generated",
"mock",
"mocks",
"test",
"tests",
]);
const args = new Set(process.argv.slice(2));
const JSON_FLAG = args.has("--json");
const SELF_TEST = args.has("--self-test");
const REPORT = args.has("--report");
function usage() {
console.log(`Usage: node packages/scripts/alias-read-guard.mjs [options]
Diff-scoped: fails only when a source file the branch touches under
packages/**/src or plugins/**/src increases its own count of raw
process.env / import.meta.env reads of a brand-env-alias key vs the
merge-base with origin/develop. Immune to unrelated develop drift.
The guarded key set is read from ${ALIAS_TABLE_FILE}.
Options:
--json Print machine-readable diff-scoped result JSON.
--report Also compute + print the repo-wide total (informational only).
--self-test Run the AST classifier + key-extraction + comparison self-test.
Env:
ALIAS_READ_GUARD_BASE_REF Override the base ref (default: origin/develop, then develop).
`);
}
if (args.has("--help") || args.has("-h")) {
usage();
process.exit(0);
}
/**
* Extract the guarded canonical keys from the alias table's source. Every
* `elizaKey` / `syncElizaKey` string-literal property value in
* `BRAND_ENV_ALIAS_DEFINITIONS` is a canonical key a raw read must not target.
* Parsing the AST (not a regex) keeps this exact as the table's formatting
* changes. Exported for the self-test.
*/
export function extractGuardedKeys(aliasTableSource) {
const sourceFile = ts.createSourceFile(
ALIAS_TABLE_FILE,
aliasTableSource,
ts.ScriptTarget.Latest,
true,
ts.ScriptKind.TS,
);
const keys = new Set();
function visit(node) {
if (
ts.isPropertyAssignment(node) &&
(ts.isIdentifier(node.name) || ts.isStringLiteral(node.name)) &&
(node.name.text === "elizaKey" || node.name.text === "syncElizaKey") &&
ts.isStringLiteralLike(node.initializer)
) {
keys.add(node.initializer.text);
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
if (keys.size === 0) {
throw new Error(
`[alias-read-guard] no guarded keys found in ${ALIAS_TABLE_FILE}; the alias-table format changed — update the extractor.`,
);
}
return keys;
}
function sourceFileKind(relPath) {
return relPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
}
/**
* True for the `process.env` / `import.meta.env` container expression, the only
* two objects whose members this guard treats as a raw aliased-env read.
*/
function isEnvContainer(node) {
if (!node || !ts.isPropertyAccessExpression(node)) return false;
if (node.name.text !== "env") return false;
const target = node.expression;
if (ts.isIdentifier(target) && target.text === "process") return true;
// import.meta.env — `import.meta` parses to a MetaProperty node.
if (ts.isMetaProperty(target) && target.name.text === "meta") return true;
return false;
}
/**
* Collect every raw aliased-env read in one source file's text. Member access,
* element access, and object destructuring off a `process.env`/`import.meta.env`
* container whose key is in `guardedKeys`. Exported for the self-test.
*/
export function collectRawAliasReads(sourceText, relPath, guardedKeys) {
const sourceFile = ts.createSourceFile(
relPath,
sourceText,
ts.ScriptTarget.Latest,
true,
sourceFileKind(relPath),
);
const findings = [];
function record(key, node) {
const pos = sourceFile.getLineAndCharacterOfPosition(
node.getStart(sourceFile),
);
findings.push({ key, file: relPath, line: pos.line + 1 });
}
function visit(node) {
// process.env.KEY / import.meta.env.KEY
if (
ts.isPropertyAccessExpression(node) &&
isEnvContainer(node.expression) &&
ts.isIdentifier(node.name) &&
guardedKeys.has(node.name.text)
) {
record(node.name.text, node);
}
// process.env["KEY"] / import.meta.env["KEY"]
if (
ts.isElementAccessExpression(node) &&
isEnvContainer(node.expression) &&
ts.isStringLiteralLike(node.argumentExpression) &&
guardedKeys.has(node.argumentExpression.text)
) {
record(node.argumentExpression.text, node);
}
// const { KEY } = process.env / import.meta.env
if (
ts.isVariableDeclaration(node) &&
node.initializer &&
isEnvContainer(node.initializer) &&
ts.isObjectBindingPattern(node.name)
) {
for (const element of node.name.elements) {
const source = element.propertyName ?? element.name;
if (ts.isIdentifier(source) && guardedKeys.has(source.text)) {
record(source.text, element);
}
}
}
ts.forEachChild(node, visit);
}
visit(sourceFile);
return findings;
}
function countText(sourceText, relPath, guardedKeys) {
return collectRawAliasReads(sourceText, relPath, guardedKeys).length;
}
/**
* A guarded source file: production `.ts`/`.tsx` under `packages/**/src` or
* `plugins/**/src`, excluding declaration/test/fixture files and the allowlisted
* reader/alias-table/sync files.
*/
export function isGuardedSourceFile(relPath) {
if (!/\.(ts|tsx)$/.test(relPath)) return false;
if (/\.d\.ts$/.test(relPath)) return false;
if (ALLOWLIST.has(relPath)) return false;
const underPackages = relPath.startsWith("packages/");
const underPlugins = relPath.startsWith("plugins/");
if (!underPackages && !underPlugins) return false;
if (!relPath.includes("/src/")) return false;
const parts = relPath.split("/");
if (parts.some((part) => EXCLUDED_SEGMENTS.has(part))) return false;
const base = path.basename(relPath);
if (/\.(test|spec|e2e|stories?|fixture|mock)\.(ts|tsx)$/.test(base)) {
return false;
}
return true;
}
function git(argv, { allowFailure = false } = {}) {
try {
return execFileSync("git", argv, {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
stdio: ["ignore", "pipe", allowFailure ? "ignore" : "inherit"],
});
} catch (err) {
if (allowFailure) return null;
throw err;
}
}
/** First resolvable ref among the env override, origin/develop, develop. */
function resolveBaseRef() {
const candidates = [
process.env.ALIAS_READ_GUARD_BASE_REF,
"origin/develop",
"develop",
].filter(Boolean);
for (const ref of candidates) {
if (
git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], {
allowFailure: true,
})
) {
return ref;
}
}
return null;
}
/** Merge-base of the base ref and HEAD; null if it cannot be computed. */
function mergeBaseWith(ref) {
const out = git(["merge-base", ref, "HEAD"], { allowFailure: true });
return out ? out.trim() : null;
}
/** Guarded source files the branch touches relative to the merge-base. */
function changedGuardedFiles(base) {
const out = git(["diff", "--name-only", "-z", `${base}`, "HEAD"], {
allowFailure: true,
});
if (!out) return [];
return [...new Set(out.split("\0").filter(Boolean))]
.filter(isGuardedSourceFile)
.sort();
}
/** File content at `base`, or null if the file did not exist there. */
function baseContent(base, relPath) {
return git(["show", `${base}:${relPath}`], { allowFailure: true });
}
/** Working-tree content, or null if the file was deleted on the branch. */
function workingTreeContent(relPath) {
try {
return readFileSync(path.join(ROOT, relPath), "utf8");
} catch {
return null;
}
}
/**
* For each changed guarded file, compare its working-tree raw-read count to its
* count at the merge-base. New files compare against zero.
*/
function diffScopedRegressions(base, files, guardedKeys) {
const perFile = [];
const regressions = [];
for (const relPath of files) {
const currentText = workingTreeContent(relPath);
if (currentText === null) continue; // deleted on the branch — nothing added.
const currentFindings = collectRawAliasReads(
currentText,
relPath,
guardedKeys,
);
const current = currentFindings.length;
const baseText = baseContent(base, relPath);
const base_ = baseText === null ? 0 : countText(baseText, relPath, guardedKeys);
perFile.push({ file: relPath, current, base: base_ });
if (current > base_) {
regressions.push({
file: relPath,
current,
base: base_,
findings: currentFindings,
});
}
}
return { perFile, regressions };
}
/** Repo-wide informational total (never gates). */
function repoWideTotal(guardedKeys) {
const output = git(["ls-files"]);
const files = [...new Set(output.split("\n").filter(Boolean))].filter(
isGuardedSourceFile,
);
let total = 0;
const byFile = [];
for (const relPath of files) {
const n = countText(
readFileSync(path.join(ROOT, relPath), "utf8"),
relPath,
guardedKeys,
);
if (n > 0) byFile.push({ file: relPath, count: n });
total += n;
}
return { filesScanned: files.length, filesWithReads: byFile.length, total };
}
function printHumanSummary({ baseRef, base, files, perFile, regressions }) {
console.log(
`[alias-read-guard] base ${baseRef} (${base.slice(0, 10)}); ${files.length} changed guarded source file(s)`,
);
for (const row of perFile) {
console.log(
`[alias-read-guard] ${row.file}: raw-aliased-reads ${row.base}->${row.current}`,
);
}
if (regressions.length === 0) {
console.log(
"[alias-read-guard] no new raw process.env/import.meta.env reads of aliased keys in touched files",
);
return;
}
console.error(
"[alias-read-guard] new raw aliased-env reads added in touched files:",
);
for (const r of regressions) {
console.error(` - ${r.file}: ${r.base} -> ${r.current}`);
for (const f of r.findings) {
console.error(` L${f.line}: process.env/import.meta.env.${f.key}`);
}
}
console.error(
`\nA PR may not ADD raw process.env / import.meta.env reads of a brand-env-alias key ` +
`(defined in ${ALIAS_TABLE_FILE}). Read aliased keys through the alias-aware reader ` +
`(resolveAliasedEnvValue / the runtime-env resolvers / readAliasedEnv) so a rebranded ` +
`<PREFIX>_* value resolves without the ELIZA_* mirror. See issue #13422.`,
);
}
function runSelfTest() {
// Key extraction reads elizaKey + syncElizaKey off the real table shape.
const sampleTable = `
export const BRAND_ENV_ALIAS_DEFINITIONS = [
{ brandSuffix: "STATE_DIR", elizaKey: "ELIZA_STATE_DIR" },
{ brandSuffix: "SETTINGS_DEBUG", elizaKey: "VITE_ELIZA_SETTINGS_DEBUG", vite: true },
{ brandSuffix: "PORT", elizaKey: "ELIZA_PORT", syncElizaKey: "ELIZA_UI_PORT" },
] as const;
// brandSuffix must NOT be extracted:
const notAKey = { brandSuffix: "NOT_A_GUARDED_KEY" };
`;
const keys = extractGuardedKeys(sampleTable);
const expectKeys = [
"ELIZA_STATE_DIR",
"VITE_ELIZA_SETTINGS_DEBUG",
"ELIZA_PORT",
"ELIZA_UI_PORT",
];
for (const k of expectKeys) {
if (!keys.has(k)) {
console.error(`[alias-read-guard] self-test failed: missing key ${k}`);
process.exit(1);
}
}
if (keys.has("STATE_DIR") || keys.has("NOT_A_GUARDED_KEY")) {
console.error(
"[alias-read-guard] self-test failed: brandSuffix leaked into guarded keys",
);
process.exit(1);
}
const guardedKeys = new Set(expectKeys);
const sample = `
const a = process.env.ELIZA_STATE_DIR; // read 1 (member)
const b = process.env["ELIZA_PORT"]; // read 2 (element)
const { ELIZA_UI_PORT } = process.env; // read 3 (destructure)
const c = import.meta.env.VITE_ELIZA_SETTINGS_DEBUG; // read 4 (import.meta.env)
const d = process.env.SOME_OTHER_KEY; // not guarded
const e = env.ELIZA_STATE_DIR; // not process.env
const f = "process.env.ELIZA_STATE_DIR"; // string, not a read
// process.env.ELIZA_PORT in a comment is not a read
const g = obj.process.env.ELIZA_PORT; // not the global process
`;
const findings = collectRawAliasReads(sample, "packages/x/src/sample.ts", guardedKeys);
if (findings.length !== 4) {
console.error(
`[alias-read-guard] self-test failed: expected 4 reads, got ${findings.length}: ${JSON.stringify(findings)}`,
);
process.exit(1);
}
// Scope: allowlisted + test + non-src + out-of-tree files are not guarded.
const scopeCases = [
["packages/x/src/a.ts", true],
// a plugins/**/src file is in scope; the literal avoids the plugins/plugin-*
// coupling-token shape audit-scripts.mjs forbids in generic scripts.
["plugins/example-y/src/b.tsx", true],
["packages/x/src/a.test.ts", false],
["packages/x/src/__tests__/a.ts", false],
["packages/x/src/a.d.ts", false],
["packages/x/lib/a.ts", false],
["packages/shared/src/runtime-env.ts", false], // allowlisted reader
["packages/core/src/boot-env.ts", false], // allowlisted reader
["apps/app/src/a.ts", false], // outside packages/ + plugins/
["scripts/a.ts", false],
];
for (const [file, expected] of scopeCases) {
if (isGuardedSourceFile(file) !== expected) {
console.error(
`[alias-read-guard] self-test failed: scope for ${file} should be ${expected}`,
);
process.exit(1);
}
}
console.log("[alias-read-guard] self-test passed");
}
if (SELF_TEST) {
runSelfTest();
process.exit(0);
}
const guardedKeys = extractGuardedKeys(
readFileSync(path.join(ROOT, ALIAS_TABLE_FILE), "utf8"),
);
const baseRef = resolveBaseRef();
const base = baseRef ? mergeBaseWith(baseRef) : null;
if (!base) {
const reason = baseRef
? `no merge-base with ${baseRef}`
: "no base ref (origin/develop) resolvable";
const repoWide = REPORT ? repoWideTotal(guardedKeys) : null;
if (JSON_FLAG) {
console.log(
JSON.stringify(
{ ok: true, skipped: reason, baseRef, mergeBase: null, repoWide },
null,
2,
),
);
} else {
console.log(
`[alias-read-guard] ${reason}; diff-scoped check skipped (pass)`,
);
if (repoWide) {
console.log(
`[alias-read-guard] repo-wide (informational): ${repoWide.total} raw aliased-env read(s) across ${repoWide.filesWithReads} file(s)`,
);
}
}
process.exit(0);
}
const files = changedGuardedFiles(base);
const { perFile, regressions } = diffScopedRegressions(base, files, guardedKeys);
const repoWide = REPORT ? repoWideTotal(guardedKeys) : null;
if (JSON_FLAG) {
console.log(
JSON.stringify(
{
ok: regressions.length === 0,
baseRef,
mergeBase: base,
guardedKeyCount: guardedKeys.size,
changedFiles: files,
perFile,
regressions,
repoWide,
},
null,
2,
),
);
} else {
printHumanSummary({ baseRef, base, files, perFile, regressions });
if (repoWide) {
console.log(
`[alias-read-guard] repo-wide (informational): ${repoWide.total} raw aliased-env read(s) across ${repoWide.filesWithReads} file(s)`,
);
}
}
if (regressions.length > 0) process.exit(1);