dry-ts
Version:
Find candidate duplicate TypeScript code by comparing normalized AST structure.
120 lines (119 loc) • 5.55 kB
JavaScript
import fs from "node:fs";
import path from "node:path";
import { isOutputFormat, OUTPUT_FORMATS } from "./types.js";
// The repo-checked-in config file. A persisted alternative to retyping the same
// flags on every run — the top v0.12 adoption blocker (#23). Read from cwd; its
// values sit BELOW an active --profile and any explicit CLI flag in precedence
// (explicit CLI flag > profile > config > built-in default), so a committed
// config sets the repo's baseline without overriding a flag the caller typed for
// this one run.
export const CONFIG_FILENAME = ".dry-ts.json";
// Value validators: shape and TYPE only. Numeric RANGES (threshold in (0,1],
// minLines >= 1, …) are enforced once, downstream, by the Options constructor —
// duplicating them here would let the two drift.
// finiteNumber and the integer (count) check are the same guard shape — a typeof
// test plus a predicate — so they share one factory rather than two near-identical
// bodies (which dry-ts's own --fail-on-duplicates gate would, rightly, flag). The
// count keys use the integer predicate so a config cannot express a fractional
// floor the integer CLI flags (--min-nodes etc., parsed with parseInt) never could
// — `minLocations: 2.5` would otherwise silently mean "effectively 3". NaN and
// Infinity fail both predicates.
function numericValidator(isValid, label) {
return (source, key, value) => {
if (typeof value !== "number" || !isValid(value)) {
throw new Error(`${source}: "${key}" must be ${label}`);
}
return value;
};
}
const finiteNumber = numericValidator(Number.isFinite, "a number");
const integer = numericValidator(Number.isInteger, "an integer");
function boolean(source, key, value) {
if (typeof value !== "boolean") {
throw new Error(`${source}: "${key}" must be a boolean`);
}
return value;
}
// Trims and drops empties so a stray "" never becomes a glob matching everything,
// matching how --exclude/--exclude-kinds discard blank entries in Options.parse.
function stringList(source, key, value) {
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) {
throw new Error(`${source}: "${key}" must be an array of strings`);
}
return value.map((entry) => entry.trim()).filter((entry) => entry.length > 0);
}
function outputFormat(source, key, value) {
if (!isOutputFormat(value)) {
throw new Error(`${source}: "${key}" must be one of ${OUTPUT_FORMATS.join(", ")}`);
}
return value;
}
// SINGLE source of truth for the persistable keys: each maps to the validator
// that types its value. parseConfig dispatches off this map and builds the
// unknown-key error's "valid:" list from its keys, so the accepted set can never
// drift from a hand-maintained second list (the prior KNOWN_KEYS array + switch).
// `satisfies Record<keyof ConfigOptions, …>` makes tsc reject any key here that
// is not a ConfigOptions field, and any ConfigOptions field missing a parser —
// coupling this map to the type at compile time.
const FIELD_PARSERS = {
paths: stringList,
threshold: finiteNumber,
minLines: integer,
minNodes: integer,
minLocations: integer,
minDistinctKinds: integer,
format: outputFormat,
failOnDuplicates: boolean,
respectGitignore: boolean,
excludeKinds: stringList,
exclude: stringList,
ignore: stringList,
excludeTaggedTemplates: boolean,
excludeTests: boolean,
counterparts: boolean,
};
const KNOWN_KEYS = Object.keys(FIELD_PARSERS);
// Reads CONFIG_FILENAME from `cwd`. ONLY a missing file (ENOENT) is the common,
// non-error case → empty config (every value falls through to profile/default).
// Any OTHER read error on a PRESENT file — a directory named .dry-ts.json
// (EISDIR), unreadable permissions (EACCES), an I/O error — fails loud (exit 2),
// rather than silently downgrading a committed policy to permissive defaults and
// passing CI at exit 0. A malformed-but-readable file throws in parseConfig.
export function loadConfig(cwd = process.cwd()) {
let text;
try {
text = fs.readFileSync(path.join(cwd, CONFIG_FILENAME), "utf8");
}
catch (error) {
if (error.code === "ENOENT") {
return {};
}
throw new Error(`${CONFIG_FILENAME}: cannot read (${error instanceof Error ? error.message : String(error)})`);
}
return parseConfig(text);
}
// `source` names the file in error messages so a config problem is attributable.
export function parseConfig(text, source = CONFIG_FILENAME) {
let raw;
try {
raw = JSON.parse(text);
}
catch (error) {
throw new Error(`${source}: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
}
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
throw new Error(`${source}: expected a JSON object at the top level`);
}
const config = {};
for (const [key, value] of Object.entries(raw)) {
// hasOwn (not `in`) so an inherited name like "toString" is still rejected,
// and a JSON `__proto__`/`constructor` key (own, post-parse) misses the map
// and falls to the unknown-key throw rather than reaching a parser.
if (!Object.hasOwn(FIELD_PARSERS, key)) {
throw new Error(`${source}: unknown key "${key}" (valid: ${KNOWN_KEYS.join(", ")})`);
}
const parse = FIELD_PARSERS[key];
config[key] = parse(source, key, value);
}
return config;
}