code-gauge 9.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +58 -32
  2. package/dist/checkCommand.cjs +2 -2
  3. package/dist/checkCommand.cjs.map +1 -1
  4. package/dist/checkCommand.d.ts +2 -3
  5. package/dist/checkCommand.js +2 -2
  6. package/dist/checkCommand.js.map +1 -1
  7. package/dist/cli.cjs +3 -3
  8. package/dist/cli.cjs.map +1 -1
  9. package/dist/cli.js +3 -3
  10. package/dist/cli.js.map +1 -1
  11. package/dist/crossFileDuplication.cjs +1 -1
  12. package/dist/crossFileDuplication.cjs.map +1 -1
  13. package/dist/crossFileDuplication.d.ts +11 -0
  14. package/dist/crossFileDuplication.js +1 -1
  15. package/dist/crossFileDuplication.js.map +1 -1
  16. package/dist/index.d.ts +1 -1
  17. package/dist/metrics.cjs +1 -1
  18. package/dist/metrics.cjs.map +1 -1
  19. package/dist/metrics.js +1 -1
  20. package/dist/metrics.js.map +1 -1
  21. package/dist/nativeMetrics.cjs +1 -1
  22. package/dist/nativeMetrics.cjs.map +1 -1
  23. package/dist/nativeMetrics.d.ts +4 -2
  24. package/dist/nativeMetrics.js +1 -1
  25. package/dist/nativeMetrics.js.map +1 -1
  26. package/dist/thresholdCheck.cjs +1 -1
  27. package/dist/thresholdCheck.cjs.map +1 -1
  28. package/dist/thresholdCheck.d.ts +18 -4
  29. package/dist/thresholdCheck.js +1 -1
  30. package/dist/thresholdCheck.js.map +1 -1
  31. package/dist/thresholds.cjs +1 -1
  32. package/dist/thresholds.cjs.map +1 -1
  33. package/dist/thresholds.d.ts +8 -3
  34. package/dist/thresholds.js +1 -1
  35. package/dist/thresholds.js.map +1 -1
  36. package/dist/types.d.ts +48 -0
  37. package/dist/wasmBinding.cjs +1 -1
  38. package/dist/wasmBinding.cjs.map +1 -1
  39. package/dist/wasmBinding.js +1 -1
  40. package/dist/wasmBinding.js.map +1 -1
  41. package/native/code-gauge.wasm +0 -0
  42. package/native/src/complexity.rs +173 -19
  43. package/native/src/container.rs +413 -0
  44. package/native/src/duplication.rs +32 -17
  45. package/native/src/functions.rs +2 -5
  46. package/native/src/lib.rs +2 -1
  47. package/native/src/measure.rs +24 -8
  48. package/native/src/types.rs +24 -0
  49. package/package.json +8 -8
package/dist/cli.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.cjs","names":["getErrorMessage","Command","levels","thresholds","runCheckCommand","configFileName","duplicationThreshold","fileThresholds","functionThresholds","resolveTarget","configSearchDirectory","loadConfig","resolveOptions","scanTarget","loadExclusion","formatPath","collectDuplicatedLineNumbers","InvalidArgumentError","parsePositiveInteger"],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { Command, InvalidArgumentError } from 'commander';\nimport { getErrorMessage, parsePositiveInteger } from '@willbooster/shared-lib';\nimport { type CliOptions, configFileName, loadConfig, type ResolvedOptions, resolveOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { runCheckCommand, type CheckCliOptions } from './checkCommand.js';\nimport { loadExclusion } from './exclusion.js';\nimport {\n addCrossFileDuplication,\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatPath,\n resolveTarget,\n scanTarget,\n writeStderr,\n writeStdout,\n type FileMetrics,\n type ScanResult,\n} from './scan.js';\nimport {\n duplicationThreshold,\n fileThresholds,\n functionThresholds,\n levels,\n thresholds,\n type Level,\n type Limits,\n} from './thresholds.js';\nimport type { FunctionMetrics } from './types.js';\n\n/** The worst (highest-cognitive-complexity) function of a file, reported as the ranking evidence. */\ninterface WorstFunction {\n name: string;\n startLine: number;\n endLine: number;\n cognitiveComplexity: number;\n ncss: number;\n nestingDepth: number;\n}\n\n/**\n * One ranked refactoring candidate. The score is the sum of the file's repo-relative percentile\n * ranks (each in [0, 1)) over three dimensions: worst-function cognitive complexity, duplicated\n * lines (within-file and cross-file combined), and file NCSS. Ranking is relative to the scanned\n * project, so no absolute threshold is involved.\n */\ninterface RankedFile {\n file: string;\n score: number;\n worstFunction?: WorstFunction;\n /** Distinct lines covered by within-file duplicate blocks or cross-file duplicate occurrences. */\n duplicatedLineCount: number;\n /** duplicatedLineCount / code lines (0 when the file has no code). */\n duplicatedLineRatio: number;\n /** Other files sharing cross-file duplicate blocks; filled only for the reported top files. */\n crossFilePartners: string[];\n ncss: number;\n codeLines: number;\n}\n\n/** Caps how many cross-file partners a ranked file lists so the report stays scannable. */\nconst maxCrossFilePartners = 3;\n\n// oxlint-disable-next-line unicorn/prefer-top-level-await -- CommonJS build output cannot preserve top-level await.\nvoid main().catch((error: unknown) => {\n writeStderr(`Error: ${getErrorMessage(error)}\\n`);\n process.exitCode = 1;\n});\n\nasync function main(): Promise<void> {\n const program = new Command()\n .name('code-gauge')\n .description('Measure code metrics: rank refactoring candidates (the default command) or check thresholds.');\n\n addSharedOptions(\n program\n .command('rank', { isDefault: true })\n .description('Rank the files of a project by refactoring priority')\n .argument('[target]', 'file or directory to measure', '.')\n )\n .option('--top <number>', 'number of top-ranked files to report (default: 10)', parsePositiveIntegerOption)\n .option('--fail-on-error', 'exit with code 1 when files or directories cannot be scanned')\n .action(runRankCommand);\n\n const check = addSharedOptions(\n program\n .command('check')\n .description(\n 'Report every exceeded threshold as a warning or an error (exit 1 on errors, 2 when files cannot be measured); with --base, only in what the change touches'\n )\n .argument('[target]', 'file or directory to check', '.')\n ).option('--base <ref>', 'check only what the working tree changed since the merge-base of this git ref and HEAD');\n for (const level of levels) {\n for (const { key, defaultLimits } of thresholds) {\n const defaultLimit = defaultLimits[level];\n check.option(\n `--${level}-${toKebabCase(key)} <number>`,\n `${describeLimit(key, level)} (default ${Number.isFinite(defaultLimit) ? defaultLimit : 'off'}); \"off\" disables it`,\n parseLimit\n );\n }\n }\n // Exit code 1 means error-level violations, so a rejected command line must not use it.\n check.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : 2));\n check.action(async (target: string, cliOptions: CheckCliOptions & Record<string, unknown>) => {\n const readLimits = (level: Level): Limits =>\n Object.fromEntries(\n thresholds.flatMap(({ key }) => {\n const limit = cliOptions[`${level}${key.charAt(0).toUpperCase()}${key.slice(1)}`];\n return limit === undefined ? [] : [[key, limit as number]];\n })\n );\n await runCheckCommand(target, cliOptions, { warning: readLimits('warning'), error: readLimits('error') });\n });\n\n await program.parseAsync();\n}\n\n/** Registers the options shared by the ranking and check commands. */\nfunction addSharedOptions(command: Command): Command {\n return command\n .option('--config <path>', `config file to use instead of the auto-detected ${configFileName}`)\n .option(\n '--duplication-min-tokens <number>',\n 'minimum normalized token count for a duplicate region (default 40)',\n parsePositiveIntegerOption\n )\n .option(\n '--duplication-max-gap-tokens <number>',\n 'maximum token gap merged into one gapped clone group; 0 disables merging (default 30)',\n parseNonNegativeInteger\n )\n .option(\n '--duplication-min-similarity-percent <number>',\n 'minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)',\n parsePercentInteger\n )\n .option('--include-tests', 'include test files and test directories')\n .option('--json', 'print JSON output');\n}\n\nfunction describeLimit(key: string, level: Level): string {\n if (key === duplicationThreshold.key) {\n return `line count from which a duplicated block is ${level === 'error' ? 'an error' : 'a warning'}`;\n }\n const fileThreshold = fileThresholds.find((threshold) => threshold.key === key);\n const subject = fileThreshold\n ? fileThreshold.label\n : `${functionThresholds.find((threshold) => threshold.key === key)?.label} of a function`;\n return `maximum ${subject} without ${level === 'error' ? 'an error' : 'a warning'}`;\n}\n\nfunction toKebabCase(key: string): string {\n return key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);\n}\n\nasync function runRankCommand(target: string, cliOptions: CliOptions): Promise<void> {\n const resolvedTarget = resolveTarget(target);\n const searchDirectory = await configSearchDirectory(resolvedTarget);\n const config = await loadConfig(cliOptions.config, searchDirectory);\n const options = resolveOptions(cliOptions, config);\n const result = await scanTarget(resolvedTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n addCrossFileDuplication(result, options);\n const rankedFiles = rankFiles(result, options.top);\n\n if (options.json) {\n printJson(result, rankedFiles, options);\n } else {\n printTextReport(resolvedTarget, result, rankedFiles, options);\n }\n\n if (result.fatalError || (options.failOnError && result.errors.length > 0)) {\n process.exitCode = 1;\n }\n}\n\nfunction rankFiles(result: ScanResult, top: number): RankedFile[] {\n const candidates = result.files.map(({ file, metrics }) => {\n const formattedFile = formatPath(file, result.displayRoot);\n const duplicatedLines = collectDuplicatedLineNumbers(metrics, result.crossFileDuplication, formattedFile);\n return {\n file: formattedFile,\n worstFunction: findWorstFunction(metrics.functions),\n duplicatedLineCount: duplicatedLines.size,\n duplicatedLineRatio: metrics.lines.code === 0 ? 0 : duplicatedLines.size / metrics.lines.code,\n crossFilePartners: [] as string[],\n ncss: metrics.ncssCount,\n codeLines: metrics.lines.code,\n };\n });\n\n const cognitivePercentile = makePercentile(candidates.map((c) => c.worstFunction?.cognitiveComplexity ?? 0));\n const duplicationPercentile = makePercentile(candidates.map((c) => c.duplicatedLineCount));\n const ncssPercentile = makePercentile(candidates.map((c) => c.ncss));\n\n const ranked = candidates\n .map((candidate) => ({\n ...candidate,\n score:\n cognitivePercentile(candidate.worstFunction?.cognitiveComplexity ?? 0) +\n duplicationPercentile(candidate.duplicatedLineCount) +\n ncssPercentile(candidate.ncss),\n }))\n .toSorted(\n (left, right) => right.score - left.score || right.ncss - left.ncss || left.file.localeCompare(right.file)\n );\n // Partner evidence is attached only to the files that will be reported: expanding partners for\n // every scanned file first would retain TH(F^2) strings when one clone group spans F files, and\n // rescanning all groups per file would be O(files x groups) on the post-scan ranking step.\n attachCrossFilePartners(ranked.slice(0, top), result.crossFileDuplication);\n return ranked;\n}\n\n/** One pass over the groups fills the reported files' partner lists (other files sharing a group). */\nfunction attachCrossFilePartners(\n reportedFiles: RankedFile[],\n crossFileDuplication: CrossFileDuplicationMetrics | undefined\n): void {\n if (!crossFileDuplication) {\n return;\n }\n const partnersByFile = new Map(reportedFiles.map((ranked) => [ranked.file, new Set<string>()]));\n for (const group of crossFileDuplication.groups) {\n for (const file of group.files) {\n const partners = partnersByFile.get(file);\n if (!partners) {\n continue;\n }\n for (const partner of group.files) {\n if (partner !== file) {\n partners.add(partner);\n }\n }\n }\n }\n for (const ranked of reportedFiles) {\n ranked.crossFilePartners = [...(partnersByFile.get(ranked.file) ?? [])].toSorted();\n }\n}\n\n/**\n * Percentile rank within the scanned project: the fraction of files with a strictly smaller\n * value, in [0, 1). Relative ranking needs no absolute threshold, which sidesteps the metric\n * calibration problem entirely — the top of the list is worth refactoring first regardless of\n * where any cutoff would sit.\n */\nfunction makePercentile(values: number[]): (value: number) => number {\n const sorted = values.toSorted((left, right) => left - right);\n return (value: number) => {\n let low = 0;\n let high = sorted.length;\n while (low < high) {\n const middle = (low + high) >> 1;\n if ((sorted[middle] as number) < value) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return sorted.length === 0 ? 0 : low / sorted.length;\n };\n}\n\n/** The highest-cognitive-complexity function; NCSS breaks ties so the larger body is reported. */\nfunction findWorstFunction(functions: FunctionMetrics[]): WorstFunction | undefined {\n let worst: FunctionMetrics | undefined;\n for (const fn of functions) {\n if (\n !worst ||\n fn.cognitiveComplexity > worst.cognitiveComplexity ||\n (fn.cognitiveComplexity === worst.cognitiveComplexity && fn.ncss > worst.ncss)\n ) {\n worst = fn;\n }\n }\n if (!worst) {\n return undefined;\n }\n return {\n name: worst.name ?? '<anonymous>',\n startLine: worst.startLine,\n endLine: worst.endLine,\n cognitiveComplexity: worst.cognitiveComplexity,\n ncss: worst.ncss,\n nestingDepth: worst.nestingDepth,\n };\n}\n\nfunction printJson(result: ScanResult, rankedFiles: RankedFile[], options: ResolvedOptions): void {\n const reportedFiles = rankedFiles.slice(0, options.top);\n writeStdout(\n JSON.stringify(\n {\n summary: summarize(result.files),\n totalRankedFiles: rankedFiles.length,\n truncated: reportedFiles.length < rankedFiles.length,\n files: reportedFiles,\n errors: result.errors,\n warnings: result.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n\nfunction printTextReport(\n target: string,\n result: ScanResult,\n rankedFiles: RankedFile[],\n options: ResolvedOptions\n): void {\n if (result.fatalError) {\n writeStderr(`Error: ${result.fatalError}\\n`);\n return;\n }\n\n const summary = summarize(result.files);\n writeStdout(\n `Measured ${summary.fileCount} files under ${target} (code LOC ${summary.linesOfCode}, NCSS ${summary.ncssCount}, functions ${summary.functionCount})\\n`\n );\n\n if (rankedFiles.length === 0) {\n writeStdout('No measurable files found.\\n');\n } else {\n const reportedFiles = rankedFiles.slice(0, options.top);\n const totalSuffix = rankedFiles.length > reportedFiles.length ? ` of ${rankedFiles.length}` : '';\n writeStdout(`\\nRefactoring candidates (top ${reportedFiles.length}${totalSuffix}):\\n`);\n for (const [index, ranked] of reportedFiles.entries()) {\n writeStdout(`${index + 1}. ${formatRankedFile(ranked)}\\n`);\n }\n }\n\n if (result.warnings.length > 0) {\n writeStderr(`\\nDegraded ${result.warnings.length} files (measured, but excluded from cross-file matching):\\n`);\n for (const warning of result.warnings.slice(0, 10)) {\n writeStderr(`- ${warning}\\n`);\n }\n if (result.warnings.length > 10) {\n writeStderr(`- ... ${result.warnings.length - 10} more\\n`);\n }\n }\n\n if (result.errors.length > 0) {\n writeStderr(`\\nSkipped ${result.errors.length} files or directories:\\n`);\n for (const error of result.errors.slice(0, 10)) {\n writeStderr(`- ${error}\\n`);\n }\n if (result.errors.length > 10) {\n writeStderr(`- ... ${result.errors.length - 10} more\\n`);\n }\n }\n}\n\n/** One ranked file as a single line: the location, the evidence, and where the duplication points. */\nfunction formatRankedFile(ranked: RankedFile): string {\n const reasons: string[] = [];\n if (ranked.worstFunction) {\n const fn = ranked.worstFunction;\n reasons.push(\n `worst function ${fn.name} (L${fn.startLine}-${fn.endLine}) cognitive ${fn.cognitiveComplexity}, NCSS ${fn.ncss}, nesting ${fn.nestingDepth}`\n );\n }\n if (ranked.duplicatedLineCount > 0) {\n const partnersSuffix =\n ranked.crossFilePartners.length > 0\n ? `, shared with ${ranked.crossFilePartners.slice(0, maxCrossFilePartners).join(', ')}${ranked.crossFilePartners.length > maxCrossFilePartners ? ', ...' : ''}`\n : '';\n reasons.push(\n `duplicated lines ${ranked.duplicatedLineCount} (${Math.round(ranked.duplicatedLineRatio * 100)}%${partnersSuffix})`\n );\n }\n reasons.push(`file NCSS ${ranked.ncss}`);\n return `${ranked.file} (score ${ranked.score.toFixed(2)}): ${reasons.join('; ')}`;\n}\n\nfunction summarize(files: FileMetrics[]): {\n fileCount: number;\n functionCount: number;\n linesOfCode: number;\n maxCognitiveComplexity: number;\n ncssCount: number;\n} {\n let functionCount = 0;\n let linesOfCode = 0;\n let maxCognitiveComplexity = 0;\n let ncssCount = 0;\n\n for (const file of files) {\n functionCount += file.metrics.functions.length;\n linesOfCode += file.metrics.lines.code;\n maxCognitiveComplexity = Math.max(maxCognitiveComplexity, file.metrics.maxCognitiveComplexity);\n ncssCount += file.metrics.ncssCount;\n }\n\n return { fileCount: files.length, functionCount, linesOfCode, maxCognitiveComplexity, ncssCount };\n}\n\nfunction parseLimit(value: string): number {\n if (value === 'off') {\n return Infinity;\n }\n const parsed = value.trim() === '' ? Number.NaN : Number(value);\n if (!Number.isFinite(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative number or \"off\".');\n }\n return parsed;\n}\n\nfunction parsePercentInteger(value: string): number {\n const parsed = parsePositiveIntegerOption(value);\n if (parsed > 100) {\n throw new InvalidArgumentError('Expected an integer between 1 and 100.');\n }\n return parsed;\n}\n\nfunction parseNonNegativeInteger(value: string): number {\n if (!/^\\d+$/u.test(value)) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n return parsed;\n}\n\nfunction parsePositiveIntegerOption(value: string): number {\n const parsed = parsePositiveInteger(value);\n if (parsed === undefined) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n return parsed;\n}\n"],"mappings":";mOAiEK,EAAK,CAAC,CAAC,MAAO,GAAmB,CACpC,EAAA,YAAY,WAAA,EAAUA,EAAAA,gBAAAA,CAAgB,CAAK,EAAE,GAAG,EAChD,QAAQ,SAAW,CACrB,CAAC,EAED,eAAe,GAAsB,CACnC,IAAM,EAAU,IAAIC,EAAAA,QAAQ,CAAC,CAC1B,KAAK,YAAY,CAAC,CAClB,YAAY,8FAA8F,EAE7G,EACE,EACG,QAAQ,OAAQ,CAAE,UAAW,EAAK,CAAC,CAAC,CACpC,YAAY,qDAAqD,CAAC,CAClE,SAAS,WAAY,+BAAgC,GAAG,CAC7D,CAAC,CACE,OAAO,iBAAkB,qDAAsD,CAA0B,CAAC,CAC1G,OAAO,kBAAmB,8DAA8D,CAAC,CACzF,OAAO,CAAc,EAExB,IAAM,EAAQ,EACZ,EACG,QAAQ,OAAO,CAAC,CAChB,YACC,4JACF,CAAC,CACA,SAAS,WAAY,6BAA8B,GAAG,CAC3D,CAAC,CAAC,OAAO,eAAgB,wFAAwF,EACjH,IAAK,IAAM,KAASC,EAAAA,OAClB,IAAK,GAAM,CAAE,MAAK,mBAAmBC,EAAAA,WAAY,CAC/C,IAAM,EAAe,EAAc,GACnC,EAAM,OACJ,KAAK,EAAM,GAAG,EAAY,CAAG,EAAE,WAC/B,GAAG,EAAc,EAAK,CAAK,EAAE,YAAY,OAAO,SAAS,CAAY,EAAI,EAAe,MAAM,sBAC9F,CACF,CACF,CAGF,EAAM,aAAc,GAAU,QAAQ,KAAK,EAAM,WAAa,EAAI,EAAI,CAAC,CAAC,EACxE,EAAM,OAAO,MAAO,EAAgB,IAA0D,CAC5F,IAAM,EAAc,GAClB,OAAO,YACLA,EAAAA,WAAW,SAAS,CAAE,SAAU,CAC9B,IAAM,EAAQ,EAAW,GAAG,IAAQ,EAAI,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,EAAI,MAAM,CAAC,KAC7E,OAAO,IAAU,IAAA,GAAY,CAAC,EAAI,CAAC,CAAC,EAAK,CAAe,CAAC,CAC3D,CAAC,CACH,EACF,MAAMC,EAAAA,gBAAgB,EAAQ,EAAY,CAAE,QAAS,EAAW,SAAS,EAAG,MAAO,EAAW,OAAO,CAAE,CAAC,CAC1G,CAAC,EAED,MAAM,EAAQ,WAAW,CAC3B,CAGA,SAAS,EAAiB,EAA2B,CACnD,OAAO,EACJ,OAAO,kBAAmB,mDAAmDC,EAAAA,gBAAgB,CAAC,CAC9F,OACC,oCACA,qEACA,CACF,CAAC,CACA,OACC,wCACA,wFACA,CACF,CAAC,CACA,OACC,gDACA,sHACA,CACF,CAAC,CACA,OAAO,kBAAmB,yCAAyC,CAAC,CACpE,OAAO,SAAU,mBAAmB,CACzC,CAEA,SAAS,EAAc,EAAa,EAAsB,CACxD,GAAI,IAAQC,EAAAA,qBAAqB,IAC/B,MAAO,+CAA+C,IAAU,QAAU,WAAa,cAEzF,IAAM,EAAgBC,EAAAA,eAAe,KAAM,GAAc,EAAU,MAAQ,CAAG,EAI9E,MAAO,WAHS,EACZ,EAAc,MACd,GAAGC,EAAAA,mBAAmB,KAAM,GAAc,EAAU,MAAQ,CAAG,CAAC,EAAE,MAAM,gBAClD,WAAW,IAAU,QAAU,WAAa,aACxE,CAEA,SAAS,EAAY,EAAqB,CACxC,OAAO,EAAI,WAAW,UAAY,GAAW,IAAI,EAAO,YAAY,GAAG,CACzE,CAEA,eAAe,EAAe,EAAgB,EAAuC,CACnF,IAAM,EAAiBC,EAAAA,cAAc,CAAM,EACrC,EAAkB,MAAMC,EAAAA,sBAAsB,CAAc,EAC5D,EAAS,MAAMC,EAAAA,WAAW,EAAW,OAAQ,CAAe,EAC5D,EAAUC,EAAAA,eAAe,EAAY,CAAM,EAC3C,EAAS,MAAMC,EAAAA,WAAW,EAAgB,CAC9C,GAAG,EACH,cAAgB,GAAkBC,EAAAA,cAAc,EAAiB,EAAQ,QAAS,CAAa,CACjG,CAAC,EACD,EAAA,wBAAwB,EAAQ,CAAO,EACvC,IAAM,EAAc,EAAU,EAAQ,EAAQ,GAAG,EAE7C,EAAQ,KACV,EAAU,EAAQ,EAAa,CAAO,EAEtC,EAAgB,EAAgB,EAAQ,EAAa,CAAO,GAG1D,EAAO,YAAe,EAAQ,aAAe,EAAO,OAAO,OAAS,KACtE,QAAQ,SAAW,EAEvB,CAEA,SAAS,EAAU,EAAoB,EAA2B,CAChE,IAAM,EAAa,EAAO,MAAM,KAAK,CAAE,OAAM,aAAc,CACzD,IAAM,EAAgBC,EAAAA,WAAW,EAAM,EAAO,WAAW,EACnD,EAAkBC,EAAAA,6BAA6B,EAAS,EAAO,qBAAsB,CAAa,EACxG,MAAO,CACL,KAAM,EACN,cAAe,EAAkB,EAAQ,SAAS,EAClD,oBAAqB,EAAgB,KACrC,oBAAqB,EAAQ,MAAM,OAAS,EAAI,EAAI,EAAgB,KAAO,EAAQ,MAAM,KACzF,kBAAmB,CAAC,EACpB,KAAM,EAAQ,UACd,UAAW,EAAQ,MAAM,IAC3B,CACF,CAAC,EAEK,EAAsB,EAAe,EAAW,IAAK,GAAM,EAAE,eAAe,qBAAuB,CAAC,CAAC,EACrG,EAAwB,EAAe,EAAW,IAAK,GAAM,EAAE,mBAAmB,CAAC,EACnF,EAAiB,EAAe,EAAW,IAAK,GAAM,EAAE,IAAI,CAAC,EAE7D,EAAS,EACZ,IAAK,IAAe,CACnB,GAAG,EACH,MACE,EAAoB,EAAU,eAAe,qBAAuB,CAAC,EACrE,EAAsB,EAAU,mBAAmB,EACnD,EAAe,EAAU,IAAI,CACjC,EAAE,CAAC,CACF,UACE,EAAM,IAAU,EAAM,MAAQ,EAAK,OAAS,EAAM,KAAO,EAAK,MAAQ,EAAK,KAAK,cAAc,EAAM,IAAI,CAC3G,EAKF,OADA,EAAwB,EAAO,MAAM,EAAG,CAAG,EAAG,EAAO,oBAAoB,EAClE,CACT,CAGA,SAAS,EACP,EACA,EACM,CACN,GAAI,CAAC,EACH,OAEF,IAAM,EAAiB,IAAI,IAAI,EAAc,IAAK,GAAW,CAAC,EAAO,qBAAM,IAAI,GAAa,CAAC,CAAC,EAC9F,IAAK,IAAM,KAAS,EAAqB,OACvC,IAAK,IAAM,KAAQ,EAAM,MAAO,CAC9B,IAAM,EAAW,EAAe,IAAI,CAAI,EACnC,KAGL,IAAK,IAAM,KAAW,EAAM,MACtB,IAAY,GACd,EAAS,IAAI,CAAO,CAG1B,CAEF,IAAK,IAAM,KAAU,EACnB,EAAO,kBAAoB,CAAC,GAAI,EAAe,IAAI,EAAO,IAAI,GAAK,CAAC,CAAE,CAAC,CAAC,SAAS,CAErF,CAQA,SAAS,EAAe,EAA6C,CACnE,IAAM,EAAS,EAAO,UAAU,EAAM,IAAU,EAAO,CAAK,EAC5D,MAAQ,IAAkB,CACxB,IAAI,EAAM,EACN,EAAO,EAAO,OAClB,KAAO,EAAM,GAAM,CACjB,IAAM,EAAU,EAAM,GAAS,EAC1B,EAAO,GAAqB,EAC/B,EAAM,EAAS,EAEf,EAAO,CAEX,CACA,OAAO,EAAO,SAAW,EAAI,EAAI,EAAM,EAAO,MAChD,CACF,CAGA,SAAS,EAAkB,EAAyD,CAClF,IAAI,EACJ,IAAK,IAAM,KAAM,GAEb,CAAC,GACD,EAAG,oBAAsB,EAAM,qBAC9B,EAAG,sBAAwB,EAAM,qBAAuB,EAAG,KAAO,EAAM,QAEzE,EAAQ,GAGP,KAGL,MAAO,CACL,KAAM,EAAM,MAAQ,cACpB,UAAW,EAAM,UACjB,QAAS,EAAM,QACf,oBAAqB,EAAM,oBAC3B,KAAM,EAAM,KACZ,aAAc,EAAM,YACtB,CACF,CAEA,SAAS,EAAU,EAAoB,EAA2B,EAAgC,CAChG,IAAM,EAAgB,EAAY,MAAM,EAAG,EAAQ,GAAG,EACtD,EAAA,YACE,KAAK,UACH,CACE,QAAS,EAAU,EAAO,KAAK,EAC/B,iBAAkB,EAAY,OAC9B,UAAW,EAAc,OAAS,EAAY,OAC9C,MAAO,EACP,OAAQ,EAAO,OACf,SAAU,EAAO,QACnB,EACA,IAAA,GACA,CACF,EAAI;CACN,CACF,CAEA,SAAS,EACP,EACA,EACA,EACA,EACM,CACN,GAAI,EAAO,WAAY,CACrB,EAAA,YAAY,UAAU,EAAO,WAAW,GAAG,EAC3C,MACF,CAEA,IAAM,EAAU,EAAU,EAAO,KAAK,EAKtC,GAJA,EAAA,YACE,YAAY,EAAQ,UAAU,eAAe,EAAO,aAAa,EAAQ,YAAY,SAAS,EAAQ,UAAU,cAAc,EAAQ,cAAc,IACtJ,EAEI,EAAY,SAAW,EACzB,EAAA,YAAY;CAA8B,MACrC,CACL,IAAM,EAAgB,EAAY,MAAM,EAAG,EAAQ,GAAG,EAChD,EAAc,EAAY,OAAS,EAAc,OAAS,OAAO,EAAY,SAAW,GAC9F,EAAA,YAAY,iCAAiC,EAAc,SAAS,EAAY,KAAK,EACrF,IAAK,GAAM,CAAC,EAAO,KAAW,EAAc,QAAQ,EAClD,EAAA,YAAY,GAAG,EAAQ,EAAE,IAAI,EAAiB,CAAM,EAAE,GAAG,CAE7D,CAEA,GAAI,EAAO,SAAS,OAAS,EAAG,CAC9B,EAAA,YAAY,cAAc,EAAO,SAAS,OAAO,4DAA4D,EAC7G,IAAK,IAAM,KAAW,EAAO,SAAS,MAAM,EAAG,EAAE,EAC/C,EAAA,YAAY,KAAK,EAAQ,GAAG,EAE1B,EAAO,SAAS,OAAS,IAC3B,EAAA,YAAY,SAAS,EAAO,SAAS,OAAS,GAAG,QAAQ,CAE7D,CAEA,GAAI,EAAO,OAAO,OAAS,EAAG,CAC5B,EAAA,YAAY,aAAa,EAAO,OAAO,OAAO,yBAAyB,EACvE,IAAK,IAAM,KAAS,EAAO,OAAO,MAAM,EAAG,EAAE,EAC3C,EAAA,YAAY,KAAK,EAAM,GAAG,EAExB,EAAO,OAAO,OAAS,IACzB,EAAA,YAAY,SAAS,EAAO,OAAO,OAAS,GAAG,QAAQ,CAE3D,CACF,CAGA,SAAS,EAAiB,EAA4B,CACpD,IAAM,EAAoB,CAAC,EAC3B,GAAI,EAAO,cAAe,CACxB,IAAM,EAAK,EAAO,cAClB,EAAQ,KACN,kBAAkB,EAAG,KAAK,KAAK,EAAG,UAAU,GAAG,EAAG,QAAQ,cAAc,EAAG,oBAAoB,SAAS,EAAG,KAAK,YAAY,EAAG,cACjI,CACF,CACA,GAAI,EAAO,oBAAsB,EAAG,CAClC,IAAM,EACJ,EAAO,kBAAkB,OAAS,EAC9B,iBAAiB,EAAO,kBAAkB,MAAM,EAAG,CAAoB,CAAC,CAAC,KAAK,IAAI,IAAI,EAAO,kBAAkB,OAAS,EAAuB,QAAU,KACzJ,GACN,EAAQ,KACN,oBAAoB,EAAO,oBAAoB,IAAI,KAAK,MAAM,EAAO,oBAAsB,GAAG,EAAE,GAAG,EAAe,EACpH,CACF,CAEA,OADA,EAAQ,KAAK,aAAa,EAAO,MAAM,EAChC,GAAG,EAAO,KAAK,UAAU,EAAO,MAAM,QAAQ,CAAC,EAAE,KAAK,EAAQ,KAAK,IAAI,GAChF,CAEA,SAAS,EAAU,EAMjB,CACA,IAAI,EAAgB,EAChB,EAAc,EACd,EAAyB,EACzB,EAAY,EAEhB,IAAK,IAAM,KAAQ,EACjB,GAAiB,EAAK,QAAQ,UAAU,OACxC,GAAe,EAAK,QAAQ,MAAM,KAClC,EAAyB,KAAK,IAAI,EAAwB,EAAK,QAAQ,sBAAsB,EAC7F,GAAa,EAAK,QAAQ,UAG5B,MAAO,CAAE,UAAW,EAAM,OAAQ,gBAAe,cAAa,yBAAwB,WAAU,CAClG,CAEA,SAAS,EAAW,EAAuB,CACzC,GAAI,IAAU,MACZ,MAAO,KAET,IAAM,EAAS,EAAM,KAAK,IAAM,GAAK,IAAa,OAAO,CAAK,EAC9D,GAAI,CAAC,OAAO,SAAS,CAAM,GAAK,EAAS,EACvC,MAAM,IAAIC,EAAAA,qBAAqB,0CAA0C,EAE3E,OAAO,CACT,CAEA,SAAS,EAAoB,EAAuB,CAClD,IAAM,EAAS,EAA2B,CAAK,EAC/C,GAAI,EAAS,IACX,MAAM,IAAIA,EAAAA,qBAAqB,wCAAwC,EAEzE,OAAO,CACT,CAEA,SAAS,EAAwB,EAAuB,CACtD,GAAI,CAAC,SAAS,KAAK,CAAK,EACtB,MAAM,IAAIA,EAAAA,qBAAqB,kCAAkC,EAGnE,IAAM,EAAS,OAAO,CAAK,EAC3B,GAAI,CAAC,OAAO,cAAc,CAAM,GAAK,EAAS,EAC5C,MAAM,IAAIA,EAAAA,qBAAqB,kCAAkC,EAEnE,OAAO,CACT,CAEA,SAAS,EAA2B,EAAuB,CACzD,IAAM,GAAA,EAASC,EAAAA,qBAAAA,CAAqB,CAAK,EACzC,GAAI,IAAW,IAAA,GACb,MAAM,IAAID,EAAAA,qBAAqB,8BAA8B,EAE/D,OAAO,CACT"}
1
+ {"version":3,"file":"cli.cjs","names":["getErrorMessage","Command","levels","thresholds","runCheckCommand","configFileName","duplicationThreshold","fileThresholds","functionThresholds","resolveTarget","configSearchDirectory","loadConfig","resolveOptions","scanTarget","loadExclusion","formatPath","collectDuplicatedLineNumbers","qualifyFunctionName","InvalidArgumentError","parsePositiveInteger"],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { Command, InvalidArgumentError } from 'commander';\nimport { getErrorMessage, parsePositiveInteger } from '@willbooster/shared-lib';\nimport { type CliOptions, configFileName, loadConfig, type ResolvedOptions, resolveOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { runCheckCommand, type CheckCliOptions } from './checkCommand.js';\nimport { loadExclusion } from './exclusion.js';\nimport {\n addCrossFileDuplication,\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatPath,\n resolveTarget,\n scanTarget,\n writeStderr,\n writeStdout,\n type FileMetrics,\n type ScanResult,\n} from './scan.js';\nimport {\n duplicationThreshold,\n fileThresholds,\n functionThresholds,\n levels,\n thresholds,\n type Level,\n type Limits,\n} from './thresholds.js';\nimport { qualifyFunctionName } from './thresholdCheck.js';\nimport type { FunctionMetrics } from './types.js';\n\n/** The worst (highest-cognitive-complexity) function of a file, reported as the ranking evidence. */\ninterface WorstFunction {\n name: string;\n startLine: number;\n endLine: number;\n cognitiveComplexity: number;\n ncss: number;\n nestingDepth: number;\n}\n\n/**\n * One ranked refactoring candidate. The score is the sum of the file's repo-relative percentile\n * ranks (each in [0, 1)) over three dimensions: worst-function cognitive complexity, duplicated\n * lines (within-file and cross-file combined), and file NCSS. Ranking is relative to the scanned\n * project, so no absolute threshold is involved.\n */\ninterface RankedFile {\n file: string;\n score: number;\n worstFunction?: WorstFunction;\n /** Distinct lines covered by within-file duplicate blocks or cross-file duplicate occurrences. */\n duplicatedLineCount: number;\n /** duplicatedLineCount / code lines (0 when the file has no code). */\n duplicatedLineRatio: number;\n /** Other files sharing cross-file duplicate blocks; filled only for the reported top files. */\n crossFilePartners: string[];\n ncss: number;\n codeLines: number;\n}\n\n/** Caps how many cross-file partners a ranked file lists so the report stays scannable. */\nconst maxCrossFilePartners = 3;\n\n// oxlint-disable-next-line unicorn/prefer-top-level-await -- CommonJS build output cannot preserve top-level await.\nvoid main().catch((error: unknown) => {\n writeStderr(`Error: ${getErrorMessage(error)}\\n`);\n process.exitCode = 1;\n});\n\nasync function main(): Promise<void> {\n const program = new Command()\n .name('code-gauge')\n .description('Measure code metrics: rank refactoring candidates (the default command) or check thresholds.');\n\n addSharedOptions(\n program\n .command('rank', { isDefault: true })\n .description('Rank the files of a project by refactoring priority')\n .argument('[target]', 'file or directory to measure', '.')\n )\n .option('--top <number>', 'number of top-ranked files to report (default: 10)', parsePositiveIntegerOption)\n .option('--fail-on-error', 'exit with code 1 when files or directories cannot be scanned')\n .action(runRankCommand);\n\n const check = addSharedOptions(\n program\n .command('check')\n .description(\n 'Report every exceeded threshold as a warning or an error (exit 1 on errors, 2 when files cannot be measured); with --base, only in what the change touches'\n )\n .argument('[target]', 'file or directory to check', '.')\n ).option('--base <ref>', 'check only what the working tree changed since the merge-base of this git ref and HEAD');\n for (const level of levels) {\n for (const { key, defaultLimits } of thresholds) {\n const defaultLimit = defaultLimits[level];\n check.option(\n `--${level}-${toKebabCase(key)} <number>`,\n `${describeLimit(key, level)} (default ${Number.isFinite(defaultLimit) ? defaultLimit : 'off'}); \"off\" disables it`,\n parseLimit\n );\n }\n }\n // Exit code 1 means error-level violations, so a rejected command line must not use it.\n check.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : 2));\n check.action(async (target: string, cliOptions: CheckCliOptions & Record<string, unknown>) => {\n const readLimits = (level: Level): Limits =>\n Object.fromEntries(\n thresholds.flatMap(({ key }) => {\n const limit = cliOptions[`${level}${key.charAt(0).toUpperCase()}${key.slice(1)}`];\n return limit === undefined ? [] : [[key, limit as number]];\n })\n );\n await runCheckCommand(target, cliOptions, { warning: readLimits('warning'), error: readLimits('error') });\n });\n\n await program.parseAsync();\n}\n\n/** Registers the options shared by the ranking and check commands. */\nfunction addSharedOptions(command: Command): Command {\n return command\n .option('--config <path>', `config file to use instead of the auto-detected ${configFileName}`)\n .option(\n '--duplication-min-tokens <number>',\n 'minimum normalized token count for a duplicate region (default 40)',\n parsePositiveIntegerOption\n )\n .option(\n '--duplication-max-gap-tokens <number>',\n 'maximum token gap merged into one gapped clone group; 0 disables merging (default 30)',\n parseNonNegativeInteger\n )\n .option(\n '--duplication-min-similarity-percent <number>',\n 'minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)',\n parsePercentInteger\n )\n .option('--include-tests', 'include test files and test directories')\n .option('--json', 'print JSON output');\n}\n\nfunction describeLimit(key: string, level: Level): string {\n if (key === duplicationThreshold.key) {\n return `code-line count from which a duplicated block is ${level === 'error' ? 'an error' : 'a warning'}`;\n }\n const fileThreshold = fileThresholds.find((threshold) => threshold.key === key);\n const subject = fileThreshold\n ? fileThreshold.label\n : `${functionThresholds.find((threshold) => threshold.key === key)?.label} of a function`;\n return `maximum ${subject} without ${level === 'error' ? 'an error' : 'a warning'}`;\n}\n\nfunction toKebabCase(key: string): string {\n return key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);\n}\n\nasync function runRankCommand(target: string, cliOptions: CliOptions): Promise<void> {\n const resolvedTarget = resolveTarget(target);\n const searchDirectory = await configSearchDirectory(resolvedTarget);\n const config = await loadConfig(cliOptions.config, searchDirectory);\n const options = resolveOptions(cliOptions, config);\n const result = await scanTarget(resolvedTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n addCrossFileDuplication(result, options);\n const rankedFiles = rankFiles(result, options.top);\n\n if (options.json) {\n printJson(result, rankedFiles, options);\n } else {\n printTextReport(resolvedTarget, result, rankedFiles, options);\n }\n\n if (result.fatalError || (options.failOnError && result.errors.length > 0)) {\n process.exitCode = 1;\n }\n}\n\nfunction rankFiles(result: ScanResult, top: number): RankedFile[] {\n const candidates = result.files.map(({ file, metrics }) => {\n const formattedFile = formatPath(file, result.displayRoot);\n const duplicatedLines = collectDuplicatedLineNumbers(metrics, result.crossFileDuplication, formattedFile);\n return {\n file: formattedFile,\n worstFunction: findWorstFunction(metrics.functions),\n duplicatedLineCount: duplicatedLines.size,\n duplicatedLineRatio: metrics.lines.code === 0 ? 0 : duplicatedLines.size / metrics.lines.code,\n crossFilePartners: [] as string[],\n ncss: metrics.ncssCount,\n codeLines: metrics.lines.code,\n };\n });\n\n const cognitivePercentile = makePercentile(candidates.map((c) => c.worstFunction?.cognitiveComplexity ?? 0));\n const duplicationPercentile = makePercentile(candidates.map((c) => c.duplicatedLineCount));\n const ncssPercentile = makePercentile(candidates.map((c) => c.ncss));\n\n const ranked = candidates\n .map((candidate) => ({\n ...candidate,\n score:\n cognitivePercentile(candidate.worstFunction?.cognitiveComplexity ?? 0) +\n duplicationPercentile(candidate.duplicatedLineCount) +\n ncssPercentile(candidate.ncss),\n }))\n .toSorted(\n (left, right) => right.score - left.score || right.ncss - left.ncss || left.file.localeCompare(right.file)\n );\n // Partner evidence is attached only to the files that will be reported: expanding partners for\n // every scanned file first would retain TH(F^2) strings when one clone group spans F files, and\n // rescanning all groups per file would be O(files x groups) on the post-scan ranking step.\n attachCrossFilePartners(ranked.slice(0, top), result.crossFileDuplication);\n return ranked;\n}\n\n/** One pass over the groups fills the reported files' partner lists (other files sharing a group). */\nfunction attachCrossFilePartners(\n reportedFiles: RankedFile[],\n crossFileDuplication: CrossFileDuplicationMetrics | undefined\n): void {\n if (!crossFileDuplication) {\n return;\n }\n const partnersByFile = new Map(reportedFiles.map((ranked) => [ranked.file, new Set<string>()]));\n for (const group of crossFileDuplication.groups) {\n for (const file of group.files) {\n const partners = partnersByFile.get(file);\n if (!partners) {\n continue;\n }\n for (const partner of group.files) {\n if (partner !== file) {\n partners.add(partner);\n }\n }\n }\n }\n for (const ranked of reportedFiles) {\n ranked.crossFilePartners = [...(partnersByFile.get(ranked.file) ?? [])].toSorted();\n }\n}\n\n/**\n * Percentile rank within the scanned project: the fraction of files with a strictly smaller\n * value, in [0, 1). Relative ranking needs no absolute threshold, which sidesteps the metric\n * calibration problem entirely — the top of the list is worth refactoring first regardless of\n * where any cutoff would sit.\n */\nfunction makePercentile(values: number[]): (value: number) => number {\n const sorted = values.toSorted((left, right) => left - right);\n return (value: number) => {\n let low = 0;\n let high = sorted.length;\n while (low < high) {\n const middle = (low + high) >> 1;\n if ((sorted[middle] as number) < value) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return sorted.length === 0 ? 0 : low / sorted.length;\n };\n}\n\n/** The highest-cognitive-complexity function; NCSS breaks ties so the larger body is reported. */\nfunction findWorstFunction(functions: FunctionMetrics[]): WorstFunction | undefined {\n let worst: FunctionMetrics | undefined;\n for (const fn of functions) {\n if (\n !worst ||\n fn.cognitiveComplexity > worst.cognitiveComplexity ||\n (fn.cognitiveComplexity === worst.cognitiveComplexity && fn.ncss > worst.ncss)\n ) {\n worst = fn;\n }\n }\n if (!worst) {\n return undefined;\n }\n return {\n name: qualifyFunctionName(worst),\n startLine: worst.startLine,\n endLine: worst.endLine,\n cognitiveComplexity: worst.cognitiveComplexity,\n ncss: worst.ncss,\n nestingDepth: worst.nestingDepth,\n };\n}\n\nfunction printJson(result: ScanResult, rankedFiles: RankedFile[], options: ResolvedOptions): void {\n const reportedFiles = rankedFiles.slice(0, options.top);\n writeStdout(\n JSON.stringify(\n {\n summary: summarize(result.files),\n totalRankedFiles: rankedFiles.length,\n truncated: reportedFiles.length < rankedFiles.length,\n files: reportedFiles,\n errors: result.errors,\n warnings: result.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n\nfunction printTextReport(\n target: string,\n result: ScanResult,\n rankedFiles: RankedFile[],\n options: ResolvedOptions\n): void {\n if (result.fatalError) {\n writeStderr(`Error: ${result.fatalError}\\n`);\n return;\n }\n\n const summary = summarize(result.files);\n writeStdout(\n `Measured ${summary.fileCount} files under ${target} (code LOC ${summary.linesOfCode}, NCSS ${summary.ncssCount}, functions ${summary.functionCount})\\n`\n );\n\n if (rankedFiles.length === 0) {\n writeStdout('No measurable files found.\\n');\n } else {\n const reportedFiles = rankedFiles.slice(0, options.top);\n const totalSuffix = rankedFiles.length > reportedFiles.length ? ` of ${rankedFiles.length}` : '';\n writeStdout(`\\nRefactoring candidates (top ${reportedFiles.length}${totalSuffix}):\\n`);\n for (const [index, ranked] of reportedFiles.entries()) {\n writeStdout(`${index + 1}. ${formatRankedFile(ranked)}\\n`);\n }\n }\n\n if (result.warnings.length > 0) {\n writeStderr(`\\nDegraded ${result.warnings.length} files (measured, but excluded from cross-file matching):\\n`);\n for (const warning of result.warnings.slice(0, 10)) {\n writeStderr(`- ${warning}\\n`);\n }\n if (result.warnings.length > 10) {\n writeStderr(`- ... ${result.warnings.length - 10} more\\n`);\n }\n }\n\n if (result.errors.length > 0) {\n writeStderr(`\\nSkipped ${result.errors.length} files or directories:\\n`);\n for (const error of result.errors.slice(0, 10)) {\n writeStderr(`- ${error}\\n`);\n }\n if (result.errors.length > 10) {\n writeStderr(`- ... ${result.errors.length - 10} more\\n`);\n }\n }\n}\n\n/** One ranked file as a single line: the location, the evidence, and where the duplication points. */\nfunction formatRankedFile(ranked: RankedFile): string {\n const reasons: string[] = [];\n if (ranked.worstFunction) {\n const fn = ranked.worstFunction;\n reasons.push(\n `worst function ${fn.name} (L${fn.startLine}-${fn.endLine}) cognitive ${fn.cognitiveComplexity}, NCSS ${fn.ncss}, nesting ${fn.nestingDepth}`\n );\n }\n if (ranked.duplicatedLineCount > 0) {\n const partnersSuffix =\n ranked.crossFilePartners.length > 0\n ? `, shared with ${ranked.crossFilePartners.slice(0, maxCrossFilePartners).join(', ')}${ranked.crossFilePartners.length > maxCrossFilePartners ? ', ...' : ''}`\n : '';\n reasons.push(\n `duplicated lines ${ranked.duplicatedLineCount} (${Math.round(ranked.duplicatedLineRatio * 100)}%${partnersSuffix})`\n );\n }\n reasons.push(`file NCSS ${ranked.ncss}`);\n return `${ranked.file} (score ${ranked.score.toFixed(2)}): ${reasons.join('; ')}`;\n}\n\nfunction summarize(files: FileMetrics[]): {\n fileCount: number;\n functionCount: number;\n linesOfCode: number;\n maxCognitiveComplexity: number;\n ncssCount: number;\n} {\n let functionCount = 0;\n let linesOfCode = 0;\n let maxCognitiveComplexity = 0;\n let ncssCount = 0;\n\n for (const file of files) {\n functionCount += file.metrics.functions.length;\n linesOfCode += file.metrics.lines.code;\n maxCognitiveComplexity = Math.max(maxCognitiveComplexity, file.metrics.maxCognitiveComplexity);\n ncssCount += file.metrics.ncssCount;\n }\n\n return { fileCount: files.length, functionCount, linesOfCode, maxCognitiveComplexity, ncssCount };\n}\n\nfunction parseLimit(value: string): number {\n if (value === 'off') {\n return Infinity;\n }\n const parsed = value.trim() === '' ? Number.NaN : Number(value);\n if (!Number.isFinite(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative number or \"off\".');\n }\n return parsed;\n}\n\nfunction parsePercentInteger(value: string): number {\n const parsed = parsePositiveIntegerOption(value);\n if (parsed > 100) {\n throw new InvalidArgumentError('Expected an integer between 1 and 100.');\n }\n return parsed;\n}\n\nfunction parseNonNegativeInteger(value: string): number {\n if (!/^\\d+$/u.test(value)) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n return parsed;\n}\n\nfunction parsePositiveIntegerOption(value: string): number {\n const parsed = parsePositiveInteger(value);\n if (parsed === undefined) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n return parsed;\n}\n"],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import{duplicationThreshold as e,fileThresholds as t,functionThresholds as n,levels as r,thresholds as i}from"./thresholds.js";import{configFileName as a,loadConfig as o,resolveOptions as s}from"./cliConfig.js";import{loadExclusion as c}from"./exclusion.js";import{addCrossFileDuplication as l,collectDuplicatedLineNumbers as u,configSearchDirectory as d,formatPath as f,resolveTarget as p,scanTarget as m,writeStderr as h,writeStdout as g}from"./scan.js";import{runCheckCommand as _}from"./checkCommand.js";import{getErrorMessage as v,parsePositiveInteger as y}from"@willbooster/shared-lib";import{Command as b,InvalidArgumentError as x}from"commander";S().catch(e=>{h(`Error: ${v(e)}\n`),process.exitCode=1});async function S(){let e=new b().name(`code-gauge`).description(`Measure code metrics: rank refactoring candidates (the default command) or check thresholds.`);C(e.command(`rank`,{isDefault:!0}).description(`Rank the files of a project by refactoring priority`).argument(`[target]`,`file or directory to measure`,`.`)).option(`--top <number>`,`number of top-ranked files to report (default: 10)`,R).option(`--fail-on-error`,`exit with code 1 when files or directories cannot be scanned`).action(E);let t=C(e.command(`check`).description(`Report every exceeded threshold as a warning or an error (exit 1 on errors, 2 when files cannot be measured); with --base, only in what the change touches`).argument(`[target]`,`file or directory to check`,`.`)).option(`--base <ref>`,`check only what the working tree changed since the merge-base of this git ref and HEAD`);for(let e of r)for(let{key:n,defaultLimits:r}of i){let i=r[e];t.option(`--${e}-${T(n)} <number>`,`${w(n,e)} (default ${Number.isFinite(i)?i:`off`}); "off" disables it`,F)}t.exitOverride(e=>process.exit(e.exitCode===0?0:2)),t.action(async(e,t)=>{let n=e=>Object.fromEntries(i.flatMap(({key:n})=>{let r=t[`${e}${n.charAt(0).toUpperCase()}${n.slice(1)}`];return r===void 0?[]:[[n,r]]}));await _(e,t,{warning:n(`warning`),error:n(`error`)})}),await e.parseAsync()}function C(e){return e.option(`--config <path>`,`config file to use instead of the auto-detected ${a}`).option(`--duplication-min-tokens <number>`,`minimum normalized token count for a duplicate region (default 40)`,R).option(`--duplication-max-gap-tokens <number>`,`maximum token gap merged into one gapped clone group; 0 disables merging (default 30)`,L).option(`--duplication-min-similarity-percent <number>`,`minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)`,I).option(`--include-tests`,`include test files and test directories`).option(`--json`,`print JSON output`)}function w(r,i){if(r===e.key)return`line count from which a duplicated block is ${i===`error`?`an error`:`a warning`}`;let a=t.find(e=>e.key===r);return`maximum ${a?a.label:`${n.find(e=>e.key===r)?.label} of a function`} without ${i===`error`?`an error`:`a warning`}`}function T(e){return e.replaceAll(/[A-Z]/gu,e=>`-${e.toLowerCase()}`)}async function E(e,t){let n=p(e),r=await d(n),i=await o(t.config,r),a=s(t,i),u=await m(n,{...a,loadExclusion:e=>c(r,a.exclude,e)});l(u,a);let f=D(u,a.top);a.json?j(u,f,a):M(n,u,f,a),(u.fatalError||a.failOnError&&u.errors.length>0)&&(process.exitCode=1)}function D(e,t){let n=e.files.map(({file:t,metrics:n})=>{let r=f(t,e.displayRoot),i=u(n,e.crossFileDuplication,r);return{file:r,worstFunction:A(n.functions),duplicatedLineCount:i.size,duplicatedLineRatio:n.lines.code===0?0:i.size/n.lines.code,crossFilePartners:[],ncss:n.ncssCount,codeLines:n.lines.code}}),r=k(n.map(e=>e.worstFunction?.cognitiveComplexity??0)),i=k(n.map(e=>e.duplicatedLineCount)),a=k(n.map(e=>e.ncss)),o=n.map(e=>({...e,score:r(e.worstFunction?.cognitiveComplexity??0)+i(e.duplicatedLineCount)+a(e.ncss)})).toSorted((e,t)=>t.score-e.score||t.ncss-e.ncss||e.file.localeCompare(t.file));return O(o.slice(0,t),e.crossFileDuplication),o}function O(e,t){if(!t)return;let n=new Map(e.map(e=>[e.file,/* @__PURE__ */ new Set]));for(let e of t.groups)for(let t of e.files){let r=n.get(t);if(r)for(let n of e.files)n!==t&&r.add(n)}for(let t of e)t.crossFilePartners=[...n.get(t.file)??[]].toSorted()}function k(e){let t=e.toSorted((e,t)=>e-t);return e=>{let n=0,r=t.length;for(;n<r;){let i=n+r>>1;t[i]<e?n=i+1:r=i}return t.length===0?0:n/t.length}}function A(e){let t;for(let n of e)(!t||n.cognitiveComplexity>t.cognitiveComplexity||n.cognitiveComplexity===t.cognitiveComplexity&&n.ncss>t.ncss)&&(t=n);if(t)return{name:t.name??`<anonymous>`,startLine:t.startLine,endLine:t.endLine,cognitiveComplexity:t.cognitiveComplexity,ncss:t.ncss,nestingDepth:t.nestingDepth}}function j(e,t,n){let r=t.slice(0,n.top);g(JSON.stringify({summary:P(e.files),totalRankedFiles:t.length,truncated:r.length<t.length,files:r,errors:e.errors,warnings:e.warnings},void 0,2)+`
3
- `)}function M(e,t,n,r){if(t.fatalError){h(`Error: ${t.fatalError}\n`);return}let i=P(t.files);if(g(`Measured ${i.fileCount} files under ${e} (code LOC ${i.linesOfCode}, NCSS ${i.ncssCount}, functions ${i.functionCount})\n`),n.length===0)g(`No measurable files found.
4
- `);else{let e=n.slice(0,r.top),t=n.length>e.length?` of ${n.length}`:``;g(`\nRefactoring candidates (top ${e.length}${t}):\n`);for(let[t,n]of e.entries())g(`${t+1}. ${N(n)}\n`)}if(t.warnings.length>0){h(`\nDegraded ${t.warnings.length} files (measured, but excluded from cross-file matching):\n`);for(let e of t.warnings.slice(0,10))h(`- ${e}\n`);t.warnings.length>10&&h(`- ... ${t.warnings.length-10} more\n`)}if(t.errors.length>0){h(`\nSkipped ${t.errors.length} files or directories:\n`);for(let e of t.errors.slice(0,10))h(`- ${e}\n`);t.errors.length>10&&h(`- ... ${t.errors.length-10} more\n`)}}function N(e){let t=[];if(e.worstFunction){let n=e.worstFunction;t.push(`worst function ${n.name} (L${n.startLine}-${n.endLine}) cognitive ${n.cognitiveComplexity}, NCSS ${n.ncss}, nesting ${n.nestingDepth}`)}if(e.duplicatedLineCount>0){let n=e.crossFilePartners.length>0?`, shared with ${e.crossFilePartners.slice(0,3).join(`, `)}${e.crossFilePartners.length>3?`, ...`:``}`:``;t.push(`duplicated lines ${e.duplicatedLineCount} (${Math.round(e.duplicatedLineRatio*100)}%${n})`)}return t.push(`file NCSS ${e.ncss}`),`${e.file} (score ${e.score.toFixed(2)}): ${t.join(`; `)}`}function P(e){let t=0,n=0,r=0,i=0;for(let a of e)t+=a.metrics.functions.length,n+=a.metrics.lines.code,r=Math.max(r,a.metrics.maxCognitiveComplexity),i+=a.metrics.ncssCount;return{fileCount:e.length,functionCount:t,linesOfCode:n,maxCognitiveComplexity:r,ncssCount:i}}function F(e){if(e===`off`)return 1/0;let t=e.trim()===``?NaN:Number(e);if(!Number.isFinite(t)||t<0)throw new x(`Expected a non-negative number or "off".`);return t}function I(e){let t=R(e);if(t>100)throw new x(`Expected an integer between 1 and 100.`);return t}function L(e){if(!/^\d+$/u.test(e))throw new x(`Expected a non-negative integer.`);let t=Number(e);if(!Number.isSafeInteger(t)||t<0)throw new x(`Expected a non-negative integer.`);return t}function R(e){let t=y(e);if(t===void 0)throw new x(`Expected a positive integer.`);return t}
2
+ import{duplicationThreshold as e,fileThresholds as t,functionThresholds as n,levels as r,thresholds as i}from"./thresholds.js";import{configFileName as a,loadConfig as o,resolveOptions as s}from"./cliConfig.js";import{loadExclusion as c}from"./exclusion.js";import{addCrossFileDuplication as l,collectDuplicatedLineNumbers as u,configSearchDirectory as d,formatPath as f,resolveTarget as p,scanTarget as m,writeStderr as h,writeStdout as g}from"./scan.js";import{qualifyFunctionName as _}from"./thresholdCheck.js";import{runCheckCommand as v}from"./checkCommand.js";import{getErrorMessage as y,parsePositiveInteger as b}from"@willbooster/shared-lib";import{Command as x,InvalidArgumentError as S}from"commander";C().catch(e=>{h(`Error: ${y(e)}\n`),process.exitCode=1});async function C(){let e=new x().name(`code-gauge`).description(`Measure code metrics: rank refactoring candidates (the default command) or check thresholds.`);w(e.command(`rank`,{isDefault:!0}).description(`Rank the files of a project by refactoring priority`).argument(`[target]`,`file or directory to measure`,`.`)).option(`--top <number>`,`number of top-ranked files to report (default: 10)`,z).option(`--fail-on-error`,`exit with code 1 when files or directories cannot be scanned`).action(D);let t=w(e.command(`check`).description(`Report every exceeded threshold as a warning or an error (exit 1 on errors, 2 when files cannot be measured); with --base, only in what the change touches`).argument(`[target]`,`file or directory to check`,`.`)).option(`--base <ref>`,`check only what the working tree changed since the merge-base of this git ref and HEAD`);for(let e of r)for(let{key:n,defaultLimits:r}of i){let i=r[e];t.option(`--${e}-${E(n)} <number>`,`${T(n,e)} (default ${Number.isFinite(i)?i:`off`}); "off" disables it`,I)}t.exitOverride(e=>process.exit(e.exitCode===0?0:2)),t.action(async(e,t)=>{let n=e=>Object.fromEntries(i.flatMap(({key:n})=>{let r=t[`${e}${n.charAt(0).toUpperCase()}${n.slice(1)}`];return r===void 0?[]:[[n,r]]}));await v(e,t,{warning:n(`warning`),error:n(`error`)})}),await e.parseAsync()}function w(e){return e.option(`--config <path>`,`config file to use instead of the auto-detected ${a}`).option(`--duplication-min-tokens <number>`,`minimum normalized token count for a duplicate region (default 40)`,z).option(`--duplication-max-gap-tokens <number>`,`maximum token gap merged into one gapped clone group; 0 disables merging (default 30)`,R).option(`--duplication-min-similarity-percent <number>`,`minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)`,L).option(`--include-tests`,`include test files and test directories`).option(`--json`,`print JSON output`)}function T(r,i){if(r===e.key)return`code-line count from which a duplicated block is ${i===`error`?`an error`:`a warning`}`;let a=t.find(e=>e.key===r);return`maximum ${a?a.label:`${n.find(e=>e.key===r)?.label} of a function`} without ${i===`error`?`an error`:`a warning`}`}function E(e){return e.replaceAll(/[A-Z]/gu,e=>`-${e.toLowerCase()}`)}async function D(e,t){let n=p(e),r=await d(n),i=await o(t.config,r),a=s(t,i),u=await m(n,{...a,loadExclusion:e=>c(r,a.exclude,e)});l(u,a);let f=O(u,a.top);a.json?M(u,f,a):N(n,u,f,a),(u.fatalError||a.failOnError&&u.errors.length>0)&&(process.exitCode=1)}function O(e,t){let n=e.files.map(({file:t,metrics:n})=>{let r=f(t,e.displayRoot),i=u(n,e.crossFileDuplication,r);return{file:r,worstFunction:j(n.functions),duplicatedLineCount:i.size,duplicatedLineRatio:n.lines.code===0?0:i.size/n.lines.code,crossFilePartners:[],ncss:n.ncssCount,codeLines:n.lines.code}}),r=A(n.map(e=>e.worstFunction?.cognitiveComplexity??0)),i=A(n.map(e=>e.duplicatedLineCount)),a=A(n.map(e=>e.ncss)),o=n.map(e=>({...e,score:r(e.worstFunction?.cognitiveComplexity??0)+i(e.duplicatedLineCount)+a(e.ncss)})).toSorted((e,t)=>t.score-e.score||t.ncss-e.ncss||e.file.localeCompare(t.file));return k(o.slice(0,t),e.crossFileDuplication),o}function k(e,t){if(!t)return;let n=new Map(e.map(e=>[e.file,/* @__PURE__ */ new Set]));for(let e of t.groups)for(let t of e.files){let r=n.get(t);if(r)for(let n of e.files)n!==t&&r.add(n)}for(let t of e)t.crossFilePartners=[...n.get(t.file)??[]].toSorted()}function A(e){let t=e.toSorted((e,t)=>e-t);return e=>{let n=0,r=t.length;for(;n<r;){let i=n+r>>1;t[i]<e?n=i+1:r=i}return t.length===0?0:n/t.length}}function j(e){let t;for(let n of e)(!t||n.cognitiveComplexity>t.cognitiveComplexity||n.cognitiveComplexity===t.cognitiveComplexity&&n.ncss>t.ncss)&&(t=n);if(t)return{name:_(t),startLine:t.startLine,endLine:t.endLine,cognitiveComplexity:t.cognitiveComplexity,ncss:t.ncss,nestingDepth:t.nestingDepth}}function M(e,t,n){let r=t.slice(0,n.top);g(JSON.stringify({summary:F(e.files),totalRankedFiles:t.length,truncated:r.length<t.length,files:r,errors:e.errors,warnings:e.warnings},void 0,2)+`
3
+ `)}function N(e,t,n,r){if(t.fatalError){h(`Error: ${t.fatalError}\n`);return}let i=F(t.files);if(g(`Measured ${i.fileCount} files under ${e} (code LOC ${i.linesOfCode}, NCSS ${i.ncssCount}, functions ${i.functionCount})\n`),n.length===0)g(`No measurable files found.
4
+ `);else{let e=n.slice(0,r.top),t=n.length>e.length?` of ${n.length}`:``;g(`\nRefactoring candidates (top ${e.length}${t}):\n`);for(let[t,n]of e.entries())g(`${t+1}. ${P(n)}\n`)}if(t.warnings.length>0){h(`\nDegraded ${t.warnings.length} files (measured, but excluded from cross-file matching):\n`);for(let e of t.warnings.slice(0,10))h(`- ${e}\n`);t.warnings.length>10&&h(`- ... ${t.warnings.length-10} more\n`)}if(t.errors.length>0){h(`\nSkipped ${t.errors.length} files or directories:\n`);for(let e of t.errors.slice(0,10))h(`- ${e}\n`);t.errors.length>10&&h(`- ... ${t.errors.length-10} more\n`)}}function P(e){let t=[];if(e.worstFunction){let n=e.worstFunction;t.push(`worst function ${n.name} (L${n.startLine}-${n.endLine}) cognitive ${n.cognitiveComplexity}, NCSS ${n.ncss}, nesting ${n.nestingDepth}`)}if(e.duplicatedLineCount>0){let n=e.crossFilePartners.length>0?`, shared with ${e.crossFilePartners.slice(0,3).join(`, `)}${e.crossFilePartners.length>3?`, ...`:``}`:``;t.push(`duplicated lines ${e.duplicatedLineCount} (${Math.round(e.duplicatedLineRatio*100)}%${n})`)}return t.push(`file NCSS ${e.ncss}`),`${e.file} (score ${e.score.toFixed(2)}): ${t.join(`; `)}`}function F(e){let t=0,n=0,r=0,i=0;for(let a of e)t+=a.metrics.functions.length,n+=a.metrics.lines.code,r=Math.max(r,a.metrics.maxCognitiveComplexity),i+=a.metrics.ncssCount;return{fileCount:e.length,functionCount:t,linesOfCode:n,maxCognitiveComplexity:r,ncssCount:i}}function I(e){if(e===`off`)return 1/0;let t=e.trim()===``?NaN:Number(e);if(!Number.isFinite(t)||t<0)throw new S(`Expected a non-negative number or "off".`);return t}function L(e){let t=z(e);if(t>100)throw new S(`Expected an integer between 1 and 100.`);return t}function R(e){if(!/^\d+$/u.test(e))throw new S(`Expected a non-negative integer.`);let t=Number(e);if(!Number.isSafeInteger(t)||t<0)throw new S(`Expected a non-negative integer.`);return t}function z(e){let t=b(e);if(t===void 0)throw new S(`Expected a positive integer.`);return t}
5
5
  //# sourceMappingURL=cli.js.map
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { Command, InvalidArgumentError } from 'commander';\nimport { getErrorMessage, parsePositiveInteger } from '@willbooster/shared-lib';\nimport { type CliOptions, configFileName, loadConfig, type ResolvedOptions, resolveOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { runCheckCommand, type CheckCliOptions } from './checkCommand.js';\nimport { loadExclusion } from './exclusion.js';\nimport {\n addCrossFileDuplication,\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatPath,\n resolveTarget,\n scanTarget,\n writeStderr,\n writeStdout,\n type FileMetrics,\n type ScanResult,\n} from './scan.js';\nimport {\n duplicationThreshold,\n fileThresholds,\n functionThresholds,\n levels,\n thresholds,\n type Level,\n type Limits,\n} from './thresholds.js';\nimport type { FunctionMetrics } from './types.js';\n\n/** The worst (highest-cognitive-complexity) function of a file, reported as the ranking evidence. */\ninterface WorstFunction {\n name: string;\n startLine: number;\n endLine: number;\n cognitiveComplexity: number;\n ncss: number;\n nestingDepth: number;\n}\n\n/**\n * One ranked refactoring candidate. The score is the sum of the file's repo-relative percentile\n * ranks (each in [0, 1)) over three dimensions: worst-function cognitive complexity, duplicated\n * lines (within-file and cross-file combined), and file NCSS. Ranking is relative to the scanned\n * project, so no absolute threshold is involved.\n */\ninterface RankedFile {\n file: string;\n score: number;\n worstFunction?: WorstFunction;\n /** Distinct lines covered by within-file duplicate blocks or cross-file duplicate occurrences. */\n duplicatedLineCount: number;\n /** duplicatedLineCount / code lines (0 when the file has no code). */\n duplicatedLineRatio: number;\n /** Other files sharing cross-file duplicate blocks; filled only for the reported top files. */\n crossFilePartners: string[];\n ncss: number;\n codeLines: number;\n}\n\n/** Caps how many cross-file partners a ranked file lists so the report stays scannable. */\nconst maxCrossFilePartners = 3;\n\n// oxlint-disable-next-line unicorn/prefer-top-level-await -- CommonJS build output cannot preserve top-level await.\nvoid main().catch((error: unknown) => {\n writeStderr(`Error: ${getErrorMessage(error)}\\n`);\n process.exitCode = 1;\n});\n\nasync function main(): Promise<void> {\n const program = new Command()\n .name('code-gauge')\n .description('Measure code metrics: rank refactoring candidates (the default command) or check thresholds.');\n\n addSharedOptions(\n program\n .command('rank', { isDefault: true })\n .description('Rank the files of a project by refactoring priority')\n .argument('[target]', 'file or directory to measure', '.')\n )\n .option('--top <number>', 'number of top-ranked files to report (default: 10)', parsePositiveIntegerOption)\n .option('--fail-on-error', 'exit with code 1 when files or directories cannot be scanned')\n .action(runRankCommand);\n\n const check = addSharedOptions(\n program\n .command('check')\n .description(\n 'Report every exceeded threshold as a warning or an error (exit 1 on errors, 2 when files cannot be measured); with --base, only in what the change touches'\n )\n .argument('[target]', 'file or directory to check', '.')\n ).option('--base <ref>', 'check only what the working tree changed since the merge-base of this git ref and HEAD');\n for (const level of levels) {\n for (const { key, defaultLimits } of thresholds) {\n const defaultLimit = defaultLimits[level];\n check.option(\n `--${level}-${toKebabCase(key)} <number>`,\n `${describeLimit(key, level)} (default ${Number.isFinite(defaultLimit) ? defaultLimit : 'off'}); \"off\" disables it`,\n parseLimit\n );\n }\n }\n // Exit code 1 means error-level violations, so a rejected command line must not use it.\n check.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : 2));\n check.action(async (target: string, cliOptions: CheckCliOptions & Record<string, unknown>) => {\n const readLimits = (level: Level): Limits =>\n Object.fromEntries(\n thresholds.flatMap(({ key }) => {\n const limit = cliOptions[`${level}${key.charAt(0).toUpperCase()}${key.slice(1)}`];\n return limit === undefined ? [] : [[key, limit as number]];\n })\n );\n await runCheckCommand(target, cliOptions, { warning: readLimits('warning'), error: readLimits('error') });\n });\n\n await program.parseAsync();\n}\n\n/** Registers the options shared by the ranking and check commands. */\nfunction addSharedOptions(command: Command): Command {\n return command\n .option('--config <path>', `config file to use instead of the auto-detected ${configFileName}`)\n .option(\n '--duplication-min-tokens <number>',\n 'minimum normalized token count for a duplicate region (default 40)',\n parsePositiveIntegerOption\n )\n .option(\n '--duplication-max-gap-tokens <number>',\n 'maximum token gap merged into one gapped clone group; 0 disables merging (default 30)',\n parseNonNegativeInteger\n )\n .option(\n '--duplication-min-similarity-percent <number>',\n 'minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)',\n parsePercentInteger\n )\n .option('--include-tests', 'include test files and test directories')\n .option('--json', 'print JSON output');\n}\n\nfunction describeLimit(key: string, level: Level): string {\n if (key === duplicationThreshold.key) {\n return `line count from which a duplicated block is ${level === 'error' ? 'an error' : 'a warning'}`;\n }\n const fileThreshold = fileThresholds.find((threshold) => threshold.key === key);\n const subject = fileThreshold\n ? fileThreshold.label\n : `${functionThresholds.find((threshold) => threshold.key === key)?.label} of a function`;\n return `maximum ${subject} without ${level === 'error' ? 'an error' : 'a warning'}`;\n}\n\nfunction toKebabCase(key: string): string {\n return key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);\n}\n\nasync function runRankCommand(target: string, cliOptions: CliOptions): Promise<void> {\n const resolvedTarget = resolveTarget(target);\n const searchDirectory = await configSearchDirectory(resolvedTarget);\n const config = await loadConfig(cliOptions.config, searchDirectory);\n const options = resolveOptions(cliOptions, config);\n const result = await scanTarget(resolvedTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n addCrossFileDuplication(result, options);\n const rankedFiles = rankFiles(result, options.top);\n\n if (options.json) {\n printJson(result, rankedFiles, options);\n } else {\n printTextReport(resolvedTarget, result, rankedFiles, options);\n }\n\n if (result.fatalError || (options.failOnError && result.errors.length > 0)) {\n process.exitCode = 1;\n }\n}\n\nfunction rankFiles(result: ScanResult, top: number): RankedFile[] {\n const candidates = result.files.map(({ file, metrics }) => {\n const formattedFile = formatPath(file, result.displayRoot);\n const duplicatedLines = collectDuplicatedLineNumbers(metrics, result.crossFileDuplication, formattedFile);\n return {\n file: formattedFile,\n worstFunction: findWorstFunction(metrics.functions),\n duplicatedLineCount: duplicatedLines.size,\n duplicatedLineRatio: metrics.lines.code === 0 ? 0 : duplicatedLines.size / metrics.lines.code,\n crossFilePartners: [] as string[],\n ncss: metrics.ncssCount,\n codeLines: metrics.lines.code,\n };\n });\n\n const cognitivePercentile = makePercentile(candidates.map((c) => c.worstFunction?.cognitiveComplexity ?? 0));\n const duplicationPercentile = makePercentile(candidates.map((c) => c.duplicatedLineCount));\n const ncssPercentile = makePercentile(candidates.map((c) => c.ncss));\n\n const ranked = candidates\n .map((candidate) => ({\n ...candidate,\n score:\n cognitivePercentile(candidate.worstFunction?.cognitiveComplexity ?? 0) +\n duplicationPercentile(candidate.duplicatedLineCount) +\n ncssPercentile(candidate.ncss),\n }))\n .toSorted(\n (left, right) => right.score - left.score || right.ncss - left.ncss || left.file.localeCompare(right.file)\n );\n // Partner evidence is attached only to the files that will be reported: expanding partners for\n // every scanned file first would retain TH(F^2) strings when one clone group spans F files, and\n // rescanning all groups per file would be O(files x groups) on the post-scan ranking step.\n attachCrossFilePartners(ranked.slice(0, top), result.crossFileDuplication);\n return ranked;\n}\n\n/** One pass over the groups fills the reported files' partner lists (other files sharing a group). */\nfunction attachCrossFilePartners(\n reportedFiles: RankedFile[],\n crossFileDuplication: CrossFileDuplicationMetrics | undefined\n): void {\n if (!crossFileDuplication) {\n return;\n }\n const partnersByFile = new Map(reportedFiles.map((ranked) => [ranked.file, new Set<string>()]));\n for (const group of crossFileDuplication.groups) {\n for (const file of group.files) {\n const partners = partnersByFile.get(file);\n if (!partners) {\n continue;\n }\n for (const partner of group.files) {\n if (partner !== file) {\n partners.add(partner);\n }\n }\n }\n }\n for (const ranked of reportedFiles) {\n ranked.crossFilePartners = [...(partnersByFile.get(ranked.file) ?? [])].toSorted();\n }\n}\n\n/**\n * Percentile rank within the scanned project: the fraction of files with a strictly smaller\n * value, in [0, 1). Relative ranking needs no absolute threshold, which sidesteps the metric\n * calibration problem entirely — the top of the list is worth refactoring first regardless of\n * where any cutoff would sit.\n */\nfunction makePercentile(values: number[]): (value: number) => number {\n const sorted = values.toSorted((left, right) => left - right);\n return (value: number) => {\n let low = 0;\n let high = sorted.length;\n while (low < high) {\n const middle = (low + high) >> 1;\n if ((sorted[middle] as number) < value) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return sorted.length === 0 ? 0 : low / sorted.length;\n };\n}\n\n/** The highest-cognitive-complexity function; NCSS breaks ties so the larger body is reported. */\nfunction findWorstFunction(functions: FunctionMetrics[]): WorstFunction | undefined {\n let worst: FunctionMetrics | undefined;\n for (const fn of functions) {\n if (\n !worst ||\n fn.cognitiveComplexity > worst.cognitiveComplexity ||\n (fn.cognitiveComplexity === worst.cognitiveComplexity && fn.ncss > worst.ncss)\n ) {\n worst = fn;\n }\n }\n if (!worst) {\n return undefined;\n }\n return {\n name: worst.name ?? '<anonymous>',\n startLine: worst.startLine,\n endLine: worst.endLine,\n cognitiveComplexity: worst.cognitiveComplexity,\n ncss: worst.ncss,\n nestingDepth: worst.nestingDepth,\n };\n}\n\nfunction printJson(result: ScanResult, rankedFiles: RankedFile[], options: ResolvedOptions): void {\n const reportedFiles = rankedFiles.slice(0, options.top);\n writeStdout(\n JSON.stringify(\n {\n summary: summarize(result.files),\n totalRankedFiles: rankedFiles.length,\n truncated: reportedFiles.length < rankedFiles.length,\n files: reportedFiles,\n errors: result.errors,\n warnings: result.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n\nfunction printTextReport(\n target: string,\n result: ScanResult,\n rankedFiles: RankedFile[],\n options: ResolvedOptions\n): void {\n if (result.fatalError) {\n writeStderr(`Error: ${result.fatalError}\\n`);\n return;\n }\n\n const summary = summarize(result.files);\n writeStdout(\n `Measured ${summary.fileCount} files under ${target} (code LOC ${summary.linesOfCode}, NCSS ${summary.ncssCount}, functions ${summary.functionCount})\\n`\n );\n\n if (rankedFiles.length === 0) {\n writeStdout('No measurable files found.\\n');\n } else {\n const reportedFiles = rankedFiles.slice(0, options.top);\n const totalSuffix = rankedFiles.length > reportedFiles.length ? ` of ${rankedFiles.length}` : '';\n writeStdout(`\\nRefactoring candidates (top ${reportedFiles.length}${totalSuffix}):\\n`);\n for (const [index, ranked] of reportedFiles.entries()) {\n writeStdout(`${index + 1}. ${formatRankedFile(ranked)}\\n`);\n }\n }\n\n if (result.warnings.length > 0) {\n writeStderr(`\\nDegraded ${result.warnings.length} files (measured, but excluded from cross-file matching):\\n`);\n for (const warning of result.warnings.slice(0, 10)) {\n writeStderr(`- ${warning}\\n`);\n }\n if (result.warnings.length > 10) {\n writeStderr(`- ... ${result.warnings.length - 10} more\\n`);\n }\n }\n\n if (result.errors.length > 0) {\n writeStderr(`\\nSkipped ${result.errors.length} files or directories:\\n`);\n for (const error of result.errors.slice(0, 10)) {\n writeStderr(`- ${error}\\n`);\n }\n if (result.errors.length > 10) {\n writeStderr(`- ... ${result.errors.length - 10} more\\n`);\n }\n }\n}\n\n/** One ranked file as a single line: the location, the evidence, and where the duplication points. */\nfunction formatRankedFile(ranked: RankedFile): string {\n const reasons: string[] = [];\n if (ranked.worstFunction) {\n const fn = ranked.worstFunction;\n reasons.push(\n `worst function ${fn.name} (L${fn.startLine}-${fn.endLine}) cognitive ${fn.cognitiveComplexity}, NCSS ${fn.ncss}, nesting ${fn.nestingDepth}`\n );\n }\n if (ranked.duplicatedLineCount > 0) {\n const partnersSuffix =\n ranked.crossFilePartners.length > 0\n ? `, shared with ${ranked.crossFilePartners.slice(0, maxCrossFilePartners).join(', ')}${ranked.crossFilePartners.length > maxCrossFilePartners ? ', ...' : ''}`\n : '';\n reasons.push(\n `duplicated lines ${ranked.duplicatedLineCount} (${Math.round(ranked.duplicatedLineRatio * 100)}%${partnersSuffix})`\n );\n }\n reasons.push(`file NCSS ${ranked.ncss}`);\n return `${ranked.file} (score ${ranked.score.toFixed(2)}): ${reasons.join('; ')}`;\n}\n\nfunction summarize(files: FileMetrics[]): {\n fileCount: number;\n functionCount: number;\n linesOfCode: number;\n maxCognitiveComplexity: number;\n ncssCount: number;\n} {\n let functionCount = 0;\n let linesOfCode = 0;\n let maxCognitiveComplexity = 0;\n let ncssCount = 0;\n\n for (const file of files) {\n functionCount += file.metrics.functions.length;\n linesOfCode += file.metrics.lines.code;\n maxCognitiveComplexity = Math.max(maxCognitiveComplexity, file.metrics.maxCognitiveComplexity);\n ncssCount += file.metrics.ncssCount;\n }\n\n return { fileCount: files.length, functionCount, linesOfCode, maxCognitiveComplexity, ncssCount };\n}\n\nfunction parseLimit(value: string): number {\n if (value === 'off') {\n return Infinity;\n }\n const parsed = value.trim() === '' ? Number.NaN : Number(value);\n if (!Number.isFinite(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative number or \"off\".');\n }\n return parsed;\n}\n\nfunction parsePercentInteger(value: string): number {\n const parsed = parsePositiveIntegerOption(value);\n if (parsed > 100) {\n throw new InvalidArgumentError('Expected an integer between 1 and 100.');\n }\n return parsed;\n}\n\nfunction parseNonNegativeInteger(value: string): number {\n if (!/^\\d+$/u.test(value)) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n return parsed;\n}\n\nfunction parsePositiveIntegerOption(value: string): number {\n const parsed = parsePositiveInteger(value);\n if (parsed === undefined) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n return parsed;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { Command, InvalidArgumentError } from 'commander';\nimport { getErrorMessage, parsePositiveInteger } from '@willbooster/shared-lib';\nimport { type CliOptions, configFileName, loadConfig, type ResolvedOptions, resolveOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { runCheckCommand, type CheckCliOptions } from './checkCommand.js';\nimport { loadExclusion } from './exclusion.js';\nimport {\n addCrossFileDuplication,\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatPath,\n resolveTarget,\n scanTarget,\n writeStderr,\n writeStdout,\n type FileMetrics,\n type ScanResult,\n} from './scan.js';\nimport {\n duplicationThreshold,\n fileThresholds,\n functionThresholds,\n levels,\n thresholds,\n type Level,\n type Limits,\n} from './thresholds.js';\nimport { qualifyFunctionName } from './thresholdCheck.js';\nimport type { FunctionMetrics } from './types.js';\n\n/** The worst (highest-cognitive-complexity) function of a file, reported as the ranking evidence. */\ninterface WorstFunction {\n name: string;\n startLine: number;\n endLine: number;\n cognitiveComplexity: number;\n ncss: number;\n nestingDepth: number;\n}\n\n/**\n * One ranked refactoring candidate. The score is the sum of the file's repo-relative percentile\n * ranks (each in [0, 1)) over three dimensions: worst-function cognitive complexity, duplicated\n * lines (within-file and cross-file combined), and file NCSS. Ranking is relative to the scanned\n * project, so no absolute threshold is involved.\n */\ninterface RankedFile {\n file: string;\n score: number;\n worstFunction?: WorstFunction;\n /** Distinct lines covered by within-file duplicate blocks or cross-file duplicate occurrences. */\n duplicatedLineCount: number;\n /** duplicatedLineCount / code lines (0 when the file has no code). */\n duplicatedLineRatio: number;\n /** Other files sharing cross-file duplicate blocks; filled only for the reported top files. */\n crossFilePartners: string[];\n ncss: number;\n codeLines: number;\n}\n\n/** Caps how many cross-file partners a ranked file lists so the report stays scannable. */\nconst maxCrossFilePartners = 3;\n\n// oxlint-disable-next-line unicorn/prefer-top-level-await -- CommonJS build output cannot preserve top-level await.\nvoid main().catch((error: unknown) => {\n writeStderr(`Error: ${getErrorMessage(error)}\\n`);\n process.exitCode = 1;\n});\n\nasync function main(): Promise<void> {\n const program = new Command()\n .name('code-gauge')\n .description('Measure code metrics: rank refactoring candidates (the default command) or check thresholds.');\n\n addSharedOptions(\n program\n .command('rank', { isDefault: true })\n .description('Rank the files of a project by refactoring priority')\n .argument('[target]', 'file or directory to measure', '.')\n )\n .option('--top <number>', 'number of top-ranked files to report (default: 10)', parsePositiveIntegerOption)\n .option('--fail-on-error', 'exit with code 1 when files or directories cannot be scanned')\n .action(runRankCommand);\n\n const check = addSharedOptions(\n program\n .command('check')\n .description(\n 'Report every exceeded threshold as a warning or an error (exit 1 on errors, 2 when files cannot be measured); with --base, only in what the change touches'\n )\n .argument('[target]', 'file or directory to check', '.')\n ).option('--base <ref>', 'check only what the working tree changed since the merge-base of this git ref and HEAD');\n for (const level of levels) {\n for (const { key, defaultLimits } of thresholds) {\n const defaultLimit = defaultLimits[level];\n check.option(\n `--${level}-${toKebabCase(key)} <number>`,\n `${describeLimit(key, level)} (default ${Number.isFinite(defaultLimit) ? defaultLimit : 'off'}); \"off\" disables it`,\n parseLimit\n );\n }\n }\n // Exit code 1 means error-level violations, so a rejected command line must not use it.\n check.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : 2));\n check.action(async (target: string, cliOptions: CheckCliOptions & Record<string, unknown>) => {\n const readLimits = (level: Level): Limits =>\n Object.fromEntries(\n thresholds.flatMap(({ key }) => {\n const limit = cliOptions[`${level}${key.charAt(0).toUpperCase()}${key.slice(1)}`];\n return limit === undefined ? [] : [[key, limit as number]];\n })\n );\n await runCheckCommand(target, cliOptions, { warning: readLimits('warning'), error: readLimits('error') });\n });\n\n await program.parseAsync();\n}\n\n/** Registers the options shared by the ranking and check commands. */\nfunction addSharedOptions(command: Command): Command {\n return command\n .option('--config <path>', `config file to use instead of the auto-detected ${configFileName}`)\n .option(\n '--duplication-min-tokens <number>',\n 'minimum normalized token count for a duplicate region (default 40)',\n parsePositiveIntegerOption\n )\n .option(\n '--duplication-max-gap-tokens <number>',\n 'maximum token gap merged into one gapped clone group; 0 disables merging (default 30)',\n parseNonNegativeInteger\n )\n .option(\n '--duplication-min-similarity-percent <number>',\n 'minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)',\n parsePercentInteger\n )\n .option('--include-tests', 'include test files and test directories')\n .option('--json', 'print JSON output');\n}\n\nfunction describeLimit(key: string, level: Level): string {\n if (key === duplicationThreshold.key) {\n return `code-line count from which a duplicated block is ${level === 'error' ? 'an error' : 'a warning'}`;\n }\n const fileThreshold = fileThresholds.find((threshold) => threshold.key === key);\n const subject = fileThreshold\n ? fileThreshold.label\n : `${functionThresholds.find((threshold) => threshold.key === key)?.label} of a function`;\n return `maximum ${subject} without ${level === 'error' ? 'an error' : 'a warning'}`;\n}\n\nfunction toKebabCase(key: string): string {\n return key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);\n}\n\nasync function runRankCommand(target: string, cliOptions: CliOptions): Promise<void> {\n const resolvedTarget = resolveTarget(target);\n const searchDirectory = await configSearchDirectory(resolvedTarget);\n const config = await loadConfig(cliOptions.config, searchDirectory);\n const options = resolveOptions(cliOptions, config);\n const result = await scanTarget(resolvedTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n addCrossFileDuplication(result, options);\n const rankedFiles = rankFiles(result, options.top);\n\n if (options.json) {\n printJson(result, rankedFiles, options);\n } else {\n printTextReport(resolvedTarget, result, rankedFiles, options);\n }\n\n if (result.fatalError || (options.failOnError && result.errors.length > 0)) {\n process.exitCode = 1;\n }\n}\n\nfunction rankFiles(result: ScanResult, top: number): RankedFile[] {\n const candidates = result.files.map(({ file, metrics }) => {\n const formattedFile = formatPath(file, result.displayRoot);\n const duplicatedLines = collectDuplicatedLineNumbers(metrics, result.crossFileDuplication, formattedFile);\n return {\n file: formattedFile,\n worstFunction: findWorstFunction(metrics.functions),\n duplicatedLineCount: duplicatedLines.size,\n duplicatedLineRatio: metrics.lines.code === 0 ? 0 : duplicatedLines.size / metrics.lines.code,\n crossFilePartners: [] as string[],\n ncss: metrics.ncssCount,\n codeLines: metrics.lines.code,\n };\n });\n\n const cognitivePercentile = makePercentile(candidates.map((c) => c.worstFunction?.cognitiveComplexity ?? 0));\n const duplicationPercentile = makePercentile(candidates.map((c) => c.duplicatedLineCount));\n const ncssPercentile = makePercentile(candidates.map((c) => c.ncss));\n\n const ranked = candidates\n .map((candidate) => ({\n ...candidate,\n score:\n cognitivePercentile(candidate.worstFunction?.cognitiveComplexity ?? 0) +\n duplicationPercentile(candidate.duplicatedLineCount) +\n ncssPercentile(candidate.ncss),\n }))\n .toSorted(\n (left, right) => right.score - left.score || right.ncss - left.ncss || left.file.localeCompare(right.file)\n );\n // Partner evidence is attached only to the files that will be reported: expanding partners for\n // every scanned file first would retain TH(F^2) strings when one clone group spans F files, and\n // rescanning all groups per file would be O(files x groups) on the post-scan ranking step.\n attachCrossFilePartners(ranked.slice(0, top), result.crossFileDuplication);\n return ranked;\n}\n\n/** One pass over the groups fills the reported files' partner lists (other files sharing a group). */\nfunction attachCrossFilePartners(\n reportedFiles: RankedFile[],\n crossFileDuplication: CrossFileDuplicationMetrics | undefined\n): void {\n if (!crossFileDuplication) {\n return;\n }\n const partnersByFile = new Map(reportedFiles.map((ranked) => [ranked.file, new Set<string>()]));\n for (const group of crossFileDuplication.groups) {\n for (const file of group.files) {\n const partners = partnersByFile.get(file);\n if (!partners) {\n continue;\n }\n for (const partner of group.files) {\n if (partner !== file) {\n partners.add(partner);\n }\n }\n }\n }\n for (const ranked of reportedFiles) {\n ranked.crossFilePartners = [...(partnersByFile.get(ranked.file) ?? [])].toSorted();\n }\n}\n\n/**\n * Percentile rank within the scanned project: the fraction of files with a strictly smaller\n * value, in [0, 1). Relative ranking needs no absolute threshold, which sidesteps the metric\n * calibration problem entirely — the top of the list is worth refactoring first regardless of\n * where any cutoff would sit.\n */\nfunction makePercentile(values: number[]): (value: number) => number {\n const sorted = values.toSorted((left, right) => left - right);\n return (value: number) => {\n let low = 0;\n let high = sorted.length;\n while (low < high) {\n const middle = (low + high) >> 1;\n if ((sorted[middle] as number) < value) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return sorted.length === 0 ? 0 : low / sorted.length;\n };\n}\n\n/** The highest-cognitive-complexity function; NCSS breaks ties so the larger body is reported. */\nfunction findWorstFunction(functions: FunctionMetrics[]): WorstFunction | undefined {\n let worst: FunctionMetrics | undefined;\n for (const fn of functions) {\n if (\n !worst ||\n fn.cognitiveComplexity > worst.cognitiveComplexity ||\n (fn.cognitiveComplexity === worst.cognitiveComplexity && fn.ncss > worst.ncss)\n ) {\n worst = fn;\n }\n }\n if (!worst) {\n return undefined;\n }\n return {\n name: qualifyFunctionName(worst),\n startLine: worst.startLine,\n endLine: worst.endLine,\n cognitiveComplexity: worst.cognitiveComplexity,\n ncss: worst.ncss,\n nestingDepth: worst.nestingDepth,\n };\n}\n\nfunction printJson(result: ScanResult, rankedFiles: RankedFile[], options: ResolvedOptions): void {\n const reportedFiles = rankedFiles.slice(0, options.top);\n writeStdout(\n JSON.stringify(\n {\n summary: summarize(result.files),\n totalRankedFiles: rankedFiles.length,\n truncated: reportedFiles.length < rankedFiles.length,\n files: reportedFiles,\n errors: result.errors,\n warnings: result.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n\nfunction printTextReport(\n target: string,\n result: ScanResult,\n rankedFiles: RankedFile[],\n options: ResolvedOptions\n): void {\n if (result.fatalError) {\n writeStderr(`Error: ${result.fatalError}\\n`);\n return;\n }\n\n const summary = summarize(result.files);\n writeStdout(\n `Measured ${summary.fileCount} files under ${target} (code LOC ${summary.linesOfCode}, NCSS ${summary.ncssCount}, functions ${summary.functionCount})\\n`\n );\n\n if (rankedFiles.length === 0) {\n writeStdout('No measurable files found.\\n');\n } else {\n const reportedFiles = rankedFiles.slice(0, options.top);\n const totalSuffix = rankedFiles.length > reportedFiles.length ? ` of ${rankedFiles.length}` : '';\n writeStdout(`\\nRefactoring candidates (top ${reportedFiles.length}${totalSuffix}):\\n`);\n for (const [index, ranked] of reportedFiles.entries()) {\n writeStdout(`${index + 1}. ${formatRankedFile(ranked)}\\n`);\n }\n }\n\n if (result.warnings.length > 0) {\n writeStderr(`\\nDegraded ${result.warnings.length} files (measured, but excluded from cross-file matching):\\n`);\n for (const warning of result.warnings.slice(0, 10)) {\n writeStderr(`- ${warning}\\n`);\n }\n if (result.warnings.length > 10) {\n writeStderr(`- ... ${result.warnings.length - 10} more\\n`);\n }\n }\n\n if (result.errors.length > 0) {\n writeStderr(`\\nSkipped ${result.errors.length} files or directories:\\n`);\n for (const error of result.errors.slice(0, 10)) {\n writeStderr(`- ${error}\\n`);\n }\n if (result.errors.length > 10) {\n writeStderr(`- ... ${result.errors.length - 10} more\\n`);\n }\n }\n}\n\n/** One ranked file as a single line: the location, the evidence, and where the duplication points. */\nfunction formatRankedFile(ranked: RankedFile): string {\n const reasons: string[] = [];\n if (ranked.worstFunction) {\n const fn = ranked.worstFunction;\n reasons.push(\n `worst function ${fn.name} (L${fn.startLine}-${fn.endLine}) cognitive ${fn.cognitiveComplexity}, NCSS ${fn.ncss}, nesting ${fn.nestingDepth}`\n );\n }\n if (ranked.duplicatedLineCount > 0) {\n const partnersSuffix =\n ranked.crossFilePartners.length > 0\n ? `, shared with ${ranked.crossFilePartners.slice(0, maxCrossFilePartners).join(', ')}${ranked.crossFilePartners.length > maxCrossFilePartners ? ', ...' : ''}`\n : '';\n reasons.push(\n `duplicated lines ${ranked.duplicatedLineCount} (${Math.round(ranked.duplicatedLineRatio * 100)}%${partnersSuffix})`\n );\n }\n reasons.push(`file NCSS ${ranked.ncss}`);\n return `${ranked.file} (score ${ranked.score.toFixed(2)}): ${reasons.join('; ')}`;\n}\n\nfunction summarize(files: FileMetrics[]): {\n fileCount: number;\n functionCount: number;\n linesOfCode: number;\n maxCognitiveComplexity: number;\n ncssCount: number;\n} {\n let functionCount = 0;\n let linesOfCode = 0;\n let maxCognitiveComplexity = 0;\n let ncssCount = 0;\n\n for (const file of files) {\n functionCount += file.metrics.functions.length;\n linesOfCode += file.metrics.lines.code;\n maxCognitiveComplexity = Math.max(maxCognitiveComplexity, file.metrics.maxCognitiveComplexity);\n ncssCount += file.metrics.ncssCount;\n }\n\n return { fileCount: files.length, functionCount, linesOfCode, maxCognitiveComplexity, ncssCount };\n}\n\nfunction parseLimit(value: string): number {\n if (value === 'off') {\n return Infinity;\n }\n const parsed = value.trim() === '' ? Number.NaN : Number(value);\n if (!Number.isFinite(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative number or \"off\".');\n }\n return parsed;\n}\n\nfunction parsePercentInteger(value: string): number {\n const parsed = parsePositiveIntegerOption(value);\n if (parsed > 100) {\n throw new InvalidArgumentError('Expected an integer between 1 and 100.');\n }\n return parsed;\n}\n\nfunction parseNonNegativeInteger(value: string): number {\n if (!/^\\d+$/u.test(value)) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n return parsed;\n}\n\nfunction parsePositiveIntegerOption(value: string): number {\n const parsed = parsePositiveInteger(value);\n if (parsed === undefined) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n return parsed;\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- "use strict";const e=require("./duplication.cjs"),t=require("./crossFileNearMiss.cjs"),n=require("./duplicateSelection.cjs");function r(t,r){let{minTokens:o,maxGapTokens:d,minSimilarityPercent:f}=e.resolveDuplicationOptions(r),p=t.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let e of a(t,o))p.push(e);let m=n.selectMaximalGroups(p,s,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),h=c(t,d),g=l([...m.values()],h,d);for(let e of i(t,g,h,o,f))g.push(e);return u(g,t,h)}function i(e,n,r,i,a){let o=e.map(()=>[]);for(let{fileIndex:e,startTokenIndex:t,endTokenIndex:i}of n.flat()){let n=r[e]??0;o[e]?.push({startTokenIndex:t-n,endTokenIndex:i-n})}return t.collectCrossFileNearMissGroups(e,o,i,a).map(t=>t.map(t=>{let n=r[t.fileIndex]??0;return{...t,file:e[t.fileIndex]?.file??``,segments:t.segments.map(e=>({startTokenIndex:e.startTokenIndex+n,endTokenIndex:e.endTokenIndex+n})),startTokenIndex:t.startTokenIndex+n,endTokenIndex:t.endTokenIndex+n}}))}function a(t,n){let r=[],i=[];for(let[n,{tokens:a,containerStatements:o}]of t.entries())a&&o&&(r.push(n),i.push({tokens:a,literalCountPrefix:e.buildLiteralCountPrefix(a),containers:o}));return i.length<2?[]:e.collectSequenceWindowCandidates(i,n,!0).flatMap(({candidate:e,contextIndex:n})=>{let i=r[n],a=i===void 0?void 0:t[i];return i===void 0||a===void 0?[]:[{...e,regionBucket:i,file:a.file}]})}function o(e){return new Set(e.map(e=>e.file)).size>=2}function s(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function c(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function l(t,n,r){let i=t.map(e=>e.map(e=>{let t=e.startTokenIndex+(n[e.regionBucket]??0),r=e.endTokenIndex+(n[e.regionBucket]??0);return{file:e.file,fileIndex:e.regionBucket,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:t,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:t,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return e.mergeAdjacentGroups(i,r,o)}function u(t,n,r){let i=[],a=/* @__PURE__ */ new Map,o=new Map(n.map((e,t)=>[e.file,{tokens:e.tokens,codeLineNumbers:e.codeLineNumbers,offset:r[t]??0}])),s=/* @__PURE__ */ new Map,c=0;for(let n of t){c+=e.countRedundantFragments(n);for(let e of n)d(e,o,s);let t=n.map(({file:e,startLine:t,endLine:n})=>({file:e,startLine:t,endLine:n})).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),r=[...new Set(t.map(({file:e})=>e))];for(let e of r)a.set(e,(a.get(e)??0)+1);i.push({files:r,occurrences:t,tokenCount:Math.min(...n.map(({tokenCount:e})=>e))})}return i.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:i}}function d(t,n,r){let i=n.get(t.file);if(!i?.tokens)return;let a=r.get(t.file);a||(a=/* @__PURE__ */ new Set,r.set(t.file,a));for(let n of t.segments)e.collectSegmentLines({startTokenIndex:n.startTokenIndex-i.offset,endTokenIndex:n.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,a)}exports.measureCrossFileDuplication=r;
1
+ "use strict";const e=require("./duplication.cjs"),t=require("./crossFileNearMiss.cjs"),n=require("./duplicateSelection.cjs");function r(t,r){let{minTokens:o,maxGapTokens:d,minSimilarityPercent:f}=e.resolveDuplicationOptions(r),p=t.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let e of a(t,o))p.push(e);let m=n.selectMaximalGroups(p,s,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),h=c(t,d),g=l([...m.values()],h,d);for(let e of i(t,g,h,o,f))g.push(e);return u(g,t,h)}function i(e,n,r,i,a){let o=e.map(()=>[]);for(let{fileIndex:e,startTokenIndex:t,endTokenIndex:i}of n.flat()){let n=r[e]??0;o[e]?.push({startTokenIndex:t-n,endTokenIndex:i-n})}return t.collectCrossFileNearMissGroups(e,o,i,a).map(t=>t.map(t=>{let n=r[t.fileIndex]??0;return{...t,file:e[t.fileIndex]?.file??``,segments:t.segments.map(e=>({startTokenIndex:e.startTokenIndex+n,endTokenIndex:e.endTokenIndex+n})),startTokenIndex:t.startTokenIndex+n,endTokenIndex:t.endTokenIndex+n}}))}function a(t,n){let r=[],i=[];for(let[n,{tokens:a,containerStatements:o}]of t.entries())a&&o&&(r.push(n),i.push({tokens:a,literalCountPrefix:e.buildLiteralCountPrefix(a),containers:o}));return i.length<2?[]:e.collectSequenceWindowCandidates(i,n,!0).flatMap(({candidate:e,contextIndex:n})=>{let i=r[n],a=i===void 0?void 0:t[i];return i===void 0||a===void 0?[]:[{...e,regionBucket:i,file:a.file}]})}function o(e){return new Set(e.map(e=>e.file)).size>=2}function s(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function c(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function l(t,n,r){let i=t.map(e=>e.map(e=>{let t=e.startTokenIndex+(n[e.regionBucket]??0),r=e.endTokenIndex+(n[e.regionBucket]??0);return{file:e.file,fileIndex:e.regionBucket,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:t,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:t,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return e.mergeAdjacentGroups(i,r,o)}function u(t,n,r){let i=[],a=/* @__PURE__ */ new Map,o=new Map(n.map((e,t)=>[e.file,{tokens:e.tokens,codeLineNumbers:e.codeLineNumbers,offset:r[t]??0}])),s=/* @__PURE__ */ new Map,c=0;for(let n of t){c+=e.countRedundantFragments(n);let t=n.map(e=>{let{file:t,startLine:n,endLine:r,tokenCount:i}=e,a=d(e,o,s);return{file:t,startLine:n,endLine:r,...a&&{lineNumbers:a},tokenCount:i}}).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),r=[...new Set(t.map(({file:e})=>e))];for(let e of r)a.set(e,(a.get(e)??0)+1);i.push({files:r,occurrences:t,tokenCount:Math.min(...n.map(({tokenCount:e})=>e))})}return i.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:i}}function d(t,n,r){let i=n.get(t.file);if(!i?.tokens)return;let a=r.get(t.file);a||(a=/* @__PURE__ */ new Set,r.set(t.file,a));let o=/* @__PURE__ */ new Set;for(let n of t.segments)e.collectSegmentLines({startTokenIndex:n.startTokenIndex-i.offset,endTokenIndex:n.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,o);for(let e of o)a.add(e);return[...o].toSorted((e,t)=>e-t)}exports.measureCrossFileDuplication=r;
2
2
  //# sourceMappingURL=crossFileDuplication.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"crossFileDuplication.cjs","names":["resolveDuplicationOptions","selectMaximalGroups","collectCrossFileNearMissGroups","buildLiteralCountPrefix","collectSequenceWindowCandidates","mergeAdjacentGroups","countRedundantFragments"],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { collectCrossFileNearMissGroups } from './crossFileNearMiss.js';\nimport { selectMaximalGroups, type SelectableRegion } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n startLine: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /**\n * Token count of the smallest occurrence. For exact and gapped groups it is the matched token\n * count every occurrence shares (gaps are not counted); for near-miss (Type-3) groups it is the\n * token count of the smallest whole block or set of matched cores, edited tokens included.\n */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by the tokens of its cross-file occurrences, sorted\n * ascending: the matched tokens of exact and gapped occurrences, and every token of a near-miss\n * block or its matched cores, edited ones included (like within-file near-miss coverage). The unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the covered lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate, SelectableRegion {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n fileIndex: number;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. A copy nested inside a larger group's region (two files share\n * a whole function, a third file only a block of it) is reported with its group, so the third\n * file's copy still shows what it duplicates. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n * Finally, blocks of files that supplied `nearMissBlocks` are compared across files for near-miss\n * (Type-3) clones under `minSimilarityPercent` (see crossFileNearMiss.ts).\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens, minSimilarityPercent } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n const groups = mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens);\n for (const group of collectNearMissGroups(files, groups, tokenOffsets, minTokens, minSimilarityPercent)) {\n groups.push(group);\n }\n return summarize(groups, files, tokenOffsets);\n}\n\n/** Near-miss groups among the blocks the exact groups leave unreported, in the project token space. */\nfunction collectNearMissGroups(\n files: CrossFileDuplicationSourceFile[],\n exactGroups: CrossFileOccurrence[][],\n tokenOffsets: number[],\n minTokens: number,\n minSimilarityPercent: number\n): CrossFileOccurrence[][] {\n const reportedSpansByFile: { startTokenIndex: number; endTokenIndex: number }[][] = files.map(() => []);\n for (const { fileIndex, startTokenIndex, endTokenIndex } of exactGroups.flat()) {\n const offset = tokenOffsets[fileIndex] ?? 0;\n reportedSpansByFile[fileIndex]?.push({\n startTokenIndex: startTokenIndex - offset,\n endTokenIndex: endTokenIndex - offset,\n });\n }\n return collectCrossFileNearMissGroups(files, reportedSpansByFile, minTokens, minSimilarityPercent).map((group) =>\n group.map((occurrence) => {\n const offset = tokenOffsets[occurrence.fileIndex] ?? 0;\n return {\n ...occurrence,\n file: files[occurrence.fileIndex]?.file ?? '',\n segments: occurrence.segments.map((segment) => ({\n startTokenIndex: segment.startTokenIndex + offset,\n endTokenIndex: segment.endTokenIndex + offset,\n })),\n startTokenIndex: occurrence.startTokenIndex + offset,\n endTokenIndex: occurrence.endTokenIndex + offset,\n };\n })\n );\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\n/** A merged group is reported only while it still covers more than one file. */\nfunction spansMultipleFilesAfterMerge(group: CrossFileOccurrence[]): boolean {\n return new Set(group.map((occurrence) => occurrence.file)).size >= 2;\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n fileIndex: candidate.regionBucket,\n spanCountedElsewhere: candidate.nestedInLargerGroup,\n nestedInLargerGroup: candidate.nestedInLargerGroup,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens, spansMultipleFilesAfterMerge);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n for (const occurrence of group) {\n collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n }\n const occurrences = group\n .map(({ file, startLine, endLine }) => ({ file, startLine, endLine }))\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: Math.min(...group.map(({ tokenCount }) => tokenCount)) });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's segment tokens cover (matched tokens of an exact or gapped\n * occurrence, the whole block or matched cores of a near-miss one) to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream. A file that supplied only\n * candidates (no token stream) is skipped rather than approximated from the bounding line range,\n * which would include gap and comment/blank lines and break the field's exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): void {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return;\n }\n let lines = lineNumbersByFile.get(occurrence.file);\n if (!lines) {\n lines = new Set();\n lineNumbersByFile.set(occurrence.file, lines);\n }\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"crossFileDuplication.cjs","names":["resolveDuplicationOptions","selectMaximalGroups","collectCrossFileNearMissGroups","buildLiteralCountPrefix","collectSequenceWindowCandidates","mergeAdjacentGroups","countRedundantFragments"],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { collectCrossFileNearMissGroups } from './crossFileNearMiss.js';\nimport { selectMaximalGroups, type SelectableRegion } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n /**\n * The code lines carrying the occurrence's matched tokens (see\n * DuplicateBlockOccurrence.lineNumbers). Absent for a file that supplied only candidates (no\n * `tokens`), whose matched lines are unknowable.\n */\n lineNumbers?: number[];\n startLine: number;\n /**\n * The tokens of its matched runs, or of a near-miss block or its matched cores with the edited\n * tokens among them; the group's `tokenCount` is the smallest of its occurrences'.\n */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /**\n * Token count of the smallest occurrence. For exact and gapped groups it is the matched token\n * count every occurrence shares (gaps are not counted); for near-miss (Type-3) groups it is the\n * token count of the smallest whole block or set of matched cores, edited tokens included.\n */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by the tokens of its cross-file occurrences, sorted\n * ascending: the matched tokens of exact and gapped occurrences, and every token of a near-miss\n * block or its matched cores, edited ones included (like within-file near-miss coverage). The unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the covered lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate, SelectableRegion {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n fileIndex: number;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. A copy nested inside a larger group's region (two files share\n * a whole function, a third file only a block of it) is reported with its group, so the third\n * file's copy still shows what it duplicates. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n * Finally, blocks of files that supplied `nearMissBlocks` are compared across files for near-miss\n * (Type-3) clones under `minSimilarityPercent` (see crossFileNearMiss.ts).\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens, minSimilarityPercent } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n const groups = mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens);\n for (const group of collectNearMissGroups(files, groups, tokenOffsets, minTokens, minSimilarityPercent)) {\n groups.push(group);\n }\n return summarize(groups, files, tokenOffsets);\n}\n\n/** Near-miss groups among the blocks the exact groups leave unreported, in the project token space. */\nfunction collectNearMissGroups(\n files: CrossFileDuplicationSourceFile[],\n exactGroups: CrossFileOccurrence[][],\n tokenOffsets: number[],\n minTokens: number,\n minSimilarityPercent: number\n): CrossFileOccurrence[][] {\n const reportedSpansByFile: { startTokenIndex: number; endTokenIndex: number }[][] = files.map(() => []);\n for (const { fileIndex, startTokenIndex, endTokenIndex } of exactGroups.flat()) {\n const offset = tokenOffsets[fileIndex] ?? 0;\n reportedSpansByFile[fileIndex]?.push({\n startTokenIndex: startTokenIndex - offset,\n endTokenIndex: endTokenIndex - offset,\n });\n }\n return collectCrossFileNearMissGroups(files, reportedSpansByFile, minTokens, minSimilarityPercent).map((group) =>\n group.map((occurrence) => {\n const offset = tokenOffsets[occurrence.fileIndex] ?? 0;\n return {\n ...occurrence,\n file: files[occurrence.fileIndex]?.file ?? '',\n segments: occurrence.segments.map((segment) => ({\n startTokenIndex: segment.startTokenIndex + offset,\n endTokenIndex: segment.endTokenIndex + offset,\n })),\n startTokenIndex: occurrence.startTokenIndex + offset,\n endTokenIndex: occurrence.endTokenIndex + offset,\n };\n })\n );\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\n/** A merged group is reported only while it still covers more than one file. */\nfunction spansMultipleFilesAfterMerge(group: CrossFileOccurrence[]): boolean {\n return new Set(group.map((occurrence) => occurrence.file)).size >= 2;\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n fileIndex: candidate.regionBucket,\n spanCountedElsewhere: candidate.nestedInLargerGroup,\n nestedInLargerGroup: candidate.nestedInLargerGroup,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens, spansMultipleFilesAfterMerge);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n const occurrences = group\n .map((occurrence) => {\n const { file, startLine, endLine, tokenCount } = occurrence;\n const lineNumbers = collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n return { file, startLine, endLine, ...(lineNumbers && { lineNumbers }), tokenCount };\n })\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: Math.min(...group.map(({ tokenCount }) => tokenCount)) });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's segment tokens cover (matched tokens of an exact or gapped\n * occurrence, the whole block or matched cores of a near-miss one) to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream, and returns them in order.\n * A file that supplied only candidates (no token stream) is skipped rather than approximated from\n * the bounding line range, which would include gap and comment/blank lines and break the field's\n * exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): number[] | undefined {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return undefined;\n }\n let fileLines = lineNumbersByFile.get(occurrence.file);\n if (!fileLines) {\n fileLines = new Set();\n lineNumbersByFile.set(occurrence.file, fileLines);\n }\n const lines = new Set<number>();\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n for (const line of lines) {\n fileLines.add(line);\n }\n return [...lines].toSorted((left, right) => left - right);\n}\n"],"mappings":"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"}
@@ -7,7 +7,18 @@ export interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplica
7
7
  export interface CrossFileDuplicateOccurrence {
8
8
  endLine: number;
9
9
  file: string;
10
+ /**
11
+ * The code lines carrying the occurrence's matched tokens (see
12
+ * DuplicateBlockOccurrence.lineNumbers). Absent for a file that supplied only candidates (no
13
+ * `tokens`), whose matched lines are unknowable.
14
+ */
15
+ lineNumbers?: number[];
10
16
  startLine: number;
17
+ /**
18
+ * The tokens of its matched runs, or of a near-miss block or its matched cores with the edited
19
+ * tokens among them; the group's `tokenCount` is the smallest of its occurrences'.
20
+ */
21
+ tokenCount: number;
11
22
  }
12
23
  export interface CrossFileDuplicateBlockGroup {
13
24
  files: string[];
@@ -1,2 +1,2 @@
1
- import{buildLiteralCountPrefix as e,collectSegmentLines as t,collectSequenceWindowCandidates as n,countRedundantFragments as r,mergeAdjacentGroups as i,resolveDuplicationOptions as a}from"./duplication.js";import{collectCrossFileNearMissGroups as o}from"./crossFileNearMiss.js";import{selectMaximalGroups as s}from"./duplicateSelection.js";function c(e,t){let{minTokens:n,maxGapTokens:r,minSimilarityPercent:i}=a(t),o=e.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let t of u(e,n))o.push(t);let c=s(o,f,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),d=p(e,r),g=m([...c.values()],d,r);for(let t of l(e,g,d,n,i))g.push(t);return h(g,e,d)}function l(e,t,n,r,i){let a=e.map(()=>[]);for(let{fileIndex:e,startTokenIndex:r,endTokenIndex:i}of t.flat()){let t=n[e]??0;a[e]?.push({startTokenIndex:r-t,endTokenIndex:i-t})}return o(e,a,r,i).map(t=>t.map(t=>{let r=n[t.fileIndex]??0;return{...t,file:e[t.fileIndex]?.file??``,segments:t.segments.map(e=>({startTokenIndex:e.startTokenIndex+r,endTokenIndex:e.endTokenIndex+r})),startTokenIndex:t.startTokenIndex+r,endTokenIndex:t.endTokenIndex+r}}))}function u(t,r){let i=[],a=[];for(let[n,{tokens:r,containerStatements:o}]of t.entries())r&&o&&(i.push(n),a.push({tokens:r,literalCountPrefix:e(r),containers:o}));return a.length<2?[]:n(a,r,!0).flatMap(({candidate:e,contextIndex:n})=>{let r=i[n],a=r===void 0?void 0:t[r];return r===void 0||a===void 0?[]:[{...e,regionBucket:r,file:a.file}]})}function d(e){return new Set(e.map(e=>e.file)).size>=2}function f(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function p(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function m(e,t,n){let r=e.map(e=>e.map(e=>{let n=e.startTokenIndex+(t[e.regionBucket]??0),r=e.endTokenIndex+(t[e.regionBucket]??0);return{file:e.file,fileIndex:e.regionBucket,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:n,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:n,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return i(r,n,d)}function h(e,t,n){let i=[],a=/* @__PURE__ */ new Map,o=new Map(t.map((e,t)=>[e.file,{tokens:e.tokens,codeLineNumbers:e.codeLineNumbers,offset:n[t]??0}])),s=/* @__PURE__ */ new Map,c=0;for(let t of e){c+=r(t);for(let e of t)g(e,o,s);let e=t.map(({file:e,startLine:t,endLine:n})=>({file:e,startLine:t,endLine:n})).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),n=[...new Set(e.map(({file:e})=>e))];for(let e of n)a.set(e,(a.get(e)??0)+1);i.push({files:n,occurrences:e,tokenCount:Math.min(...t.map(({tokenCount:e})=>e))})}return i.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:i}}function g(e,n,r){let i=n.get(e.file);if(!i?.tokens)return;let a=r.get(e.file);a||(a=/* @__PURE__ */ new Set,r.set(e.file,a));for(let n of e.segments)t({startTokenIndex:n.startTokenIndex-i.offset,endTokenIndex:n.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,a)}export{c as measureCrossFileDuplication};
1
+ import{buildLiteralCountPrefix as e,collectSegmentLines as t,collectSequenceWindowCandidates as n,countRedundantFragments as r,mergeAdjacentGroups as i,resolveDuplicationOptions as a}from"./duplication.js";import{collectCrossFileNearMissGroups as o}from"./crossFileNearMiss.js";import{selectMaximalGroups as s}from"./duplicateSelection.js";function c(e,t){let{minTokens:n,maxGapTokens:r,minSimilarityPercent:i}=a(t),o=e.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let t of u(e,n))o.push(t);let c=s(o,f,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),d=p(e,r),g=m([...c.values()],d,r);for(let t of l(e,g,d,n,i))g.push(t);return h(g,e,d)}function l(e,t,n,r,i){let a=e.map(()=>[]);for(let{fileIndex:e,startTokenIndex:r,endTokenIndex:i}of t.flat()){let t=n[e]??0;a[e]?.push({startTokenIndex:r-t,endTokenIndex:i-t})}return o(e,a,r,i).map(t=>t.map(t=>{let r=n[t.fileIndex]??0;return{...t,file:e[t.fileIndex]?.file??``,segments:t.segments.map(e=>({startTokenIndex:e.startTokenIndex+r,endTokenIndex:e.endTokenIndex+r})),startTokenIndex:t.startTokenIndex+r,endTokenIndex:t.endTokenIndex+r}}))}function u(t,r){let i=[],a=[];for(let[n,{tokens:r,containerStatements:o}]of t.entries())r&&o&&(i.push(n),a.push({tokens:r,literalCountPrefix:e(r),containers:o}));return a.length<2?[]:n(a,r,!0).flatMap(({candidate:e,contextIndex:n})=>{let r=i[n],a=r===void 0?void 0:t[r];return r===void 0||a===void 0?[]:[{...e,regionBucket:r,file:a.file}]})}function d(e){return new Set(e.map(e=>e.file)).size>=2}function f(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function p(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function m(e,t,n){let r=e.map(e=>e.map(e=>{let n=e.startTokenIndex+(t[e.regionBucket]??0),r=e.endTokenIndex+(t[e.regionBucket]??0);return{file:e.file,fileIndex:e.regionBucket,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:n,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:n,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return i(r,n,d)}function h(e,t,n){let i=[],a=/* @__PURE__ */ new Map,o=new Map(t.map((e,t)=>[e.file,{tokens:e.tokens,codeLineNumbers:e.codeLineNumbers,offset:n[t]??0}])),s=/* @__PURE__ */ new Map,c=0;for(let t of e){c+=r(t);let e=t.map(e=>{let{file:t,startLine:n,endLine:r,tokenCount:i}=e,a=g(e,o,s);return{file:t,startLine:n,endLine:r,...a&&{lineNumbers:a},tokenCount:i}}).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),n=[...new Set(e.map(({file:e})=>e))];for(let e of n)a.set(e,(a.get(e)??0)+1);i.push({files:n,occurrences:e,tokenCount:Math.min(...t.map(({tokenCount:e})=>e))})}return i.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:i}}function g(e,n,r){let i=n.get(e.file);if(!i?.tokens)return;let a=r.get(e.file);a||(a=/* @__PURE__ */ new Set,r.set(e.file,a));let o=/* @__PURE__ */ new Set;for(let n of e.segments)t({startTokenIndex:n.startTokenIndex-i.offset,endTokenIndex:n.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,o);for(let e of o)a.add(e);return[...o].toSorted((e,t)=>e-t)}export{c as measureCrossFileDuplication};
2
2
  //# sourceMappingURL=crossFileDuplication.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"crossFileDuplication.js","names":[],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { collectCrossFileNearMissGroups } from './crossFileNearMiss.js';\nimport { selectMaximalGroups, type SelectableRegion } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n startLine: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /**\n * Token count of the smallest occurrence. For exact and gapped groups it is the matched token\n * count every occurrence shares (gaps are not counted); for near-miss (Type-3) groups it is the\n * token count of the smallest whole block or set of matched cores, edited tokens included.\n */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by the tokens of its cross-file occurrences, sorted\n * ascending: the matched tokens of exact and gapped occurrences, and every token of a near-miss\n * block or its matched cores, edited ones included (like within-file near-miss coverage). The unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the covered lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate, SelectableRegion {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n fileIndex: number;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. A copy nested inside a larger group's region (two files share\n * a whole function, a third file only a block of it) is reported with its group, so the third\n * file's copy still shows what it duplicates. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n * Finally, blocks of files that supplied `nearMissBlocks` are compared across files for near-miss\n * (Type-3) clones under `minSimilarityPercent` (see crossFileNearMiss.ts).\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens, minSimilarityPercent } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n const groups = mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens);\n for (const group of collectNearMissGroups(files, groups, tokenOffsets, minTokens, minSimilarityPercent)) {\n groups.push(group);\n }\n return summarize(groups, files, tokenOffsets);\n}\n\n/** Near-miss groups among the blocks the exact groups leave unreported, in the project token space. */\nfunction collectNearMissGroups(\n files: CrossFileDuplicationSourceFile[],\n exactGroups: CrossFileOccurrence[][],\n tokenOffsets: number[],\n minTokens: number,\n minSimilarityPercent: number\n): CrossFileOccurrence[][] {\n const reportedSpansByFile: { startTokenIndex: number; endTokenIndex: number }[][] = files.map(() => []);\n for (const { fileIndex, startTokenIndex, endTokenIndex } of exactGroups.flat()) {\n const offset = tokenOffsets[fileIndex] ?? 0;\n reportedSpansByFile[fileIndex]?.push({\n startTokenIndex: startTokenIndex - offset,\n endTokenIndex: endTokenIndex - offset,\n });\n }\n return collectCrossFileNearMissGroups(files, reportedSpansByFile, minTokens, minSimilarityPercent).map((group) =>\n group.map((occurrence) => {\n const offset = tokenOffsets[occurrence.fileIndex] ?? 0;\n return {\n ...occurrence,\n file: files[occurrence.fileIndex]?.file ?? '',\n segments: occurrence.segments.map((segment) => ({\n startTokenIndex: segment.startTokenIndex + offset,\n endTokenIndex: segment.endTokenIndex + offset,\n })),\n startTokenIndex: occurrence.startTokenIndex + offset,\n endTokenIndex: occurrence.endTokenIndex + offset,\n };\n })\n );\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\n/** A merged group is reported only while it still covers more than one file. */\nfunction spansMultipleFilesAfterMerge(group: CrossFileOccurrence[]): boolean {\n return new Set(group.map((occurrence) => occurrence.file)).size >= 2;\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n fileIndex: candidate.regionBucket,\n spanCountedElsewhere: candidate.nestedInLargerGroup,\n nestedInLargerGroup: candidate.nestedInLargerGroup,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens, spansMultipleFilesAfterMerge);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n for (const occurrence of group) {\n collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n }\n const occurrences = group\n .map(({ file, startLine, endLine }) => ({ file, startLine, endLine }))\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: Math.min(...group.map(({ tokenCount }) => tokenCount)) });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's segment tokens cover (matched tokens of an exact or gapped\n * occurrence, the whole block or matched cores of a near-miss one) to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream. A file that supplied only\n * candidates (no token stream) is skipped rather than approximated from the bounding line range,\n * which would include gap and comment/blank lines and break the field's exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): void {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return;\n }\n let lines = lineNumbersByFile.get(occurrence.file);\n if (!lines) {\n lines = new Set();\n lineNumbersByFile.set(occurrence.file, lines);\n }\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"crossFileDuplication.js","names":[],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { collectCrossFileNearMissGroups } from './crossFileNearMiss.js';\nimport { selectMaximalGroups, type SelectableRegion } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n /**\n * The code lines carrying the occurrence's matched tokens (see\n * DuplicateBlockOccurrence.lineNumbers). Absent for a file that supplied only candidates (no\n * `tokens`), whose matched lines are unknowable.\n */\n lineNumbers?: number[];\n startLine: number;\n /**\n * The tokens of its matched runs, or of a near-miss block or its matched cores with the edited\n * tokens among them; the group's `tokenCount` is the smallest of its occurrences'.\n */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /**\n * Token count of the smallest occurrence. For exact and gapped groups it is the matched token\n * count every occurrence shares (gaps are not counted); for near-miss (Type-3) groups it is the\n * token count of the smallest whole block or set of matched cores, edited tokens included.\n */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by the tokens of its cross-file occurrences, sorted\n * ascending: the matched tokens of exact and gapped occurrences, and every token of a near-miss\n * block or its matched cores, edited ones included (like within-file near-miss coverage). The unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the covered lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate, SelectableRegion {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n fileIndex: number;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. A copy nested inside a larger group's region (two files share\n * a whole function, a third file only a block of it) is reported with its group, so the third\n * file's copy still shows what it duplicates. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n * Finally, blocks of files that supplied `nearMissBlocks` are compared across files for near-miss\n * (Type-3) clones under `minSimilarityPercent` (see crossFileNearMiss.ts).\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens, minSimilarityPercent } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n const groups = mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens);\n for (const group of collectNearMissGroups(files, groups, tokenOffsets, minTokens, minSimilarityPercent)) {\n groups.push(group);\n }\n return summarize(groups, files, tokenOffsets);\n}\n\n/** Near-miss groups among the blocks the exact groups leave unreported, in the project token space. */\nfunction collectNearMissGroups(\n files: CrossFileDuplicationSourceFile[],\n exactGroups: CrossFileOccurrence[][],\n tokenOffsets: number[],\n minTokens: number,\n minSimilarityPercent: number\n): CrossFileOccurrence[][] {\n const reportedSpansByFile: { startTokenIndex: number; endTokenIndex: number }[][] = files.map(() => []);\n for (const { fileIndex, startTokenIndex, endTokenIndex } of exactGroups.flat()) {\n const offset = tokenOffsets[fileIndex] ?? 0;\n reportedSpansByFile[fileIndex]?.push({\n startTokenIndex: startTokenIndex - offset,\n endTokenIndex: endTokenIndex - offset,\n });\n }\n return collectCrossFileNearMissGroups(files, reportedSpansByFile, minTokens, minSimilarityPercent).map((group) =>\n group.map((occurrence) => {\n const offset = tokenOffsets[occurrence.fileIndex] ?? 0;\n return {\n ...occurrence,\n file: files[occurrence.fileIndex]?.file ?? '',\n segments: occurrence.segments.map((segment) => ({\n startTokenIndex: segment.startTokenIndex + offset,\n endTokenIndex: segment.endTokenIndex + offset,\n })),\n startTokenIndex: occurrence.startTokenIndex + offset,\n endTokenIndex: occurrence.endTokenIndex + offset,\n };\n })\n );\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\n/** A merged group is reported only while it still covers more than one file. */\nfunction spansMultipleFilesAfterMerge(group: CrossFileOccurrence[]): boolean {\n return new Set(group.map((occurrence) => occurrence.file)).size >= 2;\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n fileIndex: candidate.regionBucket,\n spanCountedElsewhere: candidate.nestedInLargerGroup,\n nestedInLargerGroup: candidate.nestedInLargerGroup,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens, spansMultipleFilesAfterMerge);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n const occurrences = group\n .map((occurrence) => {\n const { file, startLine, endLine, tokenCount } = occurrence;\n const lineNumbers = collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n return { file, startLine, endLine, ...(lineNumbers && { lineNumbers }), tokenCount };\n })\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: Math.min(...group.map(({ tokenCount }) => tokenCount)) });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's segment tokens cover (matched tokens of an exact or gapped\n * occurrence, the whole block or matched cores of a near-miss one) to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream, and returns them in order.\n * A file that supplied only candidates (no token stream) is skipped rather than approximated from\n * the bounding line range, which would include gap and comment/blank lines and break the field's\n * exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): number[] | undefined {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return undefined;\n }\n let fileLines = lineNumbersByFile.get(occurrence.file);\n if (!fileLines) {\n fileLines = new Set();\n lineNumbersByFile.set(occurrence.file, fileLines);\n }\n const lines = new Set<number>();\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n for (const line of lines) {\n fileLines.add(line);\n }\n return [...lines].toSorted((left, right) => left - right);\n}\n"],"mappings":"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"}
package/dist/index.d.ts CHANGED
@@ -3,4 +3,4 @@ export type { CrossFileDuplicateBlockGroup, CrossFileDuplicateOccurrence, CrossF
3
3
  export type { CrossFileDuplicateCandidate, CrossFileDuplicationFileData } from './duplication.js';
4
4
  export { defaultLanguages, detectLanguage, supportedLanguages } from './languages.js';
5
5
  export { TreeMeasurer, collectCrossFileDuplicationFileData, collectDuplicationCandidates, collectFunctionTokenSequences, defaultMeasurer, measureCode, } from './metrics.js';
6
- export type { CodeMetrics, DuplicationMetrics, DuplicationOptions, FunctionMetrics, HalsteadMetrics, LanguageDefinition, LanguageName, LineMetrics, MeasureOptions, SupportedLanguage, } from './types.js';
6
+ export type { CodeMetrics, CognitiveBlock, DuplicationMetrics, DuplicationOptions, FunctionMetrics, HalsteadMetrics, LanguageDefinition, LanguageName, LineMetrics, MeasureOptions, SupportedLanguage, } from './types.js';
package/dist/metrics.cjs CHANGED
@@ -1,2 +1,2 @@
1
- "use strict";const e=require("./languages.cjs"),t=require("./nativeMetrics.cjs");var n=class{registry=e.createLanguageRegistry();getSupportedLanguages(){return[...new Set([...this.registry.values()].map(e=>e.name))]}measure(e,n){let r=this.resolveLanguage(n.language),i=n.includeSyntaxTree??!1;return a(t.measureCodeNative(e,r.name,i,n.duplication),i)}measureWithCrossFileData(e,n){let i=this.resolveLanguage(n.language),a=n.includeSyntaxTree??!1;return r(t.measureCodeNative(e,i.name,a,n.duplication,!0),a)}async measureWithCrossFileDataAsync(e,n){let i=this.resolveLanguage(n.language),a=n.includeSyntaxTree??!1;return r(await t.measureCodeNativeAsync(e,i.name,a,n.duplication,!0),a)}collectDuplicationCandidates(e,t){return this.collectCrossFileDuplicationFileData(e,t).candidates}collectCrossFileDuplicationFileData(e,n){let r=this.resolveLanguage(n.language);return i(t.collectCrossFileDataNative(e,r.name,n.duplication?.minTokens))}collectFunctionTokenSequences(e,n){let r=this.resolveLanguage(n.language);return t.collectFunctionTokenSequencesNative(e,r.name)}resolveLanguage(e){let t=this.registry.get(e);if(!t)throw Error(`Unsupported language: ${e}`);return t}};function r(e,t){if(!e.crossFileData)throw Error(`The native addon returned no cross-file duplication data`);return{metrics:a(e,t),crossFileData:i(e.crossFileData)}}function i(e){return{candidates:e.candidates,tokens:e.tokens,containerStatements:e.containerStatements,nearMissBlocks:e.nearMissBlocks,codeLineNumbers:new Set(e.codeLineNumbers)}}function a(e,t){return{language:e.language,bytes:e.bytes,lines:e.lines,functions:e.functions.map(e=>({name:e.name,nodeType:e.nodeType,startLine:e.startLine,startColumn:e.startColumn,endLine:e.endLine,endColumn:e.endColumn,cyclomaticComplexity:e.cyclomaticComplexity,cognitiveComplexity:e.cognitiveComplexity,nestingDepth:e.nestingDepth,ncss:e.ncss,parameterCount:e.parameterCount,halstead:o(e.halsteadCounts),depDegree:e.depDegree})),cyclomaticComplexity:e.cyclomaticComplexity,cognitiveComplexity:e.cognitiveComplexity,maxCognitiveComplexity:e.maxCognitiveComplexity,nestingDepth:e.nestingDepth,ncssCount:e.ncssCount,duplication:e.duplication,halstead:o(e.halsteadCounts),syntaxTree:t?e.syntaxTree:void 0}}function o(e){let{distinctOperators:t,distinctOperands:n,totalOperators:r,totalOperands:i}=e,a=t+n,o=r+i,s=a===0?0:o*Math.log2(a),c=n===0?0:t/2*(i/n);return{distinctOperators:t,distinctOperands:n,totalOperators:r,totalOperands:i,vocabulary:a,length:o,volume:s,difficulty:c,effort:c*s}}const s=new n;function c(e,t){return s.measure(e,t)}function l(e,t){return s.measureWithCrossFileDataAsync(e,t)}function u(e,t){return s.collectDuplicationCandidates(e,t)}function d(e,t){return s.collectFunctionTokenSequences(e,t)}function f(e,t){return s.collectCrossFileDuplicationFileData(e,t)}exports.TreeMeasurer=n,exports.collectCrossFileDuplicationFileData=f,exports.collectDuplicationCandidates=u,exports.collectFunctionTokenSequences=d,exports.defaultMeasurer=s,exports.measureCode=c,exports.measureCodeWithCrossFileDataAsync=l;
1
+ "use strict";const e=require("./languages.cjs"),t=require("./nativeMetrics.cjs");var n=class{registry=e.createLanguageRegistry();getSupportedLanguages(){return[...new Set([...this.registry.values()].map(e=>e.name))]}measure(e,n){let r=this.resolveLanguage(n.language),i=n.includeSyntaxTree??!1;return a(t.measureCodeNative(e,r.name,i,n.duplication),i)}measureWithCrossFileData(e,n){let i=this.resolveLanguage(n.language),a=n.includeSyntaxTree??!1;return r(t.measureCodeNative(e,i.name,a,n.duplication,!0),a)}async measureWithCrossFileDataAsync(e,n){let i=this.resolveLanguage(n.language),a=n.includeSyntaxTree??!1;return r(await t.measureCodeNativeAsync(e,i.name,a,n.duplication,!0),a)}collectDuplicationCandidates(e,t){return this.collectCrossFileDuplicationFileData(e,t).candidates}collectCrossFileDuplicationFileData(e,n){let r=this.resolveLanguage(n.language);return i(t.collectCrossFileDataNative(e,r.name,n.duplication?.minTokens))}collectFunctionTokenSequences(e,n){let r=this.resolveLanguage(n.language);return t.collectFunctionTokenSequencesNative(e,r.name)}resolveLanguage(e){let t=this.registry.get(e);if(!t)throw Error(`Unsupported language: ${e}`);return t}};function r(e,t){if(!e.crossFileData)throw Error(`The native addon returned no cross-file duplication data`);return{metrics:a(e,t),crossFileData:i(e.crossFileData)}}function i(e){return{candidates:e.candidates,tokens:e.tokens,containerStatements:e.containerStatements,nearMissBlocks:e.nearMissBlocks,codeLineNumbers:new Set(e.codeLineNumbers)}}function a(e,t){return{language:e.language,bytes:e.bytes,lines:e.lines,codeLineNumbers:e.codeLineNumbers,functions:e.functions.map(e=>({name:e.name,containerName:e.containerName,nodeType:e.nodeType,startLine:e.startLine,startColumn:e.startColumn,endLine:e.endLine,endColumn:e.endColumn,cyclomaticComplexity:e.cyclomaticComplexity,cognitiveComplexity:e.cognitiveComplexity,cognitiveBlocks:e.cognitiveBlocks??[],nestingDepth:e.nestingDepth,ncss:e.ncss,parameterCount:e.parameterCount,halstead:o(e.halsteadCounts),depDegree:e.depDegree})),cyclomaticComplexity:e.cyclomaticComplexity,cognitiveComplexity:e.cognitiveComplexity,maxCognitiveComplexity:e.maxCognitiveComplexity,nestingDepth:e.nestingDepth,ncssCount:e.ncssCount,duplication:e.duplication,halstead:o(e.halsteadCounts),syntaxTree:t?e.syntaxTree:void 0}}function o(e){let{distinctOperators:t,distinctOperands:n,totalOperators:r,totalOperands:i}=e,a=t+n,o=r+i,s=a===0?0:o*Math.log2(a),c=n===0?0:t/2*(i/n);return{distinctOperators:t,distinctOperands:n,totalOperators:r,totalOperands:i,vocabulary:a,length:o,volume:s,difficulty:c,effort:c*s}}const s=new n;function c(e,t){return s.measure(e,t)}function l(e,t){return s.measureWithCrossFileDataAsync(e,t)}function u(e,t){return s.collectDuplicationCandidates(e,t)}function d(e,t){return s.collectFunctionTokenSequences(e,t)}function f(e,t){return s.collectCrossFileDuplicationFileData(e,t)}exports.TreeMeasurer=n,exports.collectCrossFileDuplicationFileData=f,exports.collectDuplicationCandidates=u,exports.collectFunctionTokenSequences=d,exports.defaultMeasurer=s,exports.measureCode=c,exports.measureCodeWithCrossFileDataAsync=l;
2
2
  //# sourceMappingURL=metrics.cjs.map