Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "drywall",
"version": "0.3.1",
"version": "0.4.0",
"description": "Detect and eliminate code duplication using jscpd",
"mcpServers": {
"jscpd": {
Expand Down
22 changes: 16 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ jobs:
matrix:
os: [ubuntu-latest, windows-latest]
node-version: [22, 24]
env:
# Reference 4.x (Node core) version the second integration run tests
# against; the first run uses DEFAULT_VERSION from src/lib.js (the 5.x
# Rust core). Bump manually.
JSCPD_V4_VERSION: "4.2.5"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -22,10 +27,11 @@ jobs:
- run: npm ci
- run: npm test
# The integration test runs `npx jscpd@<version>` for real, which downloads
# and installs jscpd (~22 MB) into the npx cache on a cold runner. Cache that
# tree, keyed on the jscpd version, so subsequent runs reuse it instead of
# re-downloading. (setup-node's npm cache is keyed on package-lock.json, which
# never changes for jscpd, so it can't keep this warm.)
# and installs jscpd into the npx cache on a cold runner. Cache that tree,
# keyed on both jscpd versions under test, so subsequent runs reuse it
# instead of re-downloading. (setup-node's npm cache is keyed on
# package-lock.json, which never changes for jscpd, so it can't keep this
# warm.)
- name: Resolve jscpd version and npx cache path
id: jscpd
shell: bash
Expand All @@ -37,9 +43,13 @@ jobs:
uses: actions/cache@v4
with:
path: ${{ steps.jscpd.outputs.npx-cache }}
key: npx-jscpd-${{ runner.os }}-node${{ matrix.node-version }}-${{ steps.jscpd.outputs.version }}
- name: Integration test (real npx jscpd execution)
key: npx-jscpd-${{ runner.os }}-node${{ matrix.node-version }}-${{ steps.jscpd.outputs.version }}-${{ env.JSCPD_V4_VERSION }}
- name: Integration test (jscpd 5.x, code default)
run: npm run test:integration
- name: Integration test (jscpd 4.x)
run: npm run test:integration
env:
JSCPD_VERSION: ${{ env.JSCPD_V4_VERSION }}

checks:
runs-on: ubuntu-latest
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,22 @@ Deduplicate code in this codebase

## Configuration

Create a `.drywallrc.json` in your project root to set defaults. Values correspond to [jscpd CLI options](https://jscpd.dev/getting-started/configuration#cli-options), except for the DRYwall-specific ones listed below:
Create a `.drywallrc.json` in your project root to set defaults. Values correspond to [jscpd CLI options](https://jscpd.dev/getting-started/configuration#cli-options), except for the DRYwall-specific ones listed below. Example config:

```json
{
"minTokens": 50,
"minLines": 5,
"ignore": ["**/node_modules/**", "**/dist/**", "**/*.generated.*"],
"respectGitignore": true,
"jscpdVersion": "4.2.5"
"jscpdVersion": "5.0.12"
}
```

The configuration options specific to DRYwall are:

- **`respectGitignore`** — `true` by default. Passes `--gitignore` to jscpd so that files excluded by `.gitignore` are automatically skipped. Set to `false` to disable.
- **`jscpdVersion`** — Pin the jscpd version used via `npx`. Defaults to `4.2.5` if not set.
- **`respectGitignore`** — `true` by default. Files excluded by `.gitignore` are automatically skipped. Set to `false` to disable.
- **`jscpdVersion`** — Pin the jscpd version used via `npx`. Defaults to `5.0.12` if not set. The older 4.x Node-core line is also supported by pinning e.g. `"4.2.5"`; note that some jscpd CLI options [changed between 4.x and 5.x](https://jscpd.dev/getting-started/migration), so options in your config must match the pinned major version.
- **`maxDuplicates`** — Maximum number of duplicate pairs to return, ranked by impact. Defaults to `20`. (This needs to be restricted to avoid blowing past context limits right away in large codebases.)
- **`maxFragmentLength`** — Maximum character length of each code fragment before truncation. Defaults to `500`.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "drywall",
"version": "0.3.1",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
30 changes: 16 additions & 14 deletions servers/jscpd.js

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion src/jscpd.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
DEFAULT_MAX_FRAGMENT_LENGTH,
createReportDir,
buildArgs,
jscpdMajor,
readConfig,
runJscpd,
parseReport,
Expand Down Expand Up @@ -78,11 +79,13 @@ server.registerTool(
const targetPath = normalizeScanPath(scanPath || config.path || ".");
args.push(targetPath);

const { cmd } = await runJscpd(version, args);
const { cmd } = await runJscpd(version, args, reportPath);
const raw = await readFile(reportPath, "utf8");
const result = await parseReport(raw, {
maxDuplicates: maxDuplicates ?? config.maxDuplicates,
maxFragmentLength: maxFragmentLength ?? config.maxFragmentLength,
scanPath: targetPath,
jscpdMajor: jscpdMajor(version),
});

return {
Expand Down
149 changes: 126 additions & 23 deletions src/lib.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { execFile } from "node:child_process";
import { readFile, mkdtemp } from "node:fs/promises";
import { readFile, mkdtemp, open } from "node:fs/promises";
import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { existsSync } from "node:fs";

export const VERSION = DRYWALL_VERSION;
export const DEFAULT_VERSION = "4.2.5";
export const DEFAULT_VERSION = "5.0.12";

export function jscpdMajor(version) {
return Number.parseInt(version, 10);
}

export async function createReportDir() {
const dir = await mkdtemp(join(tmpdir(), "drywall-report-"));
Expand Down Expand Up @@ -43,11 +47,18 @@ export async function readConfig() {

export function buildArgs(config, toolArgs, reportDir) {
const { jscpdVersion, respectGitignore, ...jscpdConfig } = config;
const major = jscpdMajor(jscpdVersion || DEFAULT_VERSION);
const merged = { ...jscpdConfig, ...toolArgs };
const args = [];

// --gitignore unless explicitly disabled
if (respectGitignore !== false) {
// jscpd 4.x ignores .gitignore unless --gitignore is passed; 5.x respects it
// by default, removed --gitignore (unknown flags are hard errors), and only
// has --no-gitignore to opt out.
if (major >= 5) {
if (respectGitignore === false) {
args.push("--no-gitignore");
}
} else if (respectGitignore !== false) {
args.push("--gitignore");
}

Expand Down Expand Up @@ -86,11 +97,12 @@ export function buildArgs(config, toolArgs, reportDir) {
return args;
}

// jscpd globs the scan path with fast-glob, which treats backslashes as escape
// characters on every platform. On Windows the separator is `\`, so an absolute
// target like `E:\proj\src` becomes a broken pattern that matches nothing (an
// empty report). Normalize to forward slashes — safe because `\` is never a
// legal filename character on Windows. Other platforms are left untouched.
// jscpd 4.x globs the scan path with fast-glob, which treats backslashes as
// escape characters on every platform. On Windows the separator is `\`, so an
// absolute target like `E:\proj\src` becomes a broken pattern that matches
// nothing (an empty report). Normalize to forward slashes — safe because `\`
// is never a legal filename character on Windows, and harmless for the 5.x
// Rust engine, which accepts both separators. Other platforms are untouched.
export function normalizeScanPath(scanPath) {
if (process.platform === "win32" && typeof scanPath === "string") {
return scanPath.replace(/\\/g, "/");
Expand Down Expand Up @@ -128,7 +140,7 @@ function resolveNpx(fullArgs) {
return { command: "npx.cmd", spawnArgs: fullArgs, options: { shell: true } };
}

export function runJscpd(version, args) {
export function runJscpd(version, args, reportPath) {
if (!VERSION_RE.test(version)) {
throw new Error(`Invalid jscpd version: "${version}"`);
}
Expand All @@ -137,7 +149,15 @@ export function runJscpd(version, args) {
const { command, spawnArgs, options } = resolveNpx(fullArgs);
return new Promise((resolve, reject) => {
execFile(command, spawnArgs, options, (error, stdout, stderr) => {
if (error && !stderr.includes("Clone found")) {
// A nonzero exit doesn't mean the scan failed: jscpd exits 1 when a
// configured threshold is exceeded (after writing the report). The
// report file is written to a fresh temp dir, so its existence is proof
// of a completed scan. The "Clone found" stderr check covers 4.x runs
// where no reportPath was provided.
const scanCompleted =
(reportPath && existsSync(reportPath)) ||
stderr.includes("Clone found");
if (error && !scanCompleted) {
reject(new Error(stderr || error.message));
} else {
resolve({ cmd, stdout, stderr });
Expand All @@ -149,30 +169,113 @@ export function runJscpd(version, args) {
export const DEFAULT_MAX_DUPLICATES = 20;
export const DEFAULT_MAX_FRAGMENT_LENGTH = 500;

// jscpd 4.x reports file names relative to the working directory; 5.x reports
// them relative to the scanned path. Resolve to a path usable from the working
// directory so callers can open the files either way.
function resolveReportPath(name, scanPath) {
if (!scanPath || existsSync(name)) return name;
const joined = join(scanPath, name);
return existsSync(joined) ? joined : name;
}

// jscpd 5.x reports `startLoc`/`endLoc.position` as end-exclusive UTF-8 byte
// offsets into the file, so the fragment can be read directly without loading
// the whole file. (4.x positions are token-stream indices, not file offsets —
// they must never be read this way; 4.x populates `fragment` itself anyway.)
// The read is capped at 4 bytes per character of the fragment limit — the
// widest a UTF-8 character gets — so a capped read always decodes to more than
// fragLimit characters and the character-based truncation downstream both
// fires and slices off any replacement character from a split trailing byte
// sequence. Returns "" on any failure so callers can fall back to the
// line-based read.
async function readFragmentByPosition(file, startPos, endPos, fragLimit) {
let fh;
try {
const length = Math.min(endPos - startPos, fragLimit * 4 + 4);
if (length <= 0) return "";
fh = await open(file, "r");
const buffer = Buffer.alloc(length);
const { bytesRead } = await fh.read(buffer, 0, length, startPos);
return buffer.toString("utf8", 0, bytesRead);
} catch {
return "";
} finally {
await fh?.close();
}
}

// Line-based fallback when byte positions are unavailable (4.x reports with a
// missing fragment, or unusable 5.x position fields): reconstruct the snippet
// from the file's line range.
async function readFragment(file, startLine, endLine, cache) {
try {
if (!cache.has(file)) {
cache.set(file, (await readFile(file, "utf8")).split("\n"));
}
return cache
.get(file)
.slice(startLine - 1, endLine)
.join("\n");
} catch {
return "";
}
}

export async function parseReport(
raw,
{ maxDuplicates, maxFragmentLength } = {},
{ maxDuplicates, maxFragmentLength, scanPath, jscpdMajor: major = 0 } = {},
) {
const limit = maxDuplicates ?? DEFAULT_MAX_DUPLICATES;
const fragLimit = maxFragmentLength ?? DEFAULT_MAX_FRAGMENT_LENGTH;
const report = JSON.parse(raw);

const duplicates = (report.duplicates || [])
.map((d) => ({
firstFile: d.firstFile.name,
const ranked = (report.duplicates || [])
.slice()
.sort((a, b) => b.lines - a.lines)
.slice(0, limit);

const fileCache = new Map();
const duplicates = [];
for (const d of ranked) {
const dup = {
firstFile: resolveReportPath(d.firstFile.name, scanPath),
firstStart: d.firstFile.startLoc.line,
firstEnd: d.firstFile.endLoc.line,
secondFile: d.secondFile.name,
secondFile: resolveReportPath(d.secondFile.name, scanPath),
secondStart: d.secondFile.startLoc.line,
secondEnd: d.secondFile.endLoc.line,
lines: d.lines,
fragment:
d.fragment && d.fragment.length > fragLimit
? d.fragment.slice(0, fragLimit) + "\n[...truncated]"
: d.fragment,
}))
.sort((a, b) => b.lines - a.lines)
.slice(0, limit);
fragment: d.fragment,
};
if (!dup.fragment) {
const startPos = d.firstFile.startLoc.position;
const endPos = d.firstFile.endLoc.position;
if (
major >= 5 &&
Number.isInteger(startPos) &&
Number.isInteger(endPos)
) {
dup.fragment = await readFragmentByPosition(
dup.firstFile,
startPos,
endPos,
fragLimit,
);
}
if (!dup.fragment) {
dup.fragment = await readFragment(
dup.firstFile,
dup.firstStart,
dup.firstEnd,
fileCache,
);
}
}
if (dup.fragment && dup.fragment.length > fragLimit) {
dup.fragment = dup.fragment.slice(0, fragLimit) + "\n[...truncated]";
}
duplicates.push(dup);
}

const total = report.statistics?.total || {};

Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ cd test/fixtures
# then invoke /drywall:scan in a Claude Code session

# Or run jscpd directly
npx jscpd@4.2.5 --reporters json --output /tmp/drywall-report --min-tokens 30 --min-lines 5 src/
npx jscpd@5.0.12 --reporters json --output /tmp/drywall-report --min-tokens 30 --min-lines 5 src/
cat /tmp/drywall-report/jscpd-report.json

# Test the agent
Expand Down
Loading
Loading