dry-ts
Version:
Find candidate duplicate TypeScript code by comparing normalized AST structure.
357 lines (356 loc) • 15.3 kB
JavaScript
import { resolveExcludeKinds } from "./FileScanner.js";
import { isOutputFormat } from "./types.js";
// PR gate, highest signal. Deliberately requires an explicit --changed-from /
// --changed: `onlyNew` without an active scope is a hard error in the Options
// constructor, so `--profile pr` alone fails loud rather than silently gating
// against the wrong base. Named so `agent` can extend it without re-listing.
const PR_PROFILE = {
excludeTests: true,
minNodes: 50,
excludeKinds: ["ArrowFunction", "VariableStatement"],
onlyNew: true,
failOnDuplicates: true,
};
const PROFILES = {
pr: PR_PROFILE,
// Source-only sane defaults: drop test scaffolding, keep the standard floors.
src: { excludeTests: true },
// Broad exploratory scan: lower the size floor to surface near-misses the
// default would filter out; exclude nothing.
audit: { minNodes: 12 },
// Test-INFRASTRUCTURE duplication, explicitly not normal test bodies: drop
// anonymous arrow bodies (the dominant test-scan noise) and raise the floor.
// Point it at your test directories.
tests: { excludeKinds: ["ArrowFunction"], minNodes: 40 },
// After-edit agent loop: the PR gate plus per-location counterpart routing and
// JSON output, so an agent reads each new finding's nearest existing match and
// either reuses it or justifies the duplication. Inherits pr's onlyNew, so it
// too fails loud without a --changed-from/--changed scope.
agent: { ...PR_PROFILE, counterparts: true, format: "json" },
};
// The profile names a user can pass, for validation and help/docs.
export const PROFILE_NAMES = Object.keys(PROFILES);
function resolveProfile(name) {
const profile = PROFILES[name];
if (profile === undefined) {
throw new Error(`Unknown profile: ${name} (valid: ${PROFILE_NAMES.join(", ")})`);
}
return profile;
}
export class Options {
paths;
threshold;
minLines;
minNodes;
format;
help;
failOnDuplicates;
respectGitignore;
minLocations;
changedFrom;
changed;
explainChanged;
onlyNew;
excludeKinds;
minDistinctKinds;
exclude;
excludeTaggedTemplates;
excludeTests;
counterparts;
constructor(resolved) {
if (!(resolved.threshold > 0 && resolved.threshold <= 1)) {
throw new Error(`threshold must be greater than 0 and at most 1, got ${resolved.threshold}`);
}
if (resolved.minLines < 1) {
throw new Error(`minLines must be at least 1, got ${resolved.minLines}`);
}
if (resolved.minNodes < 1) {
throw new Error(`minNodes must be at least 1, got ${resolved.minNodes}`);
}
if (resolved.minDistinctKinds < 0) {
throw new Error(`minDistinctKinds must be at least 0, got ${resolved.minDistinctKinds}`);
}
if (resolved.minLocations < 2) {
throw new Error(`minLocations must be at least 2, got ${resolved.minLocations}`);
}
if (resolved.changedFrom !== undefined && resolved.changed.length > 0) {
throw new Error("--changed-from and --changed cannot be combined");
}
if (resolved.onlyNew && resolved.changedFrom === undefined && resolved.changed.length === 0) {
throw new Error("--only-new requires --changed-from or --changed");
}
// Validate names eagerly so an unknown/non-candidate kind fails at
// construction time, not silently at scan time.
resolveExcludeKinds(resolved.excludeKinds);
this.paths = resolved.paths;
this.threshold = resolved.threshold;
this.minLines = resolved.minLines;
this.minNodes = resolved.minNodes;
this.format = resolved.format;
this.help = resolved.help;
this.failOnDuplicates = resolved.failOnDuplicates;
this.respectGitignore = resolved.respectGitignore;
this.minLocations = resolved.minLocations;
this.changedFrom = resolved.changedFrom;
this.changed = resolved.changed;
this.explainChanged = resolved.explainChanged;
this.onlyNew = resolved.onlyNew;
this.excludeKinds = resolved.excludeKinds;
this.minDistinctKinds = resolved.minDistinctKinds;
this.exclude = resolved.exclude;
this.excludeTaggedTemplates = resolved.excludeTaggedTemplates;
this.excludeTests = resolved.excludeTests;
this.counterparts = resolved.counterparts;
}
static defaults() {
return new Options({
paths: ["src"],
threshold: 0.82,
minLines: 4,
minNodes: 20,
format: "text",
help: false,
failOnDuplicates: false,
respectGitignore: true,
minLocations: 2,
changedFrom: undefined,
changed: [],
explainChanged: false,
onlyNew: false,
excludeKinds: [],
minDistinctKinds: 0,
exclude: [],
excludeTaggedTemplates: false,
excludeTests: false,
counterparts: false,
});
}
static from(input = {}) {
const defaults = Options.defaults();
return new Options({
paths: input.paths && input.paths.length > 0 ? [...input.paths] : defaults.paths,
threshold: input.threshold ?? defaults.threshold,
minLines: input.minLines ?? defaults.minLines,
minNodes: input.minNodes ?? defaults.minNodes,
format: input.format ?? defaults.format,
help: input.help ?? defaults.help,
failOnDuplicates: input.failOnDuplicates ?? defaults.failOnDuplicates,
respectGitignore: input.respectGitignore ?? defaults.respectGitignore,
minLocations: input.minLocations ?? defaults.minLocations,
changedFrom: input.changedFrom,
changed: input.changed ?? [],
explainChanged: input.explainChanged ?? defaults.explainChanged,
onlyNew: input.onlyNew ?? defaults.onlyNew,
excludeKinds: input.excludeKinds ?? [],
minDistinctKinds: input.minDistinctKinds ?? defaults.minDistinctKinds,
exclude: input.exclude ?? [],
excludeTaggedTemplates: input.excludeTaggedTemplates ?? defaults.excludeTaggedTemplates,
excludeTests: input.excludeTests ?? defaults.excludeTests,
counterparts: input.counterparts ?? defaults.counterparts,
});
}
static parse(...args) {
return Options.fromCli(args);
}
// Resolve CLI args layered over a (possibly empty) config file. Precedence per
// option: explicit CLI flag > active --profile > .dry-ts.json > built-in
// default. `parse` is the no-config entrypoint; main() calls this with the
// loaded config so a committed baseline applies without a flag overriding it.
static fromCli(args, config = {}) {
const paths = [];
const changed = [];
const excludeKinds = [];
const exclude = [];
// Scalars start undefined so resolution can tell "user set this" from "left
// at the default" — the distinction `--profile` precedence needs (explicit >
// profile > default). Arrays accumulate; an empty array means "not set".
let profileName;
let threshold;
let minLines;
let minNodes;
let minLocations;
let format;
let help;
let failOnDuplicates;
let respectGitignore;
let changedFrom;
let explainChanged;
let onlyNew;
let minDistinctKinds;
let excludeTaggedTemplates;
let excludeTests;
let counterparts;
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
switch (arg) {
case "--profile":
profileName = valueFor(args, ++i, arg);
break;
case "--threshold":
threshold = numberValue(args, ++i, arg);
break;
case "--min-lines":
minLines = integerValue(args, ++i, arg);
break;
case "--min-nodes":
minNodes = integerValue(args, ++i, arg);
break;
case "--min-distinct-kinds":
minDistinctKinds = integerValue(args, ++i, arg);
break;
case "--exclude": {
const glob = valueFor(args, ++i, arg).trim();
if (glob.length > 0) {
exclude.push(glob);
}
break;
}
case "--min-locations":
minLocations = integerValue(args, ++i, arg);
break;
case "--format":
format = formatValue(args, ++i, arg);
break;
case "--changed-from":
changedFrom = valueFor(args, ++i, arg);
break;
case "--changed":
changed.push(valueFor(args, ++i, arg));
break;
case "--explain-changed":
explainChanged = true;
break;
case "--only-new":
onlyNew = true;
break;
case "--exclude-tagged-templates":
excludeTaggedTemplates = true;
break;
case "--exclude-tests":
excludeTests = true;
break;
case "--counterparts":
counterparts = true;
break;
case "--exclude-kinds":
for (const name of valueFor(args, ++i, arg).split(",")) {
const trimmed = name.trim();
if (trimmed.length > 0) {
excludeKinds.push(trimmed);
}
}
break;
case "--edn":
format = "edn";
break;
case "--json":
format = "json";
break;
case "--text":
format = "text";
break;
case "--sarif":
format = "sarif";
break;
case "--fail-on-duplicates":
failOnDuplicates = true;
break;
case "--no-gitignore":
respectGitignore = false;
break;
case "--help":
case "-h":
help = true;
break;
default:
// A typo'd flag silently becoming a scan path would scan nothing
// and exit 0 — a silent gate bypass.
if (arg.startsWith("-")) {
throw new Error(`Unknown option: ${arg}`);
}
paths.push(arg);
}
}
// `--help` short-circuits before any profile resolution so `dry-ts --help`
// never fails on an unrelated profile typo; the resolved Options below are
// unused when help is set (main() prints USAGE and returns).
const profile = help || profileName === undefined ? {} : resolveProfile(profileName);
return new Options({
paths: paths.length > 0 ? paths : config.paths && config.paths.length > 0 ? [...config.paths] : ["src"],
threshold: pick(threshold, undefined, config.threshold, 0.82),
minLines: pick(minLines, undefined, config.minLines, 4),
minNodes: pick(minNodes, profile.minNodes, config.minNodes, 20),
format: pick(format, profile.format, config.format, "text"),
help: help ?? false,
failOnDuplicates: pick(failOnDuplicates, profile.failOnDuplicates, config.failOnDuplicates, false),
respectGitignore: pick(respectGitignore, undefined, config.respectGitignore, true),
minLocations: pick(minLocations, undefined, config.minLocations, 2),
changedFrom,
changed,
explainChanged: explainChanged ?? false,
// onlyNew is run-scoped: a profile may set it, but a config file may not (a
// committed `onlyNew` would make every plain scan throw on the missing scope).
onlyNew: pick(onlyNew, profile.onlyNew, undefined, false),
excludeKinds: unionLists(profile.excludeKinds, config.excludeKinds, excludeKinds),
minDistinctKinds: pick(minDistinctKinds, undefined, config.minDistinctKinds, 0),
// Config `exclude` and its alias `ignore` both feed the --exclude glob list,
// unioned ahead of the CLI's own --exclude entries.
exclude: unionLists(config.exclude, config.ignore, exclude),
excludeTaggedTemplates: pick(excludeTaggedTemplates, undefined, config.excludeTaggedTemplates, false),
excludeTests: pick(excludeTests, profile.excludeTests, config.excludeTests, false),
counterparts: pick(counterparts, profile.counterparts, config.counterparts, false),
});
}
}
// Resolution precedence for a scalar option: an explicit CLI value wins, then
// the active profile's value, then the config file's value, then the built-in
// default. A layer that does not apply to an option passes undefined for its slot.
function pick(explicit, fromProfile, fromConfig, fallback) {
return explicit ?? fromProfile ?? fromConfig ?? fallback;
}
// List flags union their layers (deduped, earliest layer first) rather than
// letting a later one replace an earlier — adding `--exclude-kinds` on top of a
// profile augments it, and config-level globs sit ahead of CLI ones, none discarding
// the others. Undefined/empty layers are skipped.
function unionLists(...layers) {
const seen = new Set();
const result = [];
for (const layer of layers) {
if (layer === undefined) {
continue;
}
for (const value of layer) {
if (!seen.has(value)) {
seen.add(value);
result.push(value);
}
}
}
return result;
}
function valueFor(args, index, option) {
if (index >= args.length) {
throw new Error(`Missing value for ${option}`);
}
return args[index];
}
function formatValue(args, index, option) {
const value = valueFor(args, index, option);
if (!isOutputFormat(value)) {
throw new Error(`Unknown format: ${value}`);
}
return value;
}
function numberValue(args, index, option) {
return parsedValue(args, index, option, Number.parseFloat, "number");
}
function integerValue(args, index, option) {
return parsedValue(args, index, option, (value) => Number.parseInt(value, 10), "integer");
}
function parsedValue(args, index, option, parse, label) {
const parsed = parse(valueFor(args, index, option));
if (Number.isNaN(parsed)) {
throw new Error(`Invalid ${label} for ${option}`);
}
return parsed;
}