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.2.1",
"version": "0.2.2",
"description": "Detect and eliminate code duplication using jscpd",
"mcpServers": {
"jscpd": {
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,11 @@ jobs:
- run: npm run build
- name: Check build is up-to-date
run: git diff --exit-code servers/jscpd.js
- name: Check versions match
run: |
PKG=$(node -p "require('./package.json').version")
PLUGIN=$(node -p "require('./.claude-plugin/plugin.json').version")
if [ "$PKG" != "$PLUGIN" ]; then
echo "::error::Version mismatch: package.json=$PKG, plugin.json=$PLUGIN"
exit 1
fi
8 changes: 6 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@ Projects using DRYwall configure via `.drywallrc.json` at project root. Keys `js
## Development

- `npm install` — install dependencies
- `npm run build` — bundle `src/jscpd.js` to `servers/jscpd.js`
- The bundle must be rebuilt and committed after changes to `src/jscpd.js`
- `npm run build` — bundle `src/jscpd.js` (and its dependency `src/lib.js`) to `servers/jscpd.js` via `build.js`
- The bundle must be rebuilt and committed after changes to `src/jscpd.js` or `src/lib.js`
- `npm test` — run unit tests (Node.js built-in test runner)
- Tests live in `test/**/*.test.js` and cover `src/lib.js` (helpers extracted for testability)
- `test/fixtures/` contains a sample codebase with intentional duplication for manual testing

## Version

The version is defined in `package.json` and injected into the bundle at build time via esbuild's `define`. It must also be kept in sync in `.claude-plugin/plugin.json` — CI verifies this.
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ Create a `.drywallrc.json` in your project root to set defaults. Values correspo
"minLines": 5,
"ignore": ["**/node_modules/**", "**/dist/**", "**/*.generated.*"],
"respectGitignore": true,
"jscpdVersion": "4.0.8"
"jscpdVersion": "4.0.9"
}
```

The configuration options specific to DRYwall are:

- **`respectGitignore`** — `true` by default. Passes `--gitignore` to jscpd so that files excluded by `.gitignore` are automatically skipped (but: jscpd's implementation of this [is buggy](https://github.com/kucherenko/jscpd/pull/752) - some lines from your gitignore may not work as expected). Set to `false` to disable.
- **`jscpdVersion`** — Pin the jscpd version used via `npx`. Defaults to `4.0.8` if not set.
- **`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.0.9` if not set.
- **`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 Expand Up @@ -95,6 +95,20 @@ Parameters:
- `maxDuplicates` — maximum number of duplicate pairs to return, ranked by impact (default: `20`)
- `maxFragmentLength` — maximum character length of each code fragment before truncation (default: `500`)

## Development

```
npm install
npm test
npm run build
```

To test the plugin locally in Claude Code:

```
claude --plugin-dir /path/to/drywall
```

## License

This project is licensed under the [MIT License](./LICENSE.txt).
16 changes: 16 additions & 0 deletions build.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { readFileSync } from "node:fs";
import { build } from "esbuild";

const { version } = JSON.parse(readFileSync("package.json", "utf8"));

await build({
entryPoints: ["src/jscpd.js"],
bundle: true,
platform: "node",
format: "esm",
outfile: "servers/jscpd.js",
banner: { js: "#!/usr/bin/env node" },
minify: true,
treeShaking: true,
define: { DRYWALL_VERSION: JSON.stringify(version) },
});
32 changes: 16 additions & 16 deletions package-lock.json

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

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"name": "drywall",
"version": "0.2.1",
"version": "0.2.2",
"private": true,
"type": "module",
"scripts": {
"build": "esbuild src/jscpd.js --bundle --platform=node --format=esm --outfile=servers/jscpd.js --banner:js='#!/usr/bin/env node' --minify --tree-shaking=true",
"test": "node --test 'test/**/*.test.js'",
"build": "node build.js",
"test": "node --import ./test/setup.js --test 'test/**/*.test.js'",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
Expand Down
14 changes: 7 additions & 7 deletions servers/jscpd.js

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions src/jscpd.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
DEFAULT_VERSION,
DEFAULT_MAX_DUPLICATES,
DEFAULT_MAX_FRAGMENT_LENGTH,
REPORT_PATH,
createReportDir,
buildArgs,
readConfig,
runJscpd,
Expand Down Expand Up @@ -68,15 +68,16 @@ server.registerTool(
try {
const config = await readConfig();
const version = config.jscpdVersion || DEFAULT_VERSION;
const { reportDir, reportPath } = await createReportDir();

const args = buildArgs(config, options);
const args = buildArgs(config, options, reportDir);

// The path argument goes last (positional, not a flag)
const targetPath = scanPath || config.path || ".";
args.push(targetPath);

const { cmd } = await runJscpd(version, args);
const raw = await readFile(REPORT_PATH, "utf8");
const raw = await readFile(reportPath, "utf8");
const result = await parseReport(raw, {
maxDuplicates: maxDuplicates ?? config.maxDuplicates,
maxFragmentLength: maxFragmentLength ?? config.maxFragmentLength,
Expand Down
23 changes: 16 additions & 7 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 } from "node:fs/promises";
import { readFile, mkdtemp } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";

export const VERSION = "0.2.1";
export const DEFAULT_VERSION = "4.0.8";
export const REPORT_DIR = "/tmp/drywall-report";
export const REPORT_PATH = join(REPORT_DIR, "jscpd-report.json");
export const VERSION = DRYWALL_VERSION;
export const DEFAULT_VERSION = "4.0.9";

export async function createReportDir() {
const dir = await mkdtemp(join(tmpdir(), "drywall-report-"));
return { reportDir: dir, reportPath: join(dir, "jscpd-report.json") };
}
export const DRYWALL_KEYS = new Set([
"jscpdVersion",
"respectGitignore",
Expand All @@ -27,7 +31,7 @@ export async function readConfig() {
}
}

export function buildArgs(config, toolArgs) {
export function buildArgs(config, toolArgs, reportDir) {
const { jscpdVersion, respectGitignore, ...jscpdConfig } = config;
const merged = { ...jscpdConfig, ...toolArgs };
const args = [];
Expand All @@ -52,11 +56,16 @@ export function buildArgs(config, toolArgs) {
}
}

args.push("--reporters", "json", "--output", REPORT_DIR);
args.push("--reporters", "json", "--output", reportDir);
return args;
}

const VERSION_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;

export function runJscpd(version, args) {
if (!VERSION_RE.test(version)) {
throw new Error(`Invalid jscpd version: "${version}"`);
}
const fullArgs = [`jscpd@${version}`, ...args];
const cmd = ["npx", ...fullArgs];
return new Promise((resolve, reject) => {
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.0.8 --reporters json --output /tmp/drywall-report --min-tokens 30 --min-lines 5 src/
npx jscpd@4.0.9 --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
46 changes: 35 additions & 11 deletions test/lib.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import assert from "node:assert/strict";
import {
camelToKebab,
buildArgs,
runJscpd,
parseReport,
REPORT_DIR,
DRYWALL_KEYS,
DEFAULT_MAX_DUPLICATES,
DEFAULT_MAX_FRAGMENT_LENGTH,
} from "../src/lib.js";

const TEST_REPORT_DIR = "/tmp/test-report";

describe("camelToKebab", () => {
it("converts single boundary", () => {
assert.equal(camelToKebab("minTokens"), "min-tokens");
Expand All @@ -30,32 +31,40 @@ describe("camelToKebab", () => {

describe("buildArgs", () => {
it("adds --gitignore by default", () => {
const args = buildArgs({}, {});
const args = buildArgs({}, {}, TEST_REPORT_DIR);
assert.ok(args.includes("--gitignore"));
});

it("omits --gitignore when respectGitignore is false", () => {
const args = buildArgs({ respectGitignore: false }, {});
const args = buildArgs({ respectGitignore: false }, {}, TEST_REPORT_DIR);
assert.ok(!args.includes("--gitignore"));
});

it("converts camelCase config keys to kebab-case flags", () => {
const args = buildArgs({ minTokens: 30 }, {});
const args = buildArgs({ minTokens: 30 }, {}, TEST_REPORT_DIR);
const idx = args.indexOf("--min-tokens");
assert.ok(idx !== -1);
assert.equal(args[idx + 1], "30");
});

it("tool args override config values", () => {
const args = buildArgs({ minTokens: 30 }, { minTokens: 50 });
const args = buildArgs(
{ minTokens: 30 },
{ minTokens: 50 },
TEST_REPORT_DIR,
);
const idx = args.indexOf("--min-tokens");
assert.equal(args[idx + 1], "50");
// should only appear once
assert.equal(args.lastIndexOf("--min-tokens"), idx);
});

it("handles array values as repeated flags", () => {
const args = buildArgs({}, { ignore: ["**/test/**", "**/vendor/**"] });
const args = buildArgs(
{},
{ ignore: ["**/test/**", "**/vendor/**"] },
TEST_REPORT_DIR,
);
const indices = args.reduce(
(acc, v, i) => (v === "--ignore" ? [...acc, i] : acc),
[],
Expand All @@ -66,33 +75,34 @@ describe("buildArgs", () => {
});

it("handles boolean true as flag without value", () => {
const args = buildArgs({}, { silent: true });
const args = buildArgs({}, { silent: true }, TEST_REPORT_DIR);
assert.ok(args.includes("--silent"));
});

it("skips boolean false", () => {
const args = buildArgs({}, { silent: false });
const args = buildArgs({}, { silent: false }, TEST_REPORT_DIR);
assert.ok(!args.includes("--silent"));
});

it("skips DRYwall-specific keys", () => {
const args = buildArgs(
{ jscpdVersion: "5.0.0", respectGitignore: true, path: "src/" },
{},
TEST_REPORT_DIR,
);
assert.ok(!args.includes("--jscpd-version"));
assert.ok(!args.includes("--respect-gitignore"));
assert.ok(!args.includes("--path"));
});

it("always appends --reporters json and --output", () => {
const args = buildArgs({}, {});
const args = buildArgs({}, {}, TEST_REPORT_DIR);
const reportersIdx = args.indexOf("--reporters");
assert.ok(reportersIdx !== -1);
assert.equal(args[reportersIdx + 1], "json");
const outputIdx = args.indexOf("--output");
assert.ok(outputIdx !== -1);
assert.equal(args[outputIdx + 1], REPORT_DIR);
assert.equal(args[outputIdx + 1], TEST_REPORT_DIR);
});
});

Expand Down Expand Up @@ -301,3 +311,17 @@ describe("parseReport", () => {
assert.ok(result.duplicates[0].fragment.length < 200);
});
});

describe("runJscpd", () => {
it("rejects invalid version strings", () => {
assert.throws(
() => runJscpd("../../malicious-pkg", []),
/Invalid jscpd version/,
);
assert.throws(() => runJscpd("jscpd@evil", []), /Invalid jscpd version/);
assert.throws(
() => runJscpd("1.0.0; rm -rf /", []),
/Invalid jscpd version/,
);
});
});
4 changes: 4 additions & 0 deletions test/setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { readFileSync } from "node:fs";

const { version } = JSON.parse(readFileSync("package.json", "utf8"));
globalThis.DRYWALL_VERSION = version;
Loading