pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
422 lines (420 loc) • 14.2 kB
JavaScript
import { createRequire as __pilensCreateRequire } from "node:module"; const require = __pilensCreateRequire(import.meta.url);
import {
formatToolFailure
} from "./chunk-ZMOL6RQC.js";
import "./chunk-I7SPGOWI.js";
import {
createAvailabilityChecker,
findManagedNodeToolBinary,
resolveAvailableOrInstall
} from "./chunk-7FBF7NPR.js";
import "./chunk-HHYAELLJ.js";
import {
canonicalDirectory,
listNestedLinkedWorktreeRoots
} from "./chunk-3I5QFT3N.js";
import "./chunk-B3I2UUT4.js";
import "./chunk-DIULIWKU.js";
import "./chunk-VDBPVIKD.js";
import "./chunk-TGPXLY2J.js";
import "./chunk-QOZXJ4NA.js";
import "./chunk-PAK6IDME.js";
import "./chunk-GUOGVTCQ.js";
import "./chunk-AJVLWNFN.js";
import "./chunk-YGVYKNSN.js";
import {
getJscpdMaxEntriesDerived
} from "./chunk-LNAKEO63.js";
import "./chunk-Z3CCVDUB.js";
import "./chunk-2BEMIWEW.js";
import "./chunk-OQGFCZJ4.js";
import "./chunk-RUOG5D2C.js";
import {
findNodeToolBinary
} from "./chunk-U3VGR4VU.js";
import "./chunk-NTUJEGDZ.js";
import "./chunk-EOKRQ3SA.js";
import "./chunk-WONERJTP.js";
import "./chunk-22AVIGXS.js";
import "./chunk-GVXA6CCE.js";
import "./chunk-6QIUX4EP.js";
import {
shouldRecurseIntoDir,
walkTreeStackSync
} from "./chunk-VHQLPIRJ.js";
import {
getExcludedDirGlobs,
getProjectIgnoreGlobs,
getProjectIgnoreMatcher,
safeSpawnAsync
} from "./chunk-VN2AXLEX.js";
import "./chunk-BW5KFIQC.js";
import "./chunk-QYF5S6P6.js";
import "./chunk-5ZEJHV35.js";
import "./chunk-DQ2NZ7C4.js";
import "./chunk-VNMT7C64.js";
import "./chunk-2EECBNC5.js";
import {
incrementDegradationCount
} from "./chunk-N3YQJI6O.js";
import {
createSubsystemLogger
} from "./chunk-O6TQT6RI.js";
import "./chunk-5THXD6X5.js";
import "./chunk-UK3CMEAL.js";
import "./chunk-LJDODBBZ.js";
import "./chunk-ABBOA7UD.js";
import {
isAtOrAboveHomeDir,
isFullyQualified,
toPosix
} from "./chunk-Q5U6FMDI.js";
import "./chunk-NXL4FFQQ.js";
// dist/clients/jscpd-client.js
import * as fs from "node:fs";
import { mkdtempSync } from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { relative } from "node:path";
var EMPTY_RESULT = {
success: false,
clones: [],
duplicatedLines: 0,
totalLines: 0,
percentage: 0
};
var SCAN_TIMEOUT_MS = 3e4;
var JSCPD_CONFIG_FILENAMES = [".jscpd.json", "jscpd.json"];
function readProjectJscpdConfig(cwd) {
const fromObject = (config) => {
const list = config?.ignore;
if (list === void 0)
return { ignoreList: [] };
return Array.isArray(list) && list.every((e) => typeof e === "string") ? { ignoreList: list } : { ignoreList: null };
};
for (const name of JSCPD_CONFIG_FILENAMES) {
const file = path.join(cwd, name);
if (!fs.existsSync(file))
continue;
try {
return fromObject(JSON.parse(fs.readFileSync(file, "utf-8")));
} catch {
return { ignoreList: null };
}
}
try {
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
if (pkg && typeof pkg === "object" && pkg.jscpd !== void 0) {
return fromObject(pkg.jscpd);
}
} catch {
}
return null;
}
function worktreeIgnoreGlobs(cwd) {
const base = canonicalDirectory(cwd);
const globs = [];
for (const worktree of listNestedLinkedWorktreeRoots(cwd)) {
const inside = toPosix(relative(base, worktree));
const unsafe = inside.includes(",") ? "comma-in-path" : /[*?[\]{}()!\\]/.test(inside) ? "glob-char-in-path" : null;
if (unsafe) {
incrementDegradationCount({
kind: "scan-worktree-exclusion-skipped",
subject: "jscpd",
reason: `${unsafe}: ${inside} not ignored under ${cwd}`
});
continue;
}
globs.push(`${inside}/**`);
}
return globs;
}
var jscpdAvailability = createAvailabilityChecker("jscpd", "", ["--version"], {
probeTimeout: 1500,
// One definition of the managed-shim fast path, shared with knip (#1476).
fastPath: () => findManagedNodeToolBinary("jscpd")
});
var JscpdClient = class {
// Absolute path of a MANAGED jscpd (the global ~/.pi-lens tools shim
// discovered by the availability fast path, or the ensureTool install
// result), else null. Nullable on purpose: a bare-name sentinel can't
// distinguish "resolved" from "on PATH" — ensureTool may legitimately
// return a bare command for an already-spawnable tool (#1289 review).
// Project-local binaries are NOT stored; each scan resolves those against
// its own cwd via findNodeToolBinary.
jscpdManagedPath = null;
inFlight = /* @__PURE__ */ new Map();
log;
constructor(verbose = false) {
this.log = verbose ? createSubsystemLogger("jscpd") : () => {
};
}
/**
* Wait for scans already started by this client to settle. Test owners use
* this as the lifecycle barrier before removing fixture roots; the scan
* promise includes the jscpd child and its report-directory cleanup.
*/
async shutdown() {
await Promise.allSettled(this.inFlight.values());
}
/**
* Fast recursive source file presence check.
* Avoids running jscpd when repo has no relevant source files.
*
* Originally this gate accepted only JS/TS extensions (commit 8b5d588).
* That made pi-lens's jscpd integration effectively JS/TS-only even
* though jscpd's tokenizer covers 15+ languages. Pure-Python /
* pure-Go / pure-Rust / pure-Java / etc. repos got zero clone
* detection. Closes #126: extend the gate to every language jscpd
* tokenizes well. Languages with no jscpd tokenizer (Gleam, Zig,
* Fish) are deliberately excluded — the gate change wouldn't help
* them.
*/
hasSourceFilesRecursive(rootDir) {
const ignoreMatcher = getProjectIgnoreMatcher(rootDir);
const state = { visited: 0 };
const MAX_ENTRIES = getJscpdMaxEntriesDerived(rootDir);
return walkTreeStackSync(rootDir, (entry, fullPath) => {
state.visited += 1;
if (entry.isSymbolicLink())
return "skip";
if (entry.isDirectory()) {
return shouldRecurseIntoDir(entry, fullPath, {
ignoreMatcher,
followSymlinks: true
}) ? "recurse" : "skip";
}
if (!entry.isFile())
return "skip";
if (/\.(ts|tsx|js|jsx|mjs|cjs|py|pyi|java|go|rs|rb|php|swift|kt|kts|dart|lua|scala|c|h|cpp|cc|cxx|hpp|hxx|cs|m|mm)$/.test(entry.name)) {
if (entry.name.endsWith(".d.ts"))
return "skip";
if (ignoreMatcher.isIgnored(fullPath, false))
return "skip";
return "stop";
}
return "skip";
}, { shouldStop: () => state.visited >= MAX_ENTRIES });
}
/**
* Check if jscpd is available, auto-install if not
*/
async ensureAvailable() {
const resolved = await resolveAvailableOrInstall(jscpdAvailability, "jscpd", process.cwd());
if (!resolved)
return false;
if (isFullyQualified(resolved))
this.jscpdManagedPath = resolved;
return true;
}
/**
* #1623: public availability verdict for callers outside the dispatch
* graph (mode=full's fresh-fetch) that need to say WHY `ensureAvailable()`
* most recently returned false, using the SAME outcome/cause the shared
* `jscpdAvailability` checker already tracks — not a re-guessed "jscpd
* binary unavailable" that can't tell a transient retry-cooldown probe
* from a durable absence.
*/
getAvailabilityVerdict() {
const verdict = jscpdAvailability.getVerdict(process.cwd());
return {
outcome: verdict.outcome,
cause: verdict.cause,
retryAtMs: verdict.retryAtMs
};
}
/**
* Scan a directory for duplicate code blocks.
* Uses a temp output dir to capture JSON report.
* @param isTsProject - If true, excludes .js files (they're compiled artifacts in TS projects)
*
* Root contract (#747/#250): unlike `KnipClient`/`DeadCodeClient`, this
* client does NOT resolve a project root via `findNearestMarkerRoot`, so it
* has historically relied on every caller to guard the scan root itself. The
* `isAtOrAboveHomeDir` refusal below is belt-and-braces internal protection
* so a FUTURE caller that forgets that contract can't spawn a whole-$HOME
* jscpd walk (the observed OOM: a jscpd run from a WSL home reached 44 GB
* RSS). `options.homeDir` overrides `os.homedir()` for tests.
*/
async scan(cwd, minLines = 5, minTokens = 50, isTsProject = false, options = {}) {
const targetDir = path.resolve(cwd);
if (isAtOrAboveHomeDir(targetDir, options.homeDir)) {
this.log(`Refusing scan of unsafe root at/above home: ${targetDir}`);
return { ...EMPTY_RESULT };
}
if (!fs.existsSync(targetDir)) {
return { ...EMPTY_RESULT };
}
if (!this.hasSourceFilesRecursive(targetDir)) {
return { ...EMPTY_RESULT, success: true };
}
if (!await this.ensureAvailable()) {
return { ...EMPTY_RESULT };
}
const key = `${targetDir}:${minLines}:${minTokens}:${isTsProject}`;
const existing = this.inFlight.get(key);
if (existing) {
this.log(`Scan already in flight for ${targetDir}; sharing result`);
return existing;
}
const promise = this.runScan(targetDir, minLines, minTokens, isTsProject).finally(() => {
if (this.inFlight.get(key) === promise)
this.inFlight.delete(key);
});
this.inFlight.set(key, promise);
return promise;
}
async runScan(cwd, minLines, minTokens, isTsProject) {
const scannedAt = (/* @__PURE__ */ new Date()).toISOString();
const outDir = mkdtempSync(`${os.tmpdir()}${path.sep}pi-lens-jscpd-`);
const baseIgnores = [
...getExcludedDirGlobs(),
...getProjectIgnoreGlobs(cwd),
"**/*.md",
"**/*.txt",
"**/*.json",
"**/*.yaml",
"**/*.yml",
"**/*.toml",
"**/*.lock",
"**/*.test.*",
"**/*.spec.*",
"**/*.poc.test.*",
"**/__tests__/**",
"**/tests/**"
];
if (isTsProject) {
baseIgnores.push("**/*.js", "**/*.jsx");
}
try {
const bin = await findNodeToolBinary("jscpd", cwd);
const { cmd, prefix } = bin ? { cmd: bin, prefix: [] } : this.jscpdManagedPath ? { cmd: this.jscpdManagedPath, prefix: [] } : { cmd: "npx", prefix: ["jscpd"] };
const config = readProjectJscpdConfig(cwd);
const worktreeGlobs = worktreeIgnoreGlobs(cwd);
let patterns;
if (config === null) {
patterns = [...baseIgnores, ...worktreeGlobs];
} else if (worktreeGlobs.length > 0) {
if (config.ignoreList === null) {
incrementDegradationCount({
kind: "scan-worktree-exclusion-skipped",
subject: "jscpd",
reason: `config-ignore-unreadable: ${worktreeGlobs.length} linked worktree(s) under ${cwd} not ignored`
});
} else {
patterns = [...config.ignoreList, ...worktreeGlobs];
}
}
const result = await safeSpawnAsync(cmd, [
...prefix,
".",
...config === null ? [
"--min-lines",
String(minLines),
"--min-tokens",
String(minTokens)
] : [],
"--reporters",
"json",
"--output",
outDir,
...patterns === void 0 ? [] : ["--ignore", patterns.join(",")]
], {
timeout: SCAN_TIMEOUT_MS,
cwd
});
if (result.error) {
this.log(`Scan error: ${result.error.message}`);
return { ...EMPTY_RESULT };
}
const reportPath = path.join(outDir, "jscpd-report.json");
if (!fs.existsSync(reportPath)) {
if (result.status !== 0) {
const reason = formatToolFailure({
tool: "jscpd",
status: result.status,
signal: result.signal,
stderr: result.stderr,
reportMissing: true,
fields: { command: cmd }
});
this.log(reason);
incrementDegradationCount({
kind: "runner-empty-result",
subject: "jscpd",
reason
});
return { ...EMPTY_RESULT };
}
return { ...EMPTY_RESULT, success: true };
}
return { ...this.parseReport(reportPath), scannedAt };
} catch (err) {
this.log(`Scan error: ${err.message}`);
return { ...EMPTY_RESULT };
} finally {
try {
fs.rmSync(outDir, { recursive: true, force: true });
} catch (err) {
void err;
}
}
}
formatResult(result, maxClones = 8) {
if (!result.success || result.clones.length === 0)
return "";
const pct = result.percentage.toFixed(1);
let output = `[jscpd] ${result.clones.length} duplicate block(s) \u2014 ${pct}% of codebase (${result.duplicatedLines}/${result.totalLines} lines):
`;
for (const clone of result.clones.slice(0, maxClones)) {
const a = `${path.basename(clone.fileA)}:${clone.startA}`;
const b = `${path.basename(clone.fileB)}:${clone.startB}`;
output += ` ${clone.lines} lines \u2014 ${a} \u2194 ${b}
`;
}
if (result.clones.length > maxClones) {
output += ` ... and ${result.clones.length - maxClones} more
`;
}
return output;
}
// --- Internal ---
parseReport(reportPath) {
try {
const data = JSON.parse(fs.readFileSync(reportPath, "utf-8"));
const total = data.statistics?.total ?? {};
const duplicatedLines = total.duplicatedLines ?? 0;
const totalLines = total.lines ?? 0;
const percentage = total.percentage ?? (totalLines > 0 ? duplicatedLines / totalLines * 100 : 0);
const rawClones = data.duplicates ?? [];
const clones = rawClones.map((c) => ({
fileA: c.firstFile?.name ?? "",
startA: c.firstFile?.start ?? 0,
fileB: c.secondFile?.name ?? "",
startB: c.secondFile?.start ?? 0,
lines: c.lines ?? 0,
tokens: c.tokens ?? 0
}));
return {
success: true,
analyzed: true,
clones,
duplicatedLines,
totalLines,
percentage
};
} catch (err) {
void err;
return {
success: false,
clones: [],
duplicatedLines: 0,
totalLines: 0,
percentage: 0
};
}
}
};
export {
JscpdClient
};