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
1638 lines
54 KiB
JavaScript
1638 lines
54 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Type-duplication candidate-finder (#10195).
|
|
*
|
|
* This is NOT an automatic refactor and NOT a gate. It emits a ranked,
|
|
* reviewable list of duplicate / near-duplicate type declarations plus a
|
|
* weak-type inventory, so a human can decide what to consolidate, what to share
|
|
* via `@elizaos/core`, and what is legitimately parallel-but-distinct.
|
|
*
|
|
* Companion to `type-safety-ratchet.mjs`: that script gates unsafe casts at a
|
|
* baseline; this one surfaces the structural type duplication the ratchet never
|
|
* looks at. File scope mirrors the ratchet (git ls-files, production `src/`,
|
|
* no `*.d.ts` / tests / build output).
|
|
*
|
|
* Candidate classes (each ranked by a confidence score 0..1):
|
|
* 1. same-name, multi-package — `interface ApiResponse` declared in N packages.
|
|
* 2. subset/superset — one type's property-key set ⊆ another's
|
|
* (candidate for `extends` / `Pick` / `Omit`).
|
|
* 3. structural near-duplicate — Jaccard similarity over property
|
|
* name+type-text above NEAR_DUP_THRESHOLD.
|
|
* 4. literal-set duplicate — string-literal unions, `enum`s, and
|
|
* `as const` enum-like objects that share the
|
|
* SAME value set across ≥2 packages, even
|
|
* under different names (#10201). This is the
|
|
* class that surfaced the connector-setup
|
|
* `SetupState` family ("idle"|"configuring"|
|
|
* "paired"|"error") consolidated into
|
|
* `@elizaos/core`.
|
|
* 5. runtime-schema/type match — zod `z.object(...)` and JSON-schema-like
|
|
* `{ type: "object", properties: ... }`
|
|
* objects whose keys match / strongly
|
|
* overlap exported TypeScript object types.
|
|
*
|
|
* Weak-type inventory: per-site `as unknown as`, `as any`, explicit `: any`
|
|
* (the actionable weak types). Bare `: unknown` is intentionally NOT flagged —
|
|
* most are legitimate boundary types (#10195).
|
|
*/
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } 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 ALLOWLIST_PATH = path.join(
|
|
ROOT,
|
|
"packages",
|
|
"scripts",
|
|
"type-duplication-audit.allowlist.json",
|
|
);
|
|
const JSON_OUT_PATH = path.join(ROOT, "reports", "type-duplication.json");
|
|
const MARKDOWN_OUT_PATH = path.join(
|
|
ROOT,
|
|
"test-results",
|
|
"evidence",
|
|
"10195-type-duplication.md",
|
|
);
|
|
// Reviewed baseline of per-class candidate counts (#10201). Drift against
|
|
// it is advisory only — the script never fails the build on it (see --check).
|
|
const BASELINE_PATH = path.join(
|
|
ROOT,
|
|
"packages",
|
|
"scripts",
|
|
"type-duplication-audit.baseline.json",
|
|
);
|
|
|
|
const args = new Set(process.argv.slice(2));
|
|
const SELF_TEST = args.has("--self-test");
|
|
const CHECK = args.has("--check");
|
|
const UPDATE_BASELINE = args.has("--update-baseline");
|
|
const STRICT = args.has("--strict");
|
|
|
|
// A subset/superset or near-duplicate pair only counts when both sides carry at
|
|
// least this many properties — tiny shapes (`{ id }`, `{ ok }`) collide by
|
|
// accident and would drown the report in noise.
|
|
const MIN_PROPS = 3;
|
|
// Jaccard similarity over `name:typeText` property signatures.
|
|
const NEAR_DUP_THRESHOLD = 0.6;
|
|
// A literal-set (string-union / enum / `as const`) only clusters when it has at
|
|
// least this many members — two-member sets (`"asc"|"desc"`, `"on"|"off"`)
|
|
// collide by accident across unrelated domains and would drown the report.
|
|
const MIN_LITERAL_MEMBERS = 3;
|
|
// Runtime-schema/type matching is intentionally conservative: tiny request
|
|
// schemas collide constantly, so require a real property bag and strong key
|
|
// overlap before surfacing a candidate.
|
|
const MIN_SCHEMA_PROPS = 3;
|
|
const SCHEMA_TYPE_OVERLAP_THRESHOLD = 0.8;
|
|
|
|
const EXCLUDED_SEGMENTS = new Set([
|
|
"__fixtures__",
|
|
"__mocks__",
|
|
"__tests__",
|
|
"fixtures",
|
|
"generated",
|
|
"mock",
|
|
"mocks",
|
|
"test",
|
|
"tests",
|
|
]);
|
|
|
|
function usage() {
|
|
console.log(`Usage: node packages/scripts/type-duplication-audit.mjs [options]
|
|
|
|
Options:
|
|
--self-test Prove the clustering fires on a synthetic duplicate pair
|
|
and ignores a synthetic distinct pair, then exit.
|
|
--check Compare current per-class candidate counts to the saved
|
|
baseline and print the drift. ADVISORY: exits 0 even when
|
|
counts grow (unless --strict). Still writes the report.
|
|
--strict With --check, exit 1 if any class count increased above
|
|
the baseline. Off by default so local types are never
|
|
blocked.
|
|
--update-baseline Rewrite the checked-in baseline to the current counts.
|
|
--help, -h Show this help.
|
|
|
|
Writes:
|
|
reports/type-duplication.json (gitignored, full output)
|
|
test-results/evidence/10195-type-duplication.md (committed summary)
|
|
`);
|
|
}
|
|
|
|
if (args.has("--help") || args.has("-h")) {
|
|
usage();
|
|
process.exit(0);
|
|
}
|
|
|
|
function isProductionSourceFile(relPath) {
|
|
if (!/\.(ts|tsx)$/.test(relPath)) return false;
|
|
if (/\.d\.ts$/.test(relPath)) return false;
|
|
if (!relPath.startsWith("src/") && !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 trackedSourceFiles() {
|
|
const output = execFileSync("git", ["ls-files"], {
|
|
cwd: ROOT,
|
|
encoding: "utf8",
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
});
|
|
|
|
return [...new Set(output.split("\n").filter(Boolean))]
|
|
.filter(isProductionSourceFile)
|
|
.sort();
|
|
}
|
|
|
|
function sourceFileKind(relPath) {
|
|
return relPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
}
|
|
|
|
// Map a tracked path to its owning workspace package directory (best-effort):
|
|
// the path segment immediately before the first `src/` segment, qualified by
|
|
// its parent so nested products (`packages/feed/packages/agents`) stay distinct.
|
|
function packageOf(relPath) {
|
|
const parts = relPath.split("/");
|
|
const srcIdx = parts.indexOf("src");
|
|
if (srcIdx <= 0) return parts[0] ?? relPath;
|
|
return parts.slice(0, srcIdx).join("/");
|
|
}
|
|
|
|
function normalizeTypeText(text) {
|
|
return text.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function propertyNameText(name, sourceFile) {
|
|
if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text;
|
|
if (ts.isNumericLiteral(name)) return String(name.text);
|
|
return name.getText(sourceFile);
|
|
}
|
|
|
|
function unwrapExpression(node) {
|
|
let current = node;
|
|
while (
|
|
ts.isParenthesizedExpression(current) ||
|
|
ts.isAsExpression(current) ||
|
|
ts.isSatisfiesExpression?.(current) ||
|
|
ts.isTypeAssertionExpression?.(current)
|
|
) {
|
|
current = current.expression;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
// Collect the property signature set of an interface/type-literal declaration.
|
|
// Returns null for declarations without a property bag (unions, aliases to
|
|
// other names, enums) so they don't enter the shape-comparison passes.
|
|
function propertySignatures(node) {
|
|
let members;
|
|
if (ts.isInterfaceDeclaration(node)) {
|
|
members = node.members;
|
|
} else if (
|
|
ts.isTypeAliasDeclaration(node) &&
|
|
ts.isTypeLiteralNode(node.type)
|
|
) {
|
|
members = node.type.members;
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
const props = new Map();
|
|
for (const member of members) {
|
|
if (!ts.isPropertySignature(member) || !member.name) continue;
|
|
const name = propertyNameText(member.name, member.getSourceFile());
|
|
const typeText = member.type
|
|
? normalizeTypeText(member.type.getText())
|
|
: "any";
|
|
props.set(name, typeText);
|
|
}
|
|
return props;
|
|
}
|
|
|
|
function isExported(node) {
|
|
const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
|
return Boolean(mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
|
|
}
|
|
|
|
function declKind(node) {
|
|
if (ts.isInterfaceDeclaration(node)) return "interface";
|
|
if (ts.isTypeAliasDeclaration(node)) return "type";
|
|
if (ts.isEnumDeclaration(node)) return "enum";
|
|
return null;
|
|
}
|
|
|
|
// Extract the string-literal member list of a `type X = "a" | "b" | ...` alias.
|
|
// Returns null unless EVERY union member is a string literal (a closed string
|
|
// domain). A single-literal alias (`type X = "a"`) is also returned; the
|
|
// MIN_LITERAL_MEMBERS threshold decides later whether it is interesting.
|
|
function stringUnionMembers(node) {
|
|
if (!ts.isTypeAliasDeclaration(node)) return null;
|
|
const literals = [];
|
|
const collect = (typeNode) => {
|
|
if (
|
|
ts.isLiteralTypeNode(typeNode) &&
|
|
ts.isStringLiteral(typeNode.literal)
|
|
) {
|
|
literals.push(typeNode.literal.text);
|
|
return true;
|
|
}
|
|
return false;
|
|
};
|
|
const t = node.type;
|
|
if (ts.isUnionTypeNode(t)) {
|
|
for (const member of t.types) {
|
|
if (!collect(member)) return null;
|
|
}
|
|
} else if (!collect(t)) {
|
|
return null;
|
|
}
|
|
return literals;
|
|
}
|
|
|
|
// Value set of a TS `enum` (string/number initializers; bare members fall back
|
|
// to their declared name).
|
|
function enumMemberValues(node) {
|
|
if (!ts.isEnumDeclaration(node)) return null;
|
|
const values = [];
|
|
for (const member of node.members) {
|
|
const init = member.initializer;
|
|
if (init && (ts.isStringLiteral(init) || ts.isNumericLiteral(init))) {
|
|
values.push(String(init.text));
|
|
} else {
|
|
values.push(member.name.getText());
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
// Value set of an `as const` object literal whose values are all string/number
|
|
// literals — the enum-like pattern used across the repo (SETUP_ERROR_CODES,
|
|
// ChannelType, ...). Returns `{ name, values }` or null.
|
|
function constEnumLikeMembers(node) {
|
|
if (!ts.isVariableStatement(node)) return null;
|
|
const decls = node.declarationList.declarations;
|
|
if (decls.length !== 1) return null;
|
|
const decl = decls[0];
|
|
if (!decl.name || !ts.isIdentifier(decl.name) || !decl.initializer) {
|
|
return null;
|
|
}
|
|
const init = decl.initializer;
|
|
if (
|
|
!ts.isAsExpression(init) ||
|
|
!ts.isTypeReferenceNode(init.type) ||
|
|
init.type.typeName.getText() !== "const" ||
|
|
!ts.isObjectLiteralExpression(init.expression) ||
|
|
init.expression.properties.length === 0
|
|
) {
|
|
return null;
|
|
}
|
|
const values = [];
|
|
for (const prop of init.expression.properties) {
|
|
if (!ts.isPropertyAssignment(prop)) return null;
|
|
const v = prop.initializer;
|
|
if (ts.isStringLiteral(v) || ts.isNumericLiteral(v)) {
|
|
values.push(String(v.text));
|
|
} else {
|
|
return null; // not a pure literal map → not enum-like
|
|
}
|
|
}
|
|
return { name: decl.name.getText(), values };
|
|
}
|
|
|
|
function collectZodBindings(sourceFile) {
|
|
const namespaces = new Set();
|
|
const objectFunctions = new Set();
|
|
|
|
for (const statement of sourceFile.statements) {
|
|
if (
|
|
!ts.isImportDeclaration(statement) ||
|
|
!ts.isStringLiteral(statement.moduleSpecifier) ||
|
|
statement.moduleSpecifier.text !== "zod"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const clause = statement.importClause;
|
|
if (!clause) continue;
|
|
if (clause.name) namespaces.add(clause.name.text);
|
|
|
|
const bindings = clause.namedBindings;
|
|
if (bindings && ts.isNamespaceImport(bindings)) {
|
|
namespaces.add(bindings.name.text);
|
|
} else if (bindings && ts.isNamedImports(bindings)) {
|
|
for (const element of bindings.elements) {
|
|
const imported = element.propertyName?.text ?? element.name.text;
|
|
if (imported === "z") namespaces.add(element.name.text);
|
|
if (imported === "object") objectFunctions.add(element.name.text);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { namespaces, objectFunctions };
|
|
}
|
|
|
|
function isZodObjectCall(node, zodBindings) {
|
|
if (!ts.isCallExpression(node)) return false;
|
|
const expr = node.expression;
|
|
if (ts.isIdentifier(expr)) {
|
|
return zodBindings.objectFunctions.has(expr.text);
|
|
}
|
|
return (
|
|
ts.isPropertyAccessExpression(expr) &&
|
|
expr.name.text === "object" &&
|
|
ts.isIdentifier(expr.expression) &&
|
|
zodBindings.namespaces.has(expr.expression.text)
|
|
);
|
|
}
|
|
|
|
function objectLiteralKeys(node, sourceFile) {
|
|
if (!ts.isObjectLiteralExpression(node)) return null;
|
|
const props = new Map();
|
|
for (const prop of node.properties) {
|
|
if (ts.isSpreadAssignment(prop)) return null;
|
|
if (ts.isPropertyAssignment(prop) && prop.name) {
|
|
props.set(propertyNameText(prop.name, sourceFile), "schema");
|
|
} else if (ts.isShorthandPropertyAssignment(prop)) {
|
|
props.set(prop.name.text, "schema");
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return props;
|
|
}
|
|
|
|
function literalStringValue(node) {
|
|
const value = unwrapExpression(node);
|
|
return ts.isStringLiteral(value) ? value.text : null;
|
|
}
|
|
|
|
function propertyNamed(objectLiteral, name, sourceFile) {
|
|
return objectLiteral.properties.find(
|
|
(prop) =>
|
|
ts.isPropertyAssignment(prop) &&
|
|
prop.name &&
|
|
propertyNameText(prop.name, sourceFile) === name,
|
|
);
|
|
}
|
|
|
|
function jsonSchemaObjectProperties(node, sourceFile) {
|
|
const objectLiteral = unwrapExpression(node);
|
|
if (!ts.isObjectLiteralExpression(objectLiteral)) return null;
|
|
|
|
const typeProp = propertyNamed(objectLiteral, "type", sourceFile);
|
|
if (!typeProp || literalStringValue(typeProp.initializer) !== "object") {
|
|
return null;
|
|
}
|
|
|
|
const propertiesProp = propertyNamed(objectLiteral, "properties", sourceFile);
|
|
if (!propertiesProp) return null;
|
|
return objectLiteralKeys(
|
|
unwrapExpression(propertiesProp.initializer),
|
|
sourceFile,
|
|
);
|
|
}
|
|
|
|
function schemaOwnerName(node, sourceFile) {
|
|
let current = node;
|
|
let parent = node.parent;
|
|
while (parent) {
|
|
if (
|
|
ts.isPropertyAccessExpression(parent) ||
|
|
ts.isCallExpression(parent) ||
|
|
ts.isAsExpression(parent) ||
|
|
ts.isSatisfiesExpression?.(parent) ||
|
|
ts.isParenthesizedExpression(parent)
|
|
) {
|
|
current = parent;
|
|
parent = parent.parent;
|
|
continue;
|
|
}
|
|
if (ts.isVariableDeclaration(parent) && parent.initializer === current) {
|
|
return parent.name.getText(sourceFile);
|
|
}
|
|
if (ts.isPropertyAssignment(parent) && parent.initializer === current) {
|
|
return propertyNameText(parent.name, sourceFile);
|
|
}
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function jsonSchemaOwnerName(node, sourceFile) {
|
|
const parent = node.parent;
|
|
if (ts.isVariableDeclaration(parent) && parent.initializer === node) {
|
|
return parent.name.getText(sourceFile);
|
|
}
|
|
if (ts.isPropertyAssignment(parent) && parent.initializer === node) {
|
|
return propertyNameText(parent.name, sourceFile);
|
|
}
|
|
if (
|
|
(ts.isAsExpression(parent) || ts.isSatisfiesExpression?.(parent)) &&
|
|
parent.expression === node
|
|
) {
|
|
return jsonSchemaOwnerName(parent, sourceFile);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Stable, order-independent, de-duplicated identity key for a value set.
|
|
function literalSetKey(values) {
|
|
return [...new Set(values)].sort().join("|");
|
|
}
|
|
|
|
// Parse one source file into the type-declaration and weak-type records it
|
|
// contributes. `text` is provided directly in self-test mode.
|
|
function collectFromSource(sourceText, relPath) {
|
|
const sourceFile = ts.createSourceFile(
|
|
relPath,
|
|
sourceText,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
sourceFileKind(relPath),
|
|
);
|
|
const zodBindings = collectZodBindings(sourceFile);
|
|
|
|
const declarations = [];
|
|
const weakTypes = [];
|
|
const literalSets = [];
|
|
const runtimeSchemas = [];
|
|
|
|
function lineOf(node) {
|
|
return (
|
|
sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line +
|
|
1
|
|
);
|
|
}
|
|
|
|
function snippet(node) {
|
|
return normalizeTypeText(node.getText(sourceFile)).slice(0, 200);
|
|
}
|
|
|
|
function unwrap(node) {
|
|
let current = node;
|
|
while (ts.isParenthesizedExpression(current)) {
|
|
current = current.expression;
|
|
}
|
|
return current;
|
|
}
|
|
|
|
// Surrounding named declaration (for weak-type context), if any.
|
|
function enclosingDeclName(node) {
|
|
let current = node.parent;
|
|
while (current) {
|
|
if (
|
|
(ts.isFunctionDeclaration(current) ||
|
|
ts.isMethodDeclaration(current) ||
|
|
ts.isClassDeclaration(current) ||
|
|
ts.isInterfaceDeclaration(current) ||
|
|
ts.isTypeAliasDeclaration(current) ||
|
|
ts.isVariableDeclaration(current) ||
|
|
ts.isPropertyDeclaration(current)) &&
|
|
current.name
|
|
) {
|
|
return current.name.getText(sourceFile);
|
|
}
|
|
current = current.parent;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function recordWeak(kind, node) {
|
|
weakTypes.push({
|
|
kind,
|
|
file: relPath,
|
|
line: lineOf(node),
|
|
enclosing: enclosingDeclName(node),
|
|
snippet: snippet(node),
|
|
});
|
|
}
|
|
|
|
function recordLiteralSet(name, setKind, values, node) {
|
|
literalSets.push({
|
|
name,
|
|
kind: setKind,
|
|
file: relPath,
|
|
line: lineOf(node),
|
|
package: packageOf(relPath),
|
|
members: values,
|
|
memberKey: literalSetKey(values),
|
|
memberCount: new Set(values).size,
|
|
});
|
|
}
|
|
|
|
function recordRuntimeSchema(name, schemaKind, props, node) {
|
|
if (!name || !props || props.size < MIN_SCHEMA_PROPS) return;
|
|
runtimeSchemas.push({
|
|
name,
|
|
kind: schemaKind,
|
|
file: relPath,
|
|
line: lineOf(node),
|
|
package: packageOf(relPath),
|
|
props: Object.fromEntries(props),
|
|
propCount: props.size,
|
|
});
|
|
}
|
|
|
|
function visit(node) {
|
|
const kind = declKind(node);
|
|
if (kind && node.name) {
|
|
const props = propertySignatures(node);
|
|
declarations.push({
|
|
name: node.name.getText(sourceFile),
|
|
kind,
|
|
exported: isExported(node),
|
|
file: relPath,
|
|
line: lineOf(node),
|
|
package: packageOf(relPath),
|
|
props: props ? Object.fromEntries(props) : null,
|
|
propCount: props ? props.size : 0,
|
|
});
|
|
}
|
|
|
|
// Closed literal domains: string-literal unions, `enum`s, and `as const`
|
|
// enum-like maps. Clustered by value set (class 4) regardless of name.
|
|
if (node.name) {
|
|
const stringUnion = stringUnionMembers(node);
|
|
if (stringUnion) {
|
|
recordLiteralSet(
|
|
node.name.getText(sourceFile),
|
|
"string-union",
|
|
stringUnion,
|
|
node,
|
|
);
|
|
}
|
|
const enumValues = enumMemberValues(node);
|
|
if (enumValues) {
|
|
recordLiteralSet(
|
|
node.name.getText(sourceFile),
|
|
"enum",
|
|
enumValues,
|
|
node,
|
|
);
|
|
}
|
|
}
|
|
const constEnum = constEnumLikeMembers(node);
|
|
if (constEnum) {
|
|
recordLiteralSet(constEnum.name, "as-const", constEnum.values, node);
|
|
}
|
|
|
|
if (isZodObjectCall(node, zodBindings)) {
|
|
const arg = node.arguments[0];
|
|
const props = arg ? objectLiteralKeys(arg, sourceFile) : null;
|
|
recordRuntimeSchema(
|
|
schemaOwnerName(node, sourceFile),
|
|
"zod-object",
|
|
props,
|
|
node,
|
|
);
|
|
}
|
|
|
|
if (ts.isObjectLiteralExpression(node)) {
|
|
const props = jsonSchemaObjectProperties(node, sourceFile);
|
|
recordRuntimeSchema(
|
|
jsonSchemaOwnerName(node, sourceFile),
|
|
"json-schema-object",
|
|
props,
|
|
node,
|
|
);
|
|
}
|
|
|
|
if (ts.isAsExpression(node)) {
|
|
if (node.type.kind === ts.SyntaxKind.AnyKeyword) {
|
|
recordWeak("asAny", node);
|
|
}
|
|
const expression = unwrap(node.expression);
|
|
if (
|
|
ts.isAsExpression(expression) &&
|
|
expression.type.kind === ts.SyntaxKind.UnknownKeyword
|
|
) {
|
|
recordWeak("asUnknownAs", node);
|
|
}
|
|
}
|
|
|
|
// Explicit `: any` type annotation — but NOT the AnyKeyword that is the
|
|
// `.type` of an `as any` cast (counted above) to avoid double counting.
|
|
if (node.kind === ts.SyntaxKind.AnyKeyword) {
|
|
const parent = node.parent;
|
|
const isAsAnyType =
|
|
parent && ts.isAsExpression(parent) && parent.type === node;
|
|
if (!isAsAnyType) {
|
|
recordWeak("explicitAny", node);
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(node, visit);
|
|
}
|
|
|
|
visit(sourceFile);
|
|
return { declarations, weakTypes, literalSets, runtimeSchemas };
|
|
}
|
|
|
|
function jaccard(aKeys, bKeys) {
|
|
const a = new Set(aKeys);
|
|
const b = new Set(bKeys);
|
|
let inter = 0;
|
|
for (const key of a) {
|
|
if (b.has(key)) inter += 1;
|
|
}
|
|
const union = a.size + b.size - inter;
|
|
return union === 0 ? 0 : inter / union;
|
|
}
|
|
|
|
function propSignatureKeys(props) {
|
|
// `name:typeText` so two types that share a key but disagree on its type are
|
|
// less similar than two that agree.
|
|
return Object.entries(props).map(([name, type]) => `${name}:${type}`);
|
|
}
|
|
|
|
function loadAllowlist() {
|
|
if (!existsSync(ALLOWLIST_PATH)) return new Set();
|
|
const data = JSON.parse(readFileSync(ALLOWLIST_PATH, "utf8"));
|
|
const set = new Set();
|
|
for (const entry of data.entries ?? []) {
|
|
if (entry.pairKey) set.add(entry.pairKey);
|
|
if (entry.name) set.add(`name:${entry.name}`);
|
|
if (entry.memberKey) set.add(`literalSet:${entry.memberKey}`);
|
|
if (entry.schemaPairKey) set.add(`schemaPair:${entry.schemaPairKey}`);
|
|
}
|
|
return set;
|
|
}
|
|
|
|
// Stable, order-independent key for a reviewed pair of declarations.
|
|
function pairKey(a, b) {
|
|
const left = `${a.file}#${a.name}`;
|
|
const right = `${b.file}#${b.name}`;
|
|
return [left, right].sort().join(" <=> ");
|
|
}
|
|
|
|
function schemaTypePairKey(schema, decl) {
|
|
return `schema:${schema.file}#${schema.name} <=> type:${decl.file}#${decl.name}`;
|
|
}
|
|
|
|
function buildSameNameClusters(declarations, allowlist) {
|
|
const byName = new Map();
|
|
for (const decl of declarations) {
|
|
if (!byName.has(decl.name)) byName.set(decl.name, []);
|
|
byName.get(decl.name).push(decl);
|
|
}
|
|
|
|
const clusters = [];
|
|
for (const [name, decls] of byName) {
|
|
if (allowlist.has(`name:${name}`)) continue;
|
|
const packages = new Set(decls.map((d) => d.package));
|
|
if (decls.length < 2 || packages.size < 2) continue;
|
|
|
|
// Confidence rises with how many independent packages redeclare the name,
|
|
// saturating at 5 packages.
|
|
const confidence = Math.min(1, (packages.size - 1) / 4);
|
|
clusters.push({
|
|
name,
|
|
packageCount: packages.size,
|
|
declarationCount: decls.length,
|
|
confidence: Number(confidence.toFixed(3)),
|
|
locations: decls.map((d) => ({
|
|
file: d.file,
|
|
line: d.line,
|
|
kind: d.kind,
|
|
exported: d.exported,
|
|
propCount: d.propCount,
|
|
})),
|
|
});
|
|
}
|
|
return clusters.sort(
|
|
(a, b) =>
|
|
b.packageCount - a.packageCount ||
|
|
b.declarationCount - a.declarationCount ||
|
|
a.name.localeCompare(b.name),
|
|
);
|
|
}
|
|
|
|
// Cluster literal-sets (string-unions / enums / `as const` maps) by their
|
|
// VALUE set — two declarations are the same closed domain if they enumerate the
|
|
// same values, regardless of name or kind. Only sets with ≥ MIN_LITERAL_MEMBERS
|
|
// members spanning ≥2 packages are reported (#10201).
|
|
function buildLiteralSetClusters(literalSets, allowlist) {
|
|
const byKey = new Map();
|
|
for (const set of literalSets) {
|
|
if (set.memberCount < MIN_LITERAL_MEMBERS) continue;
|
|
if (!byKey.has(set.memberKey)) byKey.set(set.memberKey, []);
|
|
byKey.get(set.memberKey).push(set);
|
|
}
|
|
|
|
const clusters = [];
|
|
for (const [memberKey, sets] of byKey) {
|
|
if (allowlist.has(`literalSet:${memberKey}`)) continue;
|
|
const packages = new Set(sets.map((s) => s.package));
|
|
if (sets.length < 2 || packages.size < 2) continue;
|
|
|
|
// Confidence rises with how many independent packages share the domain,
|
|
// saturating at 5 packages (mirrors the same-name cluster scoring).
|
|
const confidence = Math.min(1, (packages.size - 1) / 4);
|
|
clusters.push({
|
|
memberKey,
|
|
members: memberKey.split("|"),
|
|
memberCount: memberKey.split("|").length,
|
|
names: [...new Set(sets.map((s) => s.name))].sort(),
|
|
packageCount: packages.size,
|
|
declarationCount: sets.length,
|
|
confidence: Number(confidence.toFixed(3)),
|
|
locations: sets.map((s) => ({
|
|
name: s.name,
|
|
kind: s.kind,
|
|
file: s.file,
|
|
line: s.line,
|
|
})),
|
|
});
|
|
}
|
|
return clusters.sort(
|
|
(a, b) =>
|
|
b.packageCount - a.packageCount ||
|
|
b.declarationCount - a.declarationCount ||
|
|
a.memberKey.localeCompare(b.memberKey),
|
|
);
|
|
}
|
|
|
|
// Blocking: a workspace has tens of thousands of type declarations, so an
|
|
// all-pairs O(n²) comparison is intractable. Index every shaped declaration by
|
|
// its property keys, then only compare pairs that co-occur in at least one
|
|
// (non-ubiquitous) key bucket. A subset/near-duplicate pair shares most of its
|
|
// keys, so it is guaranteed to co-occur in some bucket; pairs that share only a
|
|
// ubiquitous key (`id`, `name`, …) would score below threshold anyway.
|
|
const MAX_BUCKET = 400;
|
|
|
|
function candidatePairIndices(shaped) {
|
|
const invIndex = new Map();
|
|
for (let i = 0; i < shaped.length; i += 1) {
|
|
for (const key of Object.keys(shaped[i].props)) {
|
|
let bucket = invIndex.get(key);
|
|
if (!bucket) {
|
|
bucket = [];
|
|
invIndex.set(key, bucket);
|
|
}
|
|
bucket.push(i);
|
|
}
|
|
}
|
|
|
|
const pairs = new Set();
|
|
for (const bucket of invIndex.values()) {
|
|
if (bucket.length < 2 || bucket.length > MAX_BUCKET) continue;
|
|
for (let x = 0; x < bucket.length; x += 1) {
|
|
for (let y = x + 1; y < bucket.length; y += 1) {
|
|
const i = bucket[x];
|
|
const j = bucket[y];
|
|
pairs.add(i < j ? i * shaped.length + j : j * shaped.length + i);
|
|
}
|
|
}
|
|
}
|
|
return pairs;
|
|
}
|
|
|
|
function buildShapeCandidates(declarations, allowlist) {
|
|
const shaped = declarations.filter(
|
|
(d) => d.props && d.propCount >= MIN_PROPS,
|
|
);
|
|
const subsets = [];
|
|
const nearDuplicates = [];
|
|
|
|
const n = shaped.length;
|
|
for (const encoded of candidatePairIndices(shaped)) {
|
|
const i = Math.floor(encoded / n);
|
|
const j = encoded % n;
|
|
{
|
|
const a = shaped[i];
|
|
const b = shaped[j];
|
|
// Skip identical-name same-file (re-parse artifacts) and same declaration.
|
|
if (a.file === b.file && a.name === b.name) continue;
|
|
|
|
const key = pairKey(a, b);
|
|
if (allowlist.has(key)) continue;
|
|
|
|
const aKeys = Object.keys(a.props);
|
|
const bKeys = Object.keys(b.props);
|
|
const aSet = new Set(aKeys);
|
|
const bSet = new Set(bKeys);
|
|
|
|
const aInB = aKeys.every((k) => bSet.has(k));
|
|
const bInA = bKeys.every((k) => aSet.has(k));
|
|
|
|
if ((aInB || bInA) && aKeys.length !== bKeys.length) {
|
|
const sub = aInB ? a : b;
|
|
const sup = aInB ? b : a;
|
|
const confidence = Number((sub.propCount / sup.propCount).toFixed(3));
|
|
subsets.push({
|
|
pairKey: key,
|
|
subset: {
|
|
name: sub.name,
|
|
file: sub.file,
|
|
line: sub.line,
|
|
propCount: sub.propCount,
|
|
},
|
|
superset: {
|
|
name: sup.name,
|
|
file: sup.file,
|
|
line: sup.line,
|
|
propCount: sup.propCount,
|
|
},
|
|
sharedKeys: sub.propCount,
|
|
confidence,
|
|
action: "extends / Pick / Omit",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const score = jaccard(
|
|
propSignatureKeys(a.props),
|
|
propSignatureKeys(b.props),
|
|
);
|
|
// Identical shapes (aInB && bInA, equal length) are the strongest
|
|
// near-duplicate signal — keep them here; only strict subset/superset
|
|
// pairs (unequal length) are diverted to the subsets bucket above.
|
|
if (score >= NEAR_DUP_THRESHOLD) {
|
|
nearDuplicates.push({
|
|
pairKey: key,
|
|
a: {
|
|
name: a.name,
|
|
file: a.file,
|
|
line: a.line,
|
|
propCount: a.propCount,
|
|
},
|
|
b: {
|
|
name: b.name,
|
|
file: b.file,
|
|
line: b.line,
|
|
propCount: b.propCount,
|
|
},
|
|
similarity: Number(score.toFixed(3)),
|
|
confidence: Number(score.toFixed(3)),
|
|
action: "merge / share via @elizaos/core",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
subsets.sort(
|
|
(a, b) => b.confidence - a.confidence || b.sharedKeys - a.sharedKeys,
|
|
);
|
|
nearDuplicates.sort((a, b) => b.similarity - a.similarity);
|
|
return { subsets, nearDuplicates };
|
|
}
|
|
|
|
function buildRuntimeSchemaMatches(runtimeSchemas, declarations, allowlist) {
|
|
const exportedTypes = declarations.filter(
|
|
(d) => d.exported && d.props && d.propCount >= MIN_SCHEMA_PROPS,
|
|
);
|
|
const byKey = new Map();
|
|
for (let i = 0; i < exportedTypes.length; i += 1) {
|
|
for (const key of Object.keys(exportedTypes[i].props)) {
|
|
let bucket = byKey.get(key);
|
|
if (!bucket) {
|
|
bucket = [];
|
|
byKey.set(key, bucket);
|
|
}
|
|
bucket.push(i);
|
|
}
|
|
}
|
|
|
|
const matches = [];
|
|
for (const schema of runtimeSchemas.filter(
|
|
(s) => s.propCount >= MIN_SCHEMA_PROPS,
|
|
)) {
|
|
const candidateIndices = new Set();
|
|
for (const key of Object.keys(schema.props)) {
|
|
const bucket = byKey.get(key);
|
|
if (!bucket || bucket.length > MAX_BUCKET) continue;
|
|
for (const idx of bucket) candidateIndices.add(idx);
|
|
}
|
|
|
|
const schemaKeys = Object.keys(schema.props);
|
|
const schemaKeySet = new Set(schemaKeys);
|
|
for (const idx of candidateIndices) {
|
|
const typeDecl = exportedTypes[idx];
|
|
const pair = schemaTypePairKey(schema, typeDecl);
|
|
if (allowlist.has(`schemaPair:${pair}`)) continue;
|
|
|
|
const typeKeys = Object.keys(typeDecl.props);
|
|
const typeKeySet = new Set(typeKeys);
|
|
const shared = schemaKeys.filter((key) => typeKeySet.has(key)).sort();
|
|
if (shared.length < MIN_SCHEMA_PROPS) continue;
|
|
|
|
const overlap = jaccard(schemaKeys, typeKeys);
|
|
const exact =
|
|
shared.length === schemaKeys.length &&
|
|
shared.length === typeKeys.length;
|
|
if (!exact && overlap < SCHEMA_TYPE_OVERLAP_THRESHOLD) continue;
|
|
|
|
const samePackage = schema.package === typeDecl.package;
|
|
const confidence = exact
|
|
? samePackage
|
|
? 0.95
|
|
: 0.9
|
|
: Number((overlap * (samePackage ? 0.95 : 0.9)).toFixed(3));
|
|
const relation = exact
|
|
? "exact-key-match"
|
|
: schemaKeys.every((key) => typeKeySet.has(key))
|
|
? "schema-subset-of-type"
|
|
: typeKeys.every((key) => schemaKeySet.has(key))
|
|
? "type-subset-of-schema"
|
|
: "high-key-overlap";
|
|
const reason =
|
|
relation === "exact-key-match"
|
|
? "Runtime schema keys exactly match an exported TypeScript object type."
|
|
: `Runtime schema and exported type share ${shared.length} keys with ${Number(overlap.toFixed(3))} Jaccard overlap.`;
|
|
|
|
matches.push({
|
|
schemaPairKey: pair,
|
|
relation,
|
|
schema: {
|
|
name: schema.name,
|
|
kind: schema.kind,
|
|
file: schema.file,
|
|
line: schema.line,
|
|
package: schema.package,
|
|
propCount: schema.propCount,
|
|
},
|
|
type: {
|
|
name: typeDecl.name,
|
|
kind: typeDecl.kind,
|
|
file: typeDecl.file,
|
|
line: typeDecl.line,
|
|
package: typeDecl.package,
|
|
propCount: typeDecl.propCount,
|
|
},
|
|
sharedKeys: shared,
|
|
sharedKeyCount: shared.length,
|
|
keyOverlap: Number(overlap.toFixed(3)),
|
|
confidence: Number(confidence.toFixed(3)),
|
|
reason,
|
|
action: "review shared type + runtime validation ownership",
|
|
});
|
|
}
|
|
}
|
|
|
|
return matches.sort(
|
|
(a, b) =>
|
|
b.confidence - a.confidence ||
|
|
b.sharedKeyCount - a.sharedKeyCount ||
|
|
a.schema.file.localeCompare(b.schema.file) ||
|
|
a.type.file.localeCompare(b.type.file),
|
|
);
|
|
}
|
|
|
|
function summarizeWeakTypes(weakTypes) {
|
|
const counts = { asUnknownAs: 0, asAny: 0, explicitAny: 0 };
|
|
for (const item of weakTypes) {
|
|
counts[item.kind] = (counts[item.kind] ?? 0) + 1;
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
function renderMarkdown(report) {
|
|
const {
|
|
generatedAt,
|
|
filesScanned,
|
|
declarationCount,
|
|
sameName,
|
|
subsets,
|
|
nearDuplicates,
|
|
literalSetClusters,
|
|
runtimeSchemaMatches,
|
|
weakTypeCounts,
|
|
} = report;
|
|
const lines = [];
|
|
lines.push("# Type-duplication candidate report (#10195, extended #10201)");
|
|
lines.push("");
|
|
lines.push(
|
|
"Generated by `node packages/scripts/type-duplication-audit.mjs` " +
|
|
"(alias `bun run audit:type-duplication`). This is a **human-review " +
|
|
"candidate-finder**, not a gate. Full machine output (gitignored): " +
|
|
"`reports/type-duplication.json`.",
|
|
);
|
|
lines.push("");
|
|
lines.push(`- Generated: ${generatedAt}`);
|
|
lines.push(`- Production source files scanned: ${filesScanned}`);
|
|
lines.push(`- Type declarations enumerated: ${declarationCount}`);
|
|
lines.push("");
|
|
lines.push("## Candidate counts");
|
|
lines.push("");
|
|
lines.push("| Class | Count |");
|
|
lines.push("| --- | --- |");
|
|
lines.push(`| Same-name, multi-package | ${sameName.length} |`);
|
|
lines.push(`| Subset / superset | ${subsets.length} |`);
|
|
lines.push(
|
|
`| Structural near-duplicate (Jaccard ≥ ${NEAR_DUP_THRESHOLD}) | ${nearDuplicates.length} |`,
|
|
);
|
|
lines.push(
|
|
`| Literal-set duplicate (≥ ${MIN_LITERAL_MEMBERS} members, multi-package) | ${literalSetClusters.length} |`,
|
|
);
|
|
lines.push(
|
|
`| Runtime schema ↔ exported type (key overlap ≥ ${SCHEMA_TYPE_OVERLAP_THRESHOLD}) | ${runtimeSchemaMatches.length} |`,
|
|
);
|
|
lines.push("");
|
|
lines.push("## Weak-type inventory (actionable casts only)");
|
|
lines.push("");
|
|
lines.push("| Kind | Count |");
|
|
lines.push("| --- | --- |");
|
|
lines.push(`| \`as unknown as\` | ${weakTypeCounts.asUnknownAs} |`);
|
|
lines.push(`| \`as any\` | ${weakTypeCounts.asAny} |`);
|
|
lines.push(`| explicit \`: any\` | ${weakTypeCounts.explicitAny} |`);
|
|
lines.push("");
|
|
lines.push(
|
|
"_Bare `: unknown` is intentionally not inventoried — most are legitimate " +
|
|
"boundary types (#10195)._",
|
|
);
|
|
lines.push("");
|
|
lines.push("## Top same-name, multi-package clusters");
|
|
lines.push("");
|
|
lines.push("| Type | Packages | Declarations | Confidence | Files |");
|
|
lines.push("| --- | --- | --- | --- | --- |");
|
|
for (const cluster of sameName.slice(0, 30)) {
|
|
const files = cluster.locations
|
|
.map((l) => `\`${l.file}:${l.line}\``)
|
|
.join("<br>");
|
|
lines.push(
|
|
`| \`${cluster.name}\` | ${cluster.packageCount} | ${cluster.declarationCount} | ${cluster.confidence} | ${files} |`,
|
|
);
|
|
}
|
|
lines.push("");
|
|
lines.push("## Top subset / superset candidates");
|
|
lines.push("");
|
|
lines.push("| Subset | Superset | Shared keys | Confidence | Action |");
|
|
lines.push("| --- | --- | --- | --- | --- |");
|
|
for (const cand of subsets.slice(0, 20)) {
|
|
lines.push(
|
|
`| \`${cand.subset.name}\` (\`${cand.subset.file}:${cand.subset.line}\`) | ` +
|
|
`\`${cand.superset.name}\` (\`${cand.superset.file}:${cand.superset.line}\`) | ` +
|
|
`${cand.sharedKeys} | ${cand.confidence} | ${cand.action} |`,
|
|
);
|
|
}
|
|
lines.push("");
|
|
lines.push("## Top structural near-duplicate candidates");
|
|
lines.push("");
|
|
lines.push("| Type A | Type B | Similarity | Action |");
|
|
lines.push("| --- | --- | --- | --- |");
|
|
for (const cand of nearDuplicates.slice(0, 20)) {
|
|
lines.push(
|
|
`| \`${cand.a.name}\` (\`${cand.a.file}:${cand.a.line}\`) | ` +
|
|
`\`${cand.b.name}\` (\`${cand.b.file}:${cand.b.line}\`) | ` +
|
|
`${cand.similarity} | ${cand.action} |`,
|
|
);
|
|
}
|
|
lines.push("");
|
|
lines.push("## Top literal-set duplicate candidates");
|
|
lines.push("");
|
|
lines.push(
|
|
"Closed string/number domains (string-literal unions, `enum`s, `as const` " +
|
|
"maps) that enumerate the **same value set** across ≥2 packages — even " +
|
|
"under different names. Strong consolidation candidates: a shared union " +
|
|
"in `@elizaos/core` removes drift when a new member is added.",
|
|
);
|
|
lines.push("");
|
|
lines.push("| Names | Members | Packages | Decls | Confidence | Locations |");
|
|
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
for (const cluster of literalSetClusters.slice(0, 25)) {
|
|
const names = cluster.names.map((n) => `\`${n}\``).join(", ");
|
|
const members = `\`${cluster.members.join("\\|")}\``;
|
|
const locs = cluster.locations
|
|
.map((l) => `\`${l.file}:${l.line}\` (${l.kind})`)
|
|
.join("<br>");
|
|
lines.push(
|
|
`| ${names} | ${members} | ${cluster.packageCount} | ${cluster.declarationCount} | ${cluster.confidence} | ${locs} |`,
|
|
);
|
|
}
|
|
lines.push("");
|
|
lines.push("## Top runtime schema ↔ exported type candidates");
|
|
lines.push("");
|
|
lines.push(
|
|
'Zod `z.object(...)` schemas and JSON-schema-like `{ type: "object", ' +
|
|
"properties: ... }` declarations whose property keys exactly match or " +
|
|
"strongly overlap exported TypeScript object types. These are review " +
|
|
"candidates for pairing shared DTOs with runtime validation — not proof " +
|
|
"that ownership is identical.",
|
|
);
|
|
lines.push("");
|
|
lines.push(
|
|
"| Runtime schema | Exported type | Shared keys | Key overlap | Confidence | Reason |",
|
|
);
|
|
lines.push("| --- | --- | --- | --- | --- | --- |");
|
|
for (const cand of runtimeSchemaMatches.slice(0, 25)) {
|
|
const shared = `\`${cand.sharedKeys.join("\\|")}\``;
|
|
lines.push(
|
|
`| \`${cand.schema.name}\` (${cand.schema.kind}, \`${cand.schema.file}:${cand.schema.line}\`) | ` +
|
|
`\`${cand.type.name}\` (${cand.type.kind}, \`${cand.type.file}:${cand.type.line}\`) | ` +
|
|
`${shared} | ${cand.keyOverlap} | ${cand.confidence} | ${cand.reason} |`,
|
|
);
|
|
}
|
|
lines.push("");
|
|
lines.push("## Review workflow");
|
|
lines.push("");
|
|
lines.push(
|
|
"1. Triage each cluster: **merge** (genuinely one concept → share via " +
|
|
"`@elizaos/core`), **`extends`/`Pick`/`Omit`** (subset/superset), or " +
|
|
"**rename** (genuinely distinct concepts that collide by name).",
|
|
);
|
|
lines.push(
|
|
"2. Factor high-confidence duplicates by hand — no auto-rewrite. For any " +
|
|
"removed cast, lower the `type-safety-ratchet` baseline.",
|
|
);
|
|
lines.push(
|
|
"3. Record reviewed-but-kept-separate findings in " +
|
|
"`packages/scripts/type-duplication-audit.allowlist.json` with a written " +
|
|
"`reason` so re-runs stay low-noise: `name` suppresses a same-name " +
|
|
"cluster, `pairKey` a subset/near-duplicate pair, `memberKey` a " +
|
|
"literal-set cluster (the `a|b|c` value key from the report), and " +
|
|
"`schemaPairKey` a runtime-schema/type pair.",
|
|
);
|
|
lines.push(
|
|
"4. Triage guidance + the accepted/rejected family log live in " +
|
|
"[`packages/scripts/type-duplication-triage.md`]" +
|
|
"(../../packages/scripts/type-duplication-triage.md).",
|
|
);
|
|
lines.push("");
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
// ── Advisory baseline (#10201) ────────────────────────────────────────────
|
|
// The baseline records the per-class candidate counts after the first
|
|
// human-reviewed consolidation so future drift is visible without blocking the
|
|
// build. It is intentionally count-only (not a per-finding ratchet): the
|
|
// finder is advisory, and many new local types are legitimate.
|
|
|
|
const COUNT_LABELS = {
|
|
sameName: "same-name multi-package clusters",
|
|
subsets: "subset/superset candidates",
|
|
nearDuplicates: "structural near-duplicates",
|
|
literalSetClusters: "literal-set duplicates",
|
|
runtimeSchemaMatches: "runtime schema ↔ exported type matches",
|
|
weakAsUnknownAs: "weak: as unknown as",
|
|
weakAsAny: "weak: as any",
|
|
weakExplicitAny: "weak: explicit : any",
|
|
};
|
|
|
|
function countsFromReport(report) {
|
|
return {
|
|
sameName: report.sameName.length,
|
|
subsets: report.subsets.length,
|
|
nearDuplicates: report.nearDuplicates.length,
|
|
literalSetClusters: report.literalSetClusters.length,
|
|
runtimeSchemaMatches: report.runtimeSchemaMatches.length,
|
|
weakAsUnknownAs: report.weakTypeCounts.asUnknownAs,
|
|
weakAsAny: report.weakTypeCounts.asAny,
|
|
weakExplicitAny: report.weakTypeCounts.explicitAny,
|
|
};
|
|
}
|
|
|
|
function baselinePayload(report) {
|
|
return {
|
|
schema: "eliza_type_duplication_baseline_v1",
|
|
updatedAt: report.generatedAt,
|
|
thresholds: report.thresholds,
|
|
filesScanned: report.filesScanned,
|
|
counts: countsFromReport(report),
|
|
};
|
|
}
|
|
|
|
function loadBaselineFile() {
|
|
if (!existsSync(BASELINE_PATH)) return null;
|
|
return JSON.parse(readFileSync(BASELINE_PATH, "utf8"));
|
|
}
|
|
|
|
// Compare current counts to the baseline. Returns per-metric drift; `increased`
|
|
// flags any class that grew (the advisory signal).
|
|
function compareBaseline(counts, baseline) {
|
|
const limits = baseline?.counts ?? {};
|
|
const rows = [];
|
|
let increased = 0;
|
|
for (const key of Object.keys(COUNT_LABELS)) {
|
|
const current = counts[key] ?? 0;
|
|
const base = Number.isInteger(limits[key]) ? limits[key] : null;
|
|
const delta = base === null ? null : current - base;
|
|
if (delta !== null && delta > 0) increased += 1;
|
|
rows.push({ key, current, base, delta });
|
|
}
|
|
return { rows, increased };
|
|
}
|
|
|
|
function printBaselineDrift({ rows, increased }) {
|
|
console.log("[type-duplication-audit] drift vs baseline (advisory):");
|
|
for (const row of rows) {
|
|
const label = COUNT_LABELS[row.key];
|
|
if (row.base === null) {
|
|
console.log(` ? ${label}: ${row.current} (no baseline entry)`);
|
|
continue;
|
|
}
|
|
const arrow = row.delta > 0 ? "▲" : row.delta < 0 ? "▼" : "=";
|
|
const sign = row.delta > 0 ? `+${row.delta}` : String(row.delta);
|
|
console.log(` ${arrow} ${label}: ${row.current} / ${row.base} (${sign})`);
|
|
}
|
|
if (increased > 0) {
|
|
console.log(
|
|
`[type-duplication-audit] ${increased} class(es) grew above baseline — ` +
|
|
"review new duplicates, then `--update-baseline` once triaged.",
|
|
);
|
|
}
|
|
}
|
|
|
|
function runSelfTest() {
|
|
// Synthetic source with a KNOWN duplicate pair (Alpha/Beta share all keys)
|
|
// and a KNOWN distinct pair (Gamma shares nothing with them).
|
|
const sample = `
|
|
export interface Alpha { id: string; name: string; createdAt: number; active: boolean; }
|
|
export interface Beta { id: string; name: string; createdAt: number; active: boolean; }
|
|
export interface Gamma { latitude: number; longitude: number; altitude: number; heading: number; }
|
|
export interface Small { id: string; }
|
|
export interface SubAlpha { id: string; name: string; createdAt: number; }
|
|
const lazyCast = (x) => x as unknown as Alpha;
|
|
const anyCast = (x) => x as any;
|
|
function weak(p: any) { return p; }
|
|
`;
|
|
const { declarations, weakTypes } = collectFromSource(
|
|
sample,
|
|
"pkg/src/self-test.ts",
|
|
);
|
|
const allowlist = new Set();
|
|
const { subsets, nearDuplicates } = buildShapeCandidates(
|
|
declarations,
|
|
allowlist,
|
|
);
|
|
|
|
const alphaBeta = nearDuplicates.find(
|
|
(c) =>
|
|
[c.a.name, c.b.name].includes("Alpha") &&
|
|
[c.a.name, c.b.name].includes("Beta"),
|
|
);
|
|
if (alphaBeta?.similarity !== 1) {
|
|
console.error(
|
|
`[type-duplication-audit] self-test FAILED: identical Alpha/Beta not clustered as near-duplicate (got ${JSON.stringify(alphaBeta)})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// The distinct pair (Gamma vs anything) must NOT appear as a near-duplicate.
|
|
const gammaPaired = nearDuplicates.find(
|
|
(c) => c.a.name === "Gamma" || c.b.name === "Gamma",
|
|
);
|
|
if (gammaPaired) {
|
|
console.error(
|
|
`[type-duplication-audit] self-test FAILED: distinct Gamma was clustered (got ${JSON.stringify(gammaPaired)})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// SubAlpha ⊂ Alpha must surface as a subset candidate.
|
|
const subset = subsets.find(
|
|
(c) => c.subset.name === "SubAlpha" && c.superset.name === "Alpha",
|
|
);
|
|
if (!subset) {
|
|
console.error(
|
|
"[type-duplication-audit] self-test FAILED: SubAlpha ⊂ Alpha not detected as subset",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// The 1-prop `Small` shape must be ignored (below MIN_PROPS).
|
|
const smallPaired = [...subsets, ...nearDuplicates].some((c) =>
|
|
JSON.stringify(c).includes('"Small"'),
|
|
);
|
|
if (smallPaired) {
|
|
console.error(
|
|
"[type-duplication-audit] self-test FAILED: tiny `Small` shape should be ignored",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Allowlist suppression must work.
|
|
const suppressKey = alphaBeta.pairKey;
|
|
const { nearDuplicates: afterAllow } = buildShapeCandidates(
|
|
declarations,
|
|
new Set([suppressKey]),
|
|
);
|
|
if (afterAllow.some((c) => c.pairKey === suppressKey)) {
|
|
console.error(
|
|
"[type-duplication-audit] self-test FAILED: allowlist did not suppress reviewed pair",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Weak-type inventory must catch the cast/any sites and nothing spurious.
|
|
const counts = summarizeWeakTypes(weakTypes);
|
|
if (
|
|
counts.asUnknownAs !== 1 ||
|
|
counts.asAny !== 1 ||
|
|
counts.explicitAny !== 1
|
|
) {
|
|
console.error(
|
|
`[type-duplication-audit] self-test FAILED: weak-type counts off (got ${JSON.stringify(counts)})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Literal-set clustering (class 4) ──────────────────────────────────
|
|
// pkg-a declares the domain as a string-literal union; pkg-b declares the
|
|
// SAME value set as an `enum` (reordered) — they must cluster across kinds.
|
|
// A 2-member set is below MIN_LITERAL_MEMBERS; a single-package set has no
|
|
// peer; both must be ignored.
|
|
const litA = collectFromSource(
|
|
`
|
|
export type StatusA = "idle" | "configuring" | "paired" | "error";
|
|
export type FlagDomain = "on" | "off";
|
|
export type SoloDomain = "x" | "y" | "z";
|
|
`,
|
|
"pkg-a/src/x.ts",
|
|
);
|
|
const litB = collectFromSource(
|
|
`
|
|
export enum StatusB { Configuring = "configuring", Error = "error", Idle = "idle", Paired = "paired" }
|
|
export const FLAGS = { ON: "on", OFF: "off" } as const;
|
|
`,
|
|
"pkg-b/src/y.ts",
|
|
);
|
|
const allSets = [...litA.literalSets, ...litB.literalSets];
|
|
const litClusters = buildLiteralSetClusters(allSets, new Set());
|
|
const STATUS_KEY = "configuring|error|idle|paired";
|
|
|
|
const statusCluster = litClusters.find((c) => c.memberKey === STATUS_KEY);
|
|
if (
|
|
statusCluster?.packageCount !== 2 ||
|
|
!statusCluster.names.includes("StatusA") ||
|
|
!statusCluster.names.includes("StatusB")
|
|
) {
|
|
console.error(
|
|
`[type-duplication-audit] self-test FAILED: cross-kind StatusA(union)/StatusB(enum) literal-set not clustered (got ${JSON.stringify(statusCluster)})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (litClusters.some((c) => c.memberKey === "off|on")) {
|
|
console.error(
|
|
"[type-duplication-audit] self-test FAILED: 2-member literal-set should be below MIN_LITERAL_MEMBERS",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (litClusters.some((c) => c.memberKey === "x|y|z")) {
|
|
console.error(
|
|
"[type-duplication-audit] self-test FAILED: single-package literal-set should not cluster",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const litSuppressed = buildLiteralSetClusters(
|
|
allSets,
|
|
new Set([`literalSet:${STATUS_KEY}`]),
|
|
);
|
|
if (litSuppressed.some((c) => c.memberKey === STATUS_KEY)) {
|
|
console.error(
|
|
"[type-duplication-audit] self-test FAILED: allowlist memberKey did not suppress literal-set cluster",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Runtime schema ↔ exported type matching (class 5) ────────────────
|
|
const schemaSample = collectFromSource(
|
|
`
|
|
import { z } from "zod";
|
|
export const UserDtoSchema = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
active: z.boolean(),
|
|
});
|
|
export interface UserDto {
|
|
id: string;
|
|
name: string;
|
|
active: boolean;
|
|
}
|
|
export const FeatureFlagSchema = {
|
|
type: "object",
|
|
properties: {
|
|
key: { type: "string" },
|
|
enabled: { type: "boolean" },
|
|
owner: { type: "string" },
|
|
},
|
|
} as const;
|
|
export interface FeatureFlag {
|
|
key: string;
|
|
enabled: boolean;
|
|
owner: string;
|
|
}
|
|
export interface Unrelated {
|
|
latitude: number;
|
|
longitude: number;
|
|
altitude: number;
|
|
}
|
|
`,
|
|
"pkg-schema/src/schema.ts",
|
|
);
|
|
const schemaMatches = buildRuntimeSchemaMatches(
|
|
schemaSample.runtimeSchemas,
|
|
schemaSample.declarations,
|
|
new Set(),
|
|
);
|
|
const userSchemaMatch = schemaMatches.find(
|
|
(c) => c.schema.name === "UserDtoSchema" && c.type.name === "UserDto",
|
|
);
|
|
if (userSchemaMatch?.relation !== "exact-key-match") {
|
|
console.error(
|
|
`[type-duplication-audit] self-test FAILED: z.object schema did not match exported type (got ${JSON.stringify(userSchemaMatch)})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const jsonSchemaMatch = schemaMatches.find(
|
|
(c) =>
|
|
c.schema.name === "FeatureFlagSchema" && c.type.name === "FeatureFlag",
|
|
);
|
|
if (jsonSchemaMatch?.relation !== "exact-key-match") {
|
|
console.error(
|
|
`[type-duplication-audit] self-test FAILED: JSON-schema object did not match exported type (got ${JSON.stringify(jsonSchemaMatch)})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (schemaMatches.some((c) => c.type.name === "Unrelated")) {
|
|
console.error(
|
|
"[type-duplication-audit] self-test FAILED: unrelated exported type should not match runtime schemas",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const schemaSuppressed = buildRuntimeSchemaMatches(
|
|
schemaSample.runtimeSchemas,
|
|
schemaSample.declarations,
|
|
new Set([`schemaPair:${userSchemaMatch.schemaPairKey}`]),
|
|
);
|
|
if (
|
|
schemaSuppressed.some(
|
|
(c) => c.schemaPairKey === userSchemaMatch.schemaPairKey,
|
|
)
|
|
) {
|
|
console.error(
|
|
"[type-duplication-audit] self-test FAILED: allowlist schemaPairKey did not suppress schema/type match",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// ── Advisory baseline drift compare ───────────────────────────────────
|
|
const fakeReport = {
|
|
generatedAt: "2026-01-01T00:00:00.000Z",
|
|
thresholds: {},
|
|
filesScanned: 1,
|
|
sameName: [1, 2],
|
|
subsets: [1],
|
|
nearDuplicates: [],
|
|
literalSetClusters: [1, 2, 3],
|
|
runtimeSchemaMatches: [1, 2],
|
|
weakTypeCounts: { asUnknownAs: 5, asAny: 0, explicitAny: 7 },
|
|
};
|
|
const baseCounts = countsFromReport(fakeReport);
|
|
if (
|
|
baseCounts.sameName !== 2 ||
|
|
baseCounts.literalSetClusters !== 3 ||
|
|
baseCounts.runtimeSchemaMatches !== 2 ||
|
|
baseCounts.weakExplicitAny !== 7
|
|
) {
|
|
console.error(
|
|
`[type-duplication-audit] self-test FAILED: countsFromReport (got ${JSON.stringify(baseCounts)})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const baseline = baselinePayload(fakeReport);
|
|
if (compareBaseline(baseCounts, baseline).increased !== 0) {
|
|
console.error(
|
|
"[type-duplication-audit] self-test FAILED: identical counts must show 0 increase",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const grown = compareBaseline({ ...baseCounts, sameName: 5 }, baseline);
|
|
const sameNameRow = grown.rows.find((r) => r.key === "sameName");
|
|
if (grown.increased !== 1 || sameNameRow.delta !== 3) {
|
|
console.error(
|
|
`[type-duplication-audit] self-test FAILED: baseline drift not detected (got ${JSON.stringify(grown)})`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log(
|
|
"[type-duplication-audit] self-test passed (shape: duplicate + subset fire, distinct + tiny ignored; literal-set: cross-kind clusters, below-threshold + single-package ignored; runtime-schema/type matches fire; allowlist suppresses; weak-types counted; baseline drift compares)",
|
|
);
|
|
}
|
|
|
|
if (SELF_TEST) {
|
|
runSelfTest();
|
|
process.exit(0);
|
|
}
|
|
|
|
const files = trackedSourceFiles();
|
|
const allDeclarations = [];
|
|
const allWeakTypes = [];
|
|
const allLiteralSets = [];
|
|
const allRuntimeSchemas = [];
|
|
for (const relPath of files) {
|
|
const sourceText = readFileSync(path.join(ROOT, relPath), "utf8");
|
|
const { declarations, weakTypes, literalSets, runtimeSchemas } =
|
|
collectFromSource(sourceText, relPath);
|
|
allDeclarations.push(...declarations);
|
|
allWeakTypes.push(...weakTypes);
|
|
allLiteralSets.push(...literalSets);
|
|
allRuntimeSchemas.push(...runtimeSchemas);
|
|
}
|
|
|
|
const allowlist = loadAllowlist();
|
|
const sameName = buildSameNameClusters(allDeclarations, allowlist);
|
|
const { subsets, nearDuplicates } = buildShapeCandidates(
|
|
allDeclarations,
|
|
allowlist,
|
|
);
|
|
const literalSetClusters = buildLiteralSetClusters(allLiteralSets, allowlist);
|
|
const runtimeSchemaMatches = buildRuntimeSchemaMatches(
|
|
allRuntimeSchemas,
|
|
allDeclarations,
|
|
allowlist,
|
|
);
|
|
const weakTypeCounts = summarizeWeakTypes(allWeakTypes);
|
|
|
|
const report = {
|
|
schema: "eliza_type_duplication_audit_v1",
|
|
generatedAt: new Date().toISOString(),
|
|
filesScanned: files.length,
|
|
declarationCount: allDeclarations.length,
|
|
thresholds: {
|
|
minProps: MIN_PROPS,
|
|
nearDuplicateJaccard: NEAR_DUP_THRESHOLD,
|
|
minLiteralMembers: MIN_LITERAL_MEMBERS,
|
|
minSchemaProps: MIN_SCHEMA_PROPS,
|
|
schemaTypeKeyOverlap: SCHEMA_TYPE_OVERLAP_THRESHOLD,
|
|
},
|
|
sameName,
|
|
subsets,
|
|
nearDuplicates,
|
|
literalSetClusters,
|
|
runtimeSchemaMatches,
|
|
weakTypeCounts,
|
|
weakTypes: allWeakTypes,
|
|
};
|
|
|
|
mkdirSync(path.dirname(JSON_OUT_PATH), { recursive: true });
|
|
writeFileSync(JSON_OUT_PATH, `${JSON.stringify(report, null, 2)}\n`);
|
|
mkdirSync(path.dirname(MARKDOWN_OUT_PATH), { recursive: true });
|
|
writeFileSync(MARKDOWN_OUT_PATH, renderMarkdown(report));
|
|
|
|
console.log(
|
|
`[type-duplication-audit] scanned ${files.length} files, ${allDeclarations.length} type declarations`,
|
|
);
|
|
console.log(
|
|
`[type-duplication-audit] same-name multi-package clusters: ${sameName.length}`,
|
|
);
|
|
console.log(
|
|
`[type-duplication-audit] subset/superset candidates: ${subsets.length}`,
|
|
);
|
|
console.log(
|
|
`[type-duplication-audit] structural near-duplicates (Jaccard ≥ ${NEAR_DUP_THRESHOLD}): ${nearDuplicates.length}`,
|
|
);
|
|
console.log(
|
|
`[type-duplication-audit] literal-set duplicates (≥ ${MIN_LITERAL_MEMBERS} members, multi-package): ${literalSetClusters.length}`,
|
|
);
|
|
console.log(
|
|
`[type-duplication-audit] runtime schema ↔ exported type matches (key overlap ≥ ${SCHEMA_TYPE_OVERLAP_THRESHOLD}): ${runtimeSchemaMatches.length}`,
|
|
);
|
|
console.log(
|
|
`[type-duplication-audit] weak types — as unknown as: ${weakTypeCounts.asUnknownAs}, as any: ${weakTypeCounts.asAny}, explicit any: ${weakTypeCounts.explicitAny}`,
|
|
);
|
|
console.log(
|
|
`[type-duplication-audit] wrote ${path.relative(ROOT, JSON_OUT_PATH)}`,
|
|
);
|
|
console.log(
|
|
`[type-duplication-audit] wrote ${path.relative(ROOT, MARKDOWN_OUT_PATH)}`,
|
|
);
|
|
|
|
if (UPDATE_BASELINE) {
|
|
writeFileSync(
|
|
BASELINE_PATH,
|
|
`${JSON.stringify(baselinePayload(report), null, 2)}\n`,
|
|
);
|
|
console.log(
|
|
`[type-duplication-audit] wrote ${path.relative(ROOT, BASELINE_PATH)}`,
|
|
);
|
|
}
|
|
|
|
if (CHECK) {
|
|
const baseline = loadBaselineFile();
|
|
if (!baseline) {
|
|
console.error(
|
|
`[type-duplication-audit] no baseline at ${path.relative(ROOT, BASELINE_PATH)} — run with --update-baseline first.`,
|
|
);
|
|
process.exit(STRICT ? 1 : 0);
|
|
}
|
|
const drift = compareBaseline(countsFromReport(report), baseline);
|
|
printBaselineDrift(drift);
|
|
// Advisory by default: only --strict turns drift into a non-zero exit, so
|
|
// local types are never blocked (#10201 acceptance: CI/advisory mode).
|
|
if (STRICT && drift.increased > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|