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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Each external check is configured through its **tool's own config file**, exactl
`unused-code` and `duplicate-code` accept `--max-warnings <n>` and fail when findings exceed `n`.

- `unused-code` passes the value to knip's [`--max-issues`](https://knip.dev/reference/cli#--max-issues). Knip configuration errors still fail.
- `duplicate-code` counts jscpd clones. Do not pass jscpd's `--reporters`, `--output`, or `--silent` options with `--max-warnings`; `verifyx` sets them to produce the count.
- `duplicate-code` counts **distinct duplicated regions**, not raw jscpd clones: jscpd reports the same duplication several times (overlapping ranges, and one pattern across N files as N−1 pairs), so clones whose ranges overlap are merged before the count is compared to the budget. The failure output shows both numbers. Do not pass jscpd's `--reporters`, `--output`, or `--silent` options with `--max-warnings`; `verifyx` sets them to produce the count.

```sh
verifyx unused-code --max-warnings 5
Expand Down Expand Up @@ -247,7 +247,7 @@ It first asks how `verify` should run: **run all built-in checks** (`verifyx all
- installs the external checks' tools as `--save-dev`, **skipping any already declared in `package.json` or present in `node_modules`** (so an existing `typescript`/`oxlint` is never re-installed or version-bumped). If the install hits a conflict (e.g. a peer-dependency clash), it isolates the failing package(s), installs the rest, and reports what to install manually at the end instead of aborting,
- writes the **`verify` skill**: the same `SKILL.md` to `.claude/skills/verify/` (Claude) and `.agent-skills/verify/` (cross-vendor), so the integration is identical everywhere,
- appends a one-line pointer to `CLAUDE.md` / `AGENTS.md` (only if not already present; existing content is never rewritten),
- if `unused-code` is selected, adds the other external tools (`oxlint`/`oxfmt`/`skott`/`jscpd`) to knip's `ignoreDependencies` (verifyx runs them at runtime, so knip can't see them and would otherwise report them as unused). Merged into `knip.json` or `package.json#knip` (created if neither exists), adding only what's missing; a code-based `knip.ts`/`knip.js` is left for you to edit.
- if `unused-code` is selected, adds the other external tools (`oxlint`/`oxfmt`/`skott`/`jscpd`) to knip's `ignoreDependencies` (verifyx runs them at runtime, so knip can't see them and would otherwise report them as unused), and adds system binaries your scripts invoke (`uv`, `az`, `python`, `terraform`, …) that knip's own global list doesn't cover to `ignoreBinaries`. Merged into `knip.json` or `package.json#knip` (created if neither exists), adding only what's missing; a code-based `knip.ts`/`knip.js` is left for you to edit.

The skill auto-triggers on "verify"/"run checks", so agents run the checks proactively; the pointer reinforces it for tools that read `CLAUDE.md`/`AGENTS.md` as standing instructions.

Expand Down
8 changes: 5 additions & 3 deletions package-lock.json

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@
"cross-spawn": "^7.0.6",
"enquirer": "^2.4.1",
"minimatch": "^10.2.5",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"unbash": "^4.0.2"
},
"devDependencies": {
"@rollup/plugin-typescript": "^12.3.0",
Expand Down
5 changes: 5 additions & 0 deletions src/checks/external.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ describe('externalFailureHint', () => {
expect(hint).toContain('["skott","src"]')
expect(hint).not.toContain('undefined')
})

it('appends the failure advice when the check declares one', () => {
const hint = externalFailureHint({ name: 'unused-code', bin: 'knip', failureAdvice: 'check for dynamic loading' }, ['knip'])
expect(hint).toContain('check for dynamic loading')
})
})

describe('defineExternalCheck', () => {
Expand Down
12 changes: 9 additions & 3 deletions src/checks/external.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export type ExternalCheckSpec = {
recommended?: boolean
/** Docs / config reference for the underlying tool, surfaced when the check fails. */
docs?: string
/** Extra guidance printed on failure, e.g. how to recognise and suppress the tool's known false positives. */
failureAdvice?: string
/** Extra guard beyond bin presence (e.g. require a tsconfig). */
canRun?: () => boolean
/** Rewrite the tool's captured output before it is printed, e.g. to strip a tool's own hardcoded colouring. */
Expand All @@ -57,12 +59,16 @@ export function selectCommand(spec: Pick<ExternalCheckSpec, 'checkCommand' | 'fi
* tool is otherwise hidden), the exact argv that ran, and where to configure it — so an agent can set up
* the tool's config file (e.g. knip.json) without guessing.
*/
export function externalFailureHint(spec: Pick<ExternalCheckSpec, 'name' | 'bin' | 'docs'>, argv: readonly string[]): string {
return `↳ ${spec.name} uses ${spec.bin}: ran argv \`${formatArgv(argv)}\`. Configure ${spec.bin}${spec.docs ? ` — ${spec.docs}` : ''}.`
export function externalFailureHint(
spec: Pick<ExternalCheckSpec, 'name' | 'bin' | 'docs' | 'failureAdvice'>,
argv: readonly string[],
): string {
const hint = `↳ ${spec.name} uses ${spec.bin}: ran argv \`${formatArgv(argv)}\`. Configure ${spec.bin}${spec.docs ? ` — ${spec.docs}` : ''}.`
return spec.failureAdvice ? `${hint}\n↳ ${spec.failureAdvice}` : hint
}

type CountBudget = Extract<MaxWarningsSupport, { strategy: 'count' }>
type CountableSpec = Pick<ExternalCheckSpec, 'name' | 'bin' | 'checkCommand' | 'docs' | 'transformOutput'>
type CountableSpec = Pick<ExternalCheckSpec, 'name' | 'bin' | 'checkCommand' | 'docs' | 'failureAdvice' | 'transformOutput'>

export async function runCountedBudget(
spec: CountableSpec,
Expand Down
82 changes: 79 additions & 3 deletions src/checks/maxWarnings.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,35 @@
import { describe, expect, it } from 'vitest'
import fs from 'node:fs'
import path from 'node:path'

import { countJscpdClones, withinBudget } from './maxWarnings.ts'
import { afterEach, describe, expect, it, vi } from 'vitest'

const captureArgvCommand = vi.hoisted(() => vi.fn())

vi.mock('../shared/spawn.ts', () => ({ captureArgvCommand }))

import { countJscpdClones, jscpdCount, withinBudget } from './maxWarnings.ts'

function clone(aName: string, aStart: number, aEnd: number, bName: string, bStart: number, bEnd: number) {
return {
firstFile: { name: aName, start: aStart, end: aEnd },
secondFile: { name: bName, start: bStart, end: bEnd },
}
}

function mockJscpdReport(duplicates: ReturnType<typeof clone>[]) {
captureArgvCommand.mockImplementationOnce(async (argv: readonly string[]) => {
const outputIndex = argv.indexOf('--output')
const outputDir = argv[outputIndex + 1]
if (!outputDir) throw new Error('missing jscpd output directory')
fs.writeFileSync(path.join(outputDir, 'jscpd-report.json'), JSON.stringify({ duplicates }))
return { code: 1, stdout: 'clone report\n', stderr: '' }
})
}

afterEach(() => captureArgvCommand.mockReset())

describe('countJscpdClones', () => {
it('reads the total clone count from statistics', () => {
it('reads the total clone count from statistics when duplicates lack range info', () => {
const report = { duplicates: [{}, {}, {}], statistics: { total: { clones: 3 } } }
expect(countJscpdClones(report)).toBe(3)
})
Expand All @@ -12,6 +38,36 @@ describe('countJscpdClones', () => {
expect(countJscpdClones({ duplicates: [{}, {}] })).toBe(2)
})

it('counts disjoint clones as separate regions', () => {
const report = { duplicates: [clone('a.ts', 1, 10, 'b.ts', 1, 10), clone('a.ts', 50, 60, 'c.ts', 1, 10)] }
expect(countJscpdClones(report)).toBe(2)
})

it('merges clones reported over overlapping ranges into one region', () => {
const report = { duplicates: [clone('a.ts', 100, 120, 'b.ts', 10, 30), clone('a.ts', 110, 130, 'b.ts', 20, 40)] }
expect(countJscpdClones(report)).toBe(1)
})

it('merges the same pattern repeated across N files into one region', () => {
const report = {
duplicates: [clone('x.ts', 1, 20, 'y.ts', 1, 20), clone('x.ts', 1, 20, 'z.ts', 1, 20), clone('y.ts', 1, 20, 'z.ts', 1, 20)],
}
expect(countJscpdClones(report)).toBe(1)
})

it('does not merge clones in the same file at non-overlapping ranges', () => {
const report = { duplicates: [clone('a.ts', 979, 986, 'a.ts', 1085, 1092), clone('a.ts', 1008, 1023, 'a.ts', 1101, 1115)] }
expect(countJscpdClones(report)).toBe(2)
})

it('prefers the deduped region count over the raw statistics total', () => {
const report = {
duplicates: [clone('a.ts', 1, 20, 'b.ts', 1, 20), clone('a.ts', 5, 25, 'c.ts', 1, 20)],
statistics: { total: { clones: 2 } },
}
expect(countJscpdClones(report)).toBe(1)
})

it('returns 0 for a clean report', () => {
expect(countJscpdClones({ duplicates: [], statistics: { total: { clones: 0 } } })).toBe(0)
})
Expand All @@ -27,6 +83,26 @@ describe('countJscpdClones', () => {
})
})

describe('jscpdCount', () => {
it('reports both counts when raw clones merge into fewer regions', async () => {
mockJscpdReport([clone('a.ts', 1, 20, 'b.ts', 1, 20), clone('a.ts', 5, 25, 'c.ts', 1, 20)])

const result = await jscpdCount({ argv: ['jscpd', 'src'], env: {} })

expect(result.count).toBe(1)
expect(result.report).toContain('1 distinct duplicated region(s) merged from 2 raw jscpd clone(s).')
})

it('omits the merged-count summary when no clones were merged', async () => {
mockJscpdReport([clone('a.ts', 1, 20, 'b.ts', 1, 20)])

const result = await jscpdCount({ argv: ['jscpd', 'src'], env: {} })

expect(result.count).toBe(1)
expect(result.report).toBe('clone report\n')
})
})

describe('withinBudget', () => {
it('passes when the count is at or below the budget (budget is inclusive)', () => {
expect(withinBudget(3, 5)).toBe(true)
Expand Down
53 changes: 51 additions & 2 deletions src/checks/maxWarnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,53 @@ export function withinBudget(count: number, maxWarnings: number): boolean {
return count <= maxWarnings
}

type CloneRange = { name: string; start: number; end: number }
type CloneRanges = [CloneRange, CloneRange]

function parseCloneRange(value: unknown): CloneRange | undefined {
if (typeof value !== 'object' || value === null) return undefined
const { name, start, end } = value as { name?: unknown; start?: unknown; end?: unknown }
if (typeof name !== 'string' || typeof start !== 'number' || typeof end !== 'number') return undefined
return { name, start, end }
}

function parseCloneRangePairs(duplicates: readonly unknown[]): CloneRanges[] | undefined {
const pairs: CloneRanges[] = []
for (const entry of duplicates) {
const { firstFile, secondFile } = (entry ?? {}) as { firstFile?: unknown; secondFile?: unknown }
const a = parseCloneRange(firstFile)
const b = parseCloneRange(secondFile)
if (!a || !b) return undefined
pairs.push([a, b])
}
return pairs
}

function rangesOverlap(a: CloneRange, b: CloneRange): boolean {
return a.name === b.name && a.start <= b.end && b.start <= a.end
}

function clonesShareRegion(a: CloneRanges, b: CloneRanges): boolean {
return a.some((aRange) => b.some((bRange) => rangesOverlap(aRange, bRange)))
}

/** One pattern across N files yields N-1 jscpd pairs (plus overlapping re-reports), so the budget gates on connected components, not raw pairs. */
function countDistinctCloneRegions(pairs: readonly CloneRanges[]): number {
const regionOf = pairs.map((_, i) => i)
const rootOf = (i: number): number => (regionOf[i] === i ? i : (regionOf[i] = rootOf(regionOf[i] as number)))
for (let i = 0; i < pairs.length; i++) {
for (let j = i + 1; j < pairs.length; j++) {
if (clonesShareRegion(pairs[i] as CloneRanges, pairs[j] as CloneRanges)) regionOf[rootOf(j)] = rootOf(i)
}
}
return new Set(pairs.map((_, i) => rootOf(i))).size
}

export function countJscpdClones(report: { statistics?: { total?: { clones?: unknown } }; duplicates?: unknown }): number {
if (Array.isArray(report.duplicates)) {
const pairs = parseCloneRangePairs(report.duplicates)
if (pairs) return countDistinctCloneRegions(pairs)
}
const clones = report.statistics?.total?.clones
if (typeof clones === 'number' && Number.isInteger(clones) && clones >= 0) return clones
if (Array.isArray(report.duplicates)) return report.duplicates.length
Expand All @@ -33,12 +79,15 @@ export async function jscpdCount(ctx: MaxWarningsCountContext): Promise<CountRes
const { code, stdout, stderr } = await captureArgvCommand(argv, { env: ctx.env })
Comment thread
PatrickDinh marked this conversation as resolved.
const reportPath = path.join(dir, 'jscpd-report.json')
if (!fs.existsSync(reportPath)) throw new Error(`jscpd produced no report (exit ${code})${stderr.trim() ? `: ${stderr.trim()}` : ''}`)
const count = countJscpdClones(JSON.parse(fs.readFileSync(reportPath, 'utf8')))
const parsed = JSON.parse(fs.readFileSync(reportPath, 'utf8')) as { duplicates?: unknown }
const count = countJscpdClones(parsed)
const rawClones = Array.isArray(parsed.duplicates) ? parsed.duplicates.length : count
// Drop the json reporter's "report saved to <dir>" line — the temp dir is deleted before anyone could read it.
const report = (stdout + stderr)
let report = (stdout + stderr)
.split('\n')
.filter((line) => !line.includes(dir))
.join('\n')
if (rawClones > count) report += `\n↳ ${count} distinct duplicated region(s) merged from ${rawClones} raw jscpd clone(s).\n`
return { count, report }
} finally {
// Retry transient Windows locks on the new report.
Expand Down
4 changes: 3 additions & 1 deletion src/checks/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export const CHECKS: Check[] = [
checkCommand: ['knip', '--no-progress', '--treat-config-hints-as-errors'],
devDeps: ['knip'],
docs: 'https://knip.dev/reference/configuration',
failureAdvice:
'An "unused" finding can be a false positive when the file is loaded dynamically (directory scan + require(), glob-registered ORM entities) or a script calls a system binary. Verify before deleting: suppress genuinely runtime-loaded files via knip `entry` globs and system tools via `ignoreBinaries` — never delete a file to satisfy this check without checking how it is loaded.',
maxWarnings: { strategy: 'flag', toArgs: (n) => ['--max-issues', String(n)] },
}),
defineExternalCheck({
Expand All @@ -90,7 +92,7 @@ export const CHECKS: Check[] = [
// NO_COLOR/FORCE_COLOR, so strip the red foreground from its output; the table renders in the default colour.
transformOutput: withoutRed,
docs: 'https://github.com/kucherenko/jscpd/tree/master/apps/jscpd#config',
maxWarnings: { strategy: 'count', unit: 'clone', count: jscpdCount },
maxWarnings: { strategy: 'count', unit: 'duplicated region', count: jscpdCount },
}),
]

Expand Down
Loading