code-gauge 4.7.1 → 5.0.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.
- package/README.md +35 -10
- package/THIRD-PARTY-NOTICES.txt +595 -29
- package/dist/cli.cjs +3 -3
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +3 -3
- package/dist/cli.js.map +1 -1
- package/dist/cliConfig.cjs +1 -1
- package/dist/cliConfig.cjs.map +1 -1
- package/dist/cliConfig.d.ts +13 -3
- package/dist/cliConfig.js +1 -1
- package/dist/cliConfig.js.map +1 -1
- package/dist/crossFileDuplication.cjs +1 -1
- package/dist/crossFileDuplication.cjs.map +1 -1
- package/dist/crossFileDuplication.js +1 -1
- package/dist/crossFileDuplication.js.map +1 -1
- package/dist/crossFileNearMiss.cjs +1 -1
- package/dist/crossFileNearMiss.cjs.map +1 -1
- package/dist/crossFileNearMiss.js +1 -1
- package/dist/crossFileNearMiss.js.map +1 -1
- package/dist/diffCommand.cjs +3 -3
- package/dist/diffCommand.cjs.map +1 -1
- package/dist/diffCommand.js +3 -3
- package/dist/diffCommand.js.map +1 -1
- package/dist/duplicateSelection.cjs +1 -1
- package/dist/duplicateSelection.cjs.map +1 -1
- package/dist/duplicateSelection.js +1 -1
- package/dist/duplicateSelection.js.map +1 -1
- package/dist/duplication.cjs +1 -1
- package/dist/duplication.cjs.map +1 -1
- package/dist/duplication.js +1 -1
- package/dist/duplication.js.map +1 -1
- package/dist/exclusion.cjs +2 -0
- package/dist/exclusion.cjs.map +1 -0
- package/dist/exclusion.d.ts +30 -0
- package/dist/exclusion.js +2 -0
- package/dist/exclusion.js.map +1 -0
- package/dist/git.cjs +1 -1
- package/dist/git.cjs.map +1 -1
- package/dist/git.d.ts +16 -0
- package/dist/git.js +1 -1
- package/dist/git.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -1
- package/dist/languages.cjs +1 -1
- package/dist/languages.cjs.map +1 -1
- package/dist/languages.js +1 -1
- package/dist/languages.js.map +1 -1
- package/dist/newDuplication.cjs +2 -0
- package/dist/newDuplication.cjs.map +1 -0
- package/dist/newDuplication.d.ts +40 -0
- package/dist/newDuplication.js +2 -0
- package/dist/newDuplication.js.map +1 -0
- package/dist/regressionGate.cjs +1 -1
- package/dist/regressionGate.cjs.map +1 -1
- package/dist/regressionGate.d.ts +8 -6
- package/dist/regressionGate.js +1 -1
- package/dist/regressionGate.js.map +1 -1
- package/dist/scan.cjs +1 -1
- package/dist/scan.cjs.map +1 -1
- package/dist/scan.d.ts +12 -3
- package/dist/scan.js +1 -1
- package/dist/scan.js.map +1 -1
- package/dist/worker.cjs +1 -1
- package/dist/worker.cjs.map +1 -1
- package/dist/worker.js +1 -1
- package/dist/worker.js.map +1 -1
- package/native/Cargo.lock +47 -15
- package/native/Cargo.toml +9 -9
- package/native/code-gauge.wasm +0 -0
- package/native/src/complexity.rs +26 -33
- package/native/src/dep_degree.rs +43 -32
- package/native/src/duplication.rs +158 -40
- package/native/src/functions.rs +20 -62
- package/native/src/languages.rs +28 -7
- package/native/src/measure.rs +16 -27
- package/native/src/ncss.rs +32 -23
- package/native/src/tree_index.rs +9 -0
- package/native/src/util.rs +12 -57
- package/package.json +10 -10
package/dist/diffCommand.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"diffCommand.js","names":[],"sources":["../src/diffCommand.ts"],"sourcesContent":["import { lstat, readFile, realpath, stat } from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { loadConfig, resolveGateOptions, resolveOptions, type ResolvedOptions } from './cliConfig.js';\nimport { measureCrossFileDuplication, type CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport type { CrossFileDuplicationFileData } from './duplication.js';\nimport {\n listChangedFiles,\n listRepositoryFiles,\n listSymlinkPathsAtRevision,\n readFileAtRevision,\n resolveMergeBase,\n resolveRepoRoot,\n type ChangedFile,\n} from './git.js';\nimport { collectFunctionTokenSequences } from './metrics.js';\nimport {\n evaluateRegressionGate,\n type CheckedFunctionReport,\n type GateFileInput,\n type GateFunctionValues,\n type GateResult,\n} from './regressionGate.js';\nimport {\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatError,\n formatPath,\n getLanguage,\n isScannedPath,\n measureWithCrossFileData,\n resolveTarget,\n scanListedFiles,\n writeStderr,\n writeStdout,\n type FileMetrics,\n} from './scan.js';\nimport type { CodeMetrics, LanguageName } from './types.js';\n\n/** Raw options of the `diff` subcommand; every field but base is undefined unless the flag was passed. */\nexport interface DiffCliOptions {\n base: string;\n config?: string;\n duplicationMinTokens?: number;\n duplicationMaxGapTokens?: number;\n duplicationMinSimilarityPercent?: number;\n includeTests?: boolean;\n json?: boolean;\n full?: boolean;\n}\n\n/** One changed file measured at both revisions, plus its duplication-universe contribution. */\ninterface PreparedFile {\n changed: ChangedFile;\n /** Repository-relative display path: the head path, or the base path for deleted files. */\n displayFile: string;\n /** Whether the file is gated (under the target directory); others only feed the base universe. */\n gated: boolean;\n headFile?: FileMetrics;\n baseMetrics?: CodeMetrics;\n baseCandidates?: CrossFileDuplicationFileData;\n baseFunctionTokens?: Int32Array[];\n headFunctionTokens?: Int32Array[];\n}\n\n/** A scanned file that git considers part of the project, keyed by its repository-relative path. */\ninterface ScannedFile {\n relativePath: string;\n file: FileMetrics;\n}\n\n/**\n * Runs the regression gate: measures the files changed relative to the merge-base with the base\n * ref, at both revisions (`git cat-file`; no checkout, no persisted baseline), and reports only\n * violations. Exit codes: 0 all gates passed, 1 violations, 2 changed files could not be measured.\n */\nexport async function runDiffCommand(target: string, cliOptions: DiffCliOptions): Promise<void> {\n try {\n await runGate(target, cliOptions);\n } catch (error) {\n writeStderr(`Error: ${formatError(error)}\\n`);\n process.exitCode = 2;\n }\n}\n\nasync function runGate(target: string, cliOptions: DiffCliOptions): Promise<void> {\n const resolvedTarget = resolveTarget(target);\n const config = await loadConfig(cliOptions.config, await configSearchDirectory(resolvedTarget));\n const options = resolveOptions(cliOptions, config);\n const gateOptions = resolveGateOptions(config);\n\n // The target may be a typo'd path whose ancestors don't exist either; repository discovery must\n // still run so the mistyped target gets its own diagnostic instead of a git spawn failure.\n const repoRoot = await realpath(\n await resolveRepoRoot(await firstExistingDirectory(await configSearchDirectory(resolvedTarget)))\n );\n const mergeBase = await resolveMergeBase(repoRoot, cliOptions.base);\n const changedFiles = await listChangedFiles(repoRoot, mergeBase);\n\n // Every git-visible file (tracked or untracked non-ignored) is measured at head: that provides\n // the head metrics of changed files and the project-wide duplication universe, so copy-paste\n // from unchanged code into changed files is caught. Scanning the explicit git list (instead of\n // walking the tree) keeps ignored artifact directories from ever being parsed: they exist in\n // neither the base commit nor CI, so they would only cost time and skew duplication counts.\n // Unchanged files are byte-identical at both revisions, so the base universe is the same scan\n // with the changed files' contents swapped for their merge-base blobs.\n const repositoryFiles = await listRepositoryFiles(repoRoot);\n const baseSymlinkPaths = await listSymlinkPathsAtRevision(repoRoot, mergeBase);\n const scan = await scanListedFiles(repoRoot, repositoryFiles, options);\n // A run-wide failure (a missing native addon) invalidates the whole gate: surface it once as\n // the fatal error (exit 2) instead of diagnosing every changed file as unmeasured.\n if (scan.fatalError) {\n throw new Error(scan.fatalError);\n }\n const scannedFiles: ScannedFile[] = scan.files.map((file) => ({\n relativePath: formatPath(file.file, scan.displayRoot),\n file,\n }));\n\n // A measurement failure on ANY scannable changed file forces exit 2 — deliberately including\n // files outside a scoped target, because cross-file function matching and the base duplication\n // universe for the gated files depend on them. Failures elsewhere (unchanged files) and\n // unsupported changed paths degrade to warnings.\n const changedPaths = new Set(\n changedFiles\n .flatMap((changed) => [changed.headPath, ...(changed.basePath === undefined ? [] : [changed.basePath])])\n .filter((changedPath) => isScannedPath(changedPath, options))\n );\n const errors: string[] = [];\n const warnings = [...scan.warnings];\n for (const error of scan.errors) {\n if ([...changedPaths].some((changedPath) => error.startsWith(`${changedPath}:`))) {\n errors.push(error);\n } else {\n warnings.push(error);\n }\n }\n\n const { canonicalTarget, targetExists } = await canonicalizeTarget(resolvedTarget);\n const prepared = await prepareChangedFiles(\n changedFiles,\n { repoRoot, mergeBase, canonicalTarget, options, scannedFiles, baseSymlinkPaths, scanErrors: [...errors] },\n errors,\n warnings\n );\n // A gate must not fail open on a mistyped target: a nonexistent path is only acceptable when it\n // still matches changed files (e.g. a fully deleted directory).\n if (!targetExists && !prepared.some((file) => file.gated)) {\n throw new Error(`target \"${target}\" does not exist and matches no changed file`);\n }\n\n // Non-gated files (outside the target, or renamed out of scan scope) still feed function\n // matching and the duplication universes; the evaluator reports nothing for them.\n const { baseCross, headCross } = measureDuplicationUniverses(prepared, scannedFiles, options);\n const inputs = prepared.map((file) => toGateInput(file, baseCross, headCross));\n const result = evaluateRegressionGate(inputs, gateOptions);\n\n if (cliOptions.json) {\n printJsonReport(cliOptions, mergeBase, result, inputs, errors, warnings);\n } else {\n printTextReport(cliOptions, mergeBase, result, errors, warnings);\n }\n\n if (errors.length > 0) {\n process.exitCode = 2;\n } else if (result.violations.length > 0) {\n process.exitCode = 1;\n }\n}\n\n/** The target may not exist (e.g. only deleted files under it); fall back to the resolved path. */\nasync function canonicalizeTarget(resolvedTarget: string): Promise<{ canonicalTarget: string; targetExists: boolean }> {\n try {\n return { canonicalTarget: await realpath(resolvedTarget), targetExists: true };\n } catch {\n return { canonicalTarget: resolvedTarget, targetExists: false };\n }\n}\n\ninterface GateContext {\n repoRoot: string;\n mergeBase: string;\n canonicalTarget: string;\n options: ResolvedOptions;\n scannedFiles: ScannedFile[];\n /** Paths that are symbolic links at the merge-base; like head symlinks, they are not gated. */\n baseSymlinkPaths: Set<string>;\n /** Errors the head scan recorded against changed files. */\n scanErrors: readonly string[];\n}\n\nasync function prepareChangedFiles(\n changedFiles: ChangedFile[],\n context: GateContext,\n errors: string[],\n warnings: string[]\n): Promise<PreparedFile[]> {\n const headByPath = new Map(context.scannedFiles.map(({ relativePath, file }) => [relativePath, file]));\n // Files are prepared concurrently (git reads and base measurements); each records its diagnostics\n // on its own, and they are appended in changed-file order so the report stays deterministic.\n const results = await mapConcurrently(changedFiles, os.availableParallelism() * 2, async (changed) => {\n const fileErrors: string[] = [];\n const fileWarnings: string[] = [];\n const file = await prepareChangedFile(changed, context, headByPath, fileErrors, fileWarnings);\n return { file, fileErrors, fileWarnings };\n });\n const prepared: PreparedFile[] = [];\n for (const { file, fileErrors, fileWarnings } of results) {\n errors.push(...fileErrors);\n warnings.push(...fileWarnings);\n if (file) {\n prepared.push(file);\n }\n }\n return prepared;\n}\n\n/** `Promise.all(items.map(map))` with at most `limit` calls pending at once, results in input order. */\nasync function mapConcurrently<T, R>(items: readonly T[], limit: number, map: (item: T) => Promise<R>): Promise<R[]> {\n const results: R[] = [];\n let nextIndex = 0;\n const work = async (): Promise<void> => {\n while (nextIndex < items.length) {\n const index = nextIndex;\n nextIndex += 1;\n results[index] = await map(items[index] as T);\n }\n };\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, work));\n return results;\n}\n\nasync function prepareChangedFile(\n changed: ChangedFile,\n context: GateContext,\n headByPath: Map<string, FileMetrics>,\n errors: string[],\n warnings: string[]\n): Promise<PreparedFile | undefined> {\n // Symbolic links are skipped on both sides, mirroring scanListedFiles: git stores only the\n // target string, so a symlink blob is not measurable source.\n const headScannable =\n changed.status !== 'deleted' &&\n isScannedPath(changed.headPath, context.options) &&\n !(await isSymbolicLink(path.join(context.repoRoot, changed.headPath)));\n // A base path outside the scan scope (renamed from a test/ignored directory, or an unsupported\n // extension) was never measurable code: its content gates as new code instead of ratcheting\n // against a blob the scanner would not have measured.\n const baseScannable =\n changed.basePath !== undefined &&\n isScannedPath(changed.basePath, context.options) &&\n !context.baseSymlinkPaths.has(changed.basePath);\n if (!headScannable && !baseScannable) {\n return undefined;\n }\n\n const displayFile = changed.status === 'deleted' ? (changed.basePath as string) : changed.headPath;\n const headFile = headScannable ? headByPath.get(changed.headPath) : undefined;\n if (headScannable && !headFile) {\n reportUnmeasuredChangedFile(changed.headPath, context.scanErrors, errors);\n return undefined;\n }\n\n const file: PreparedFile = {\n changed,\n displayFile,\n // A file whose head left the scan scope still contributes its base functions to matching and\n // its base blob to the base universe, but nothing about it is gated or reported.\n gated:\n headScannable || changed.status === 'deleted'\n ? isWithinTarget(path.join(context.repoRoot, displayFile), context.canonicalTarget)\n : false,\n headFile,\n };\n\n if (baseScannable && !(await measureBaseRevision(file, changed.basePath as string, context, errors, warnings))) {\n return undefined;\n }\n\n if (headFile) {\n await collectHeadFunctionTokens(file, headFile, context, warnings);\n }\n\n return file;\n}\n\n/**\n * The scan covers exactly the git-visible list, so a scannable changed path can only be missing\n * after a measurement failure (already recorded as an error) or a silent exclusion (an alias of\n * an already-visited file, or absence from the git list). Failing loudly keeps the gate from\n * passing with the file unchecked.\n */\nfunction reportUnmeasuredChangedFile(headPath: string, scanErrors: readonly string[], errors: string[]): void {\n if (!scanErrors.some((error) => error.startsWith(`${headPath}:`))) {\n errors.push(`${headPath}: changed file was not measured`);\n }\n}\n\nasync function collectHeadFunctionTokens(\n file: PreparedFile,\n headFile: FileMetrics,\n context: GateContext,\n warnings: string[]\n): Promise<void> {\n try {\n const headContent = await readFile(headFile.file, 'utf8');\n file.headFunctionTokens = collectFunctionTokenSequences(headContent, {\n language: getLanguage(file.changed.headPath, context.options) as LanguageName,\n duplication: context.options.duplication,\n });\n } catch (error) {\n // Only rename re-matching degrades without token sequences; the head metrics still gate.\n warnings.push(`${file.displayFile}: function token sequences unavailable: ${formatError(error)}`);\n }\n}\n\n/**\n * Measures the merge-base blob into `file`; false (with an error recorded) only when the metrics\n * themselves cannot be measured. The auxiliary collections (duplication candidates, token\n * sequences) may fail independently of the metrics, so their failure only degrades duplication\n * data and rename re-matching — the function-level ratchets still run.\n */\nasync function measureBaseRevision(\n file: PreparedFile,\n basePath: string,\n context: GateContext,\n errors: string[],\n warnings: string[]\n): Promise<boolean> {\n const measureOptions = {\n language: getLanguage(basePath, context.options) as LanguageName,\n duplication: context.options.duplication,\n };\n let baseContent;\n try {\n baseContent = await readFileAtRevision(context.repoRoot, context.mergeBase, basePath);\n const measured = await measureWithCrossFileData(baseContent, measureOptions);\n file.baseMetrics = measured.metrics;\n file.baseCandidates = measured.crossFileData;\n if (measured.crossFileError !== undefined) {\n warnings.push(`${basePath} (at merge-base): duplication candidates unavailable: ${measured.crossFileError}`);\n }\n } catch (error) {\n errors.push(`${basePath} (at merge-base): ${formatError(error)}`);\n return false;\n }\n try {\n file.baseFunctionTokens = collectFunctionTokenSequences(baseContent, measureOptions);\n } catch (error) {\n warnings.push(`${basePath} (at merge-base): function token sequences unavailable: ${formatError(error)}`);\n }\n return true;\n}\n\nasync function isSymbolicLink(absolutePath: string): Promise<boolean> {\n const stats = await lstat(absolutePath).catch(() => {});\n return stats?.isSymbolicLink() ?? false;\n}\n\n/** Walks up to the nearest existing DIRECTORY, so git commands never spawn in a missing or non-directory cwd. */\nasync function firstExistingDirectory(directory: string): Promise<string> {\n let current = directory;\n while (true) {\n const stats = await stat(current).catch(() => {});\n if (stats?.isDirectory()) {\n return current;\n }\n const parent = path.dirname(current);\n if (parent === current) {\n return current;\n }\n current = parent;\n }\n}\n\nfunction isWithinTarget(candidate: string, targetDirectory: string): boolean {\n const relative = path.relative(targetDirectory, candidate);\n return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));\n}\n\nfunction measureDuplicationUniverses(\n prepared: PreparedFile[],\n scannedFiles: ScannedFile[],\n options: ResolvedOptions\n): { baseCross?: CrossFileDuplicationMetrics; headCross?: CrossFileDuplicationMetrics } {\n const headSources = scannedFiles.flatMap(({ relativePath, file }) =>\n file.duplicationCandidates ? [{ file: relativePath, ...file.duplicationCandidates }] : []\n );\n\n const changedHeadPaths = new Set(\n prepared.flatMap((file) => (file.changed.status === 'deleted' ? [] : [file.changed.headPath]))\n );\n const baseSources = headSources.filter((source) => !changedHeadPaths.has(source.file));\n for (const file of prepared) {\n if (file.baseCandidates && file.changed.basePath !== undefined) {\n baseSources.push({ file: file.changed.basePath, ...file.baseCandidates });\n }\n }\n\n return {\n baseCross: baseSources.length >= 2 ? measureCrossFileDuplication(baseSources, options.duplication) : undefined,\n headCross: headSources.length >= 2 ? measureCrossFileDuplication(headSources, options.duplication) : undefined,\n };\n}\n\nfunction toGateInput(\n file: PreparedFile,\n baseCross: CrossFileDuplicationMetrics | undefined,\n headCross: CrossFileDuplicationMetrics | undefined\n): GateFileInput {\n return {\n file: file.displayFile,\n baseMetrics: file.baseMetrics,\n headMetrics: file.headFile?.metrics,\n baseFunctionTokens: file.baseFunctionTokens,\n headFunctionTokens: file.headFunctionTokens,\n baseDuplicatedLineCount:\n file.baseMetrics === undefined || file.changed.basePath === undefined\n ? 0\n : countDuplicatedLines(file.baseMetrics, baseCross, file.changed.basePath),\n headDuplicatedLineCount:\n file.changed.status === 'deleted'\n ? 0\n : countDuplicatedLines(file.headFile?.metrics, headCross, file.changed.headPath),\n duplicationPartners: collectPartners(headCross, file.changed.headPath),\n gated: file.gated,\n };\n}\n\nfunction countDuplicatedLines(\n metrics: CodeMetrics | undefined,\n cross: CrossFileDuplicationMetrics | undefined,\n file: string\n): number {\n return collectDuplicatedLineNumbers(metrics, cross, file).size;\n}\n\nfunction collectPartners(cross: CrossFileDuplicationMetrics | undefined, file: string): string[] {\n if (!cross) {\n return [];\n }\n const partners = new Set<string>();\n for (const group of cross.groups) {\n if (group.files.includes(file)) {\n for (const partner of group.files) {\n if (partner !== file) {\n partners.add(partner);\n }\n }\n }\n }\n return [...partners].toSorted();\n}\n\nfunction printTextReport(\n cliOptions: DiffCliOptions,\n mergeBase: string,\n result: GateResult,\n errors: string[],\n warnings: string[]\n): void {\n const shortBase = mergeBase.slice(0, 12);\n if (errors.length > 0) {\n // Unmeasured files were not gated, so \"0 violations\" would be vacuous; never claim a pass.\n writeStdout(\n `Regression gate could not complete: ${errors.length} measurement failures (details on stderr)` +\n `${result.violations.length > 0 ? `; ${result.violations.length} violations in the measured files` : ''} (base ${cliOptions.base}, merge-base ${shortBase}).\\n`\n );\n printViolations(result);\n } else if (result.violations.length === 0) {\n writeStdout(\n `Regression gate passed: ${result.checkedFileCount} changed files, ${result.checkedFunctionCount} functions checked (base ${cliOptions.base}, merge-base ${shortBase}).\\n`\n );\n } else {\n writeStdout(\n `Regression gate vs ${cliOptions.base} (merge-base ${shortBase}): ${result.violations.length} violations\\n`\n );\n printViolations(result);\n }\n\n if (cliOptions.full) {\n printFullDetails(result);\n }\n\n for (const warning of warnings) {\n writeStderr(`Warning: ${warning}\\n`);\n }\n for (const error of errors) {\n writeStderr(`Error: ${error}\\n`);\n }\n}\n\nfunction printViolations(result: GateResult): void {\n for (const [index, violation] of result.violations.entries()) {\n writeStdout(`${index + 1}. ${violation.message}\\n`);\n }\n}\n\n/** Base -> head values of every checked function; kept behind --full for humans and trending. */\nfunction printFullDetails(result: GateResult): void {\n if (result.checkedFunctions.length === 0) {\n return;\n }\n writeStdout('\\nChecked functions (base -> head):\\n');\n for (const report of result.checkedFunctions) {\n writeStdout(`- ${formatFunctionReport(report)}\\n`);\n }\n}\n\nfunction formatFunctionReport(report: CheckedFunctionReport): string {\n const range = (\n select: (values: GateFunctionValues) => number,\n format: (value: number) => string = String\n ): string => {\n const head = format(select(report.head));\n return report.base ? `${format(select(report.base))} -> ${head}` : head;\n };\n const values = [\n `cognitive ${range((fn) => fn.cognitiveComplexity)}`,\n `NCSS ${range((fn) => fn.ncss)}`,\n `nesting ${range((fn) => fn.nestingDepth)}`,\n `DepDegree ${range((fn) => fn.depDegree)}`,\n `volume ${range(\n (fn) => fn.halsteadVolume,\n (value) => value.toFixed(1)\n )}`,\n ];\n return `${report.file}:${report.startLine}-${report.endLine} ${report.name}${report.base ? '' : ' (new)'}: ${values.join(', ')}`;\n}\n\nfunction printJsonReport(\n cliOptions: DiffCliOptions,\n mergeBase: string,\n result: GateResult,\n inputs: GateFileInput[],\n errors: string[],\n warnings: string[]\n): void {\n const report: Record<string, unknown> = {\n base: cliOptions.base,\n mergeBase,\n passed: result.violations.length === 0 && errors.length === 0,\n violations: result.violations,\n checkedFileCount: result.checkedFileCount,\n checkedFunctionCount: result.checkedFunctionCount,\n newFunctionCount: result.newFunctionCount,\n errors,\n warnings,\n };\n if (cliOptions.full) {\n report.files = inputs\n .filter((input) => input.gated !== false)\n .map((input) => ({\n file: input.file,\n baseFunctionCount: input.baseMetrics?.functions.length ?? 0,\n headFunctionCount: input.headMetrics?.functions.length ?? 0,\n baseNcss: input.baseMetrics?.ncssCount ?? 0,\n headNcss: input.headMetrics?.ncssCount ?? 0,\n baseMaxCognitiveComplexity: input.baseMetrics?.maxCognitiveComplexity ?? 0,\n headMaxCognitiveComplexity: input.headMetrics?.maxCognitiveComplexity ?? 0,\n baseDuplicatedLineCount: input.baseDuplicatedLineCount,\n headDuplicatedLineCount: input.headDuplicatedLineCount,\n duplicationPartners: input.duplicationPartners,\n functions: result.checkedFunctions.filter((fn) => fn.file === input.file),\n }));\n }\n writeStdout(JSON.stringify(report, undefined, 2) + '\\n');\n}\n"],"mappings":"k0BA4EA,eAAsB,EAAe,EAAgB,EAA2C,CAC9F,GAAI,CACF,MAAM,EAAQ,EAAQ,CAAU,CAClC,OAAS,EAAO,CACd,EAAY,UAAU,EAAY,CAAK,EAAE,GAAG,EAC5C,QAAQ,SAAW,CACrB,CACF,CAEA,eAAe,EAAQ,EAAgB,EAA2C,CAChF,IAAM,EAAiB,EAAc,CAAM,EACrC,EAAS,MAAM,EAAW,EAAW,OAAQ,MAAM,EAAsB,CAAc,CAAC,EACxF,EAAU,EAAe,EAAY,CAAM,EAC3C,EAAc,EAAmB,CAAM,EAIvC,EAAW,MAAM,EACrB,MAAM,EAAgB,MAAM,EAAuB,MAAM,EAAsB,CAAc,CAAC,CAAC,CACjG,EACM,EAAY,MAAM,EAAiB,EAAU,EAAW,IAAI,EAC5D,EAAe,MAAM,EAAiB,EAAU,CAAS,EASzD,EAAkB,MAAM,EAAoB,CAAQ,EACpD,EAAmB,MAAM,EAA2B,EAAU,CAAS,EACvE,EAAO,MAAM,EAAgB,EAAU,EAAiB,CAAO,EAGrE,GAAI,EAAK,WACP,MAAU,MAAM,EAAK,UAAU,EAEjC,IAAM,EAA8B,EAAK,MAAM,IAAK,IAAU,CAC5D,aAAc,EAAW,EAAK,KAAM,EAAK,WAAW,EACpD,MACF,EAAE,EAMI,EAAe,IAAI,IACvB,EACG,QAAS,GAAY,CAAC,EAAQ,SAAU,GAAI,EAAQ,WAAa,IAAA,GAAY,CAAC,EAAI,CAAC,EAAQ,QAAQ,CAAE,CAAC,CAAC,CACvG,OAAQ,GAAgB,EAAc,EAAa,CAAO,CAAC,CAChE,EACM,EAAmB,CAAC,EACpB,EAAW,CAAC,GAAG,EAAK,QAAQ,EAClC,IAAK,IAAM,KAAS,EAAK,OACnB,CAAC,GAAG,CAAY,CAAC,CAAC,KAAM,GAAgB,EAAM,WAAW,GAAG,EAAY,EAAE,CAAC,EAC7E,EAAO,KAAK,CAAK,EAEjB,EAAS,KAAK,CAAK,EAIvB,GAAM,CAAE,kBAAiB,gBAAiB,MAAM,EAAmB,CAAc,EAC3E,EAAW,MAAM,EACrB,EACA,CAAE,WAAU,YAAW,kBAAiB,UAAS,eAAc,mBAAkB,WAAY,CAAC,GAAG,CAAM,CAAE,EACzG,EACA,CACF,EAGA,GAAI,CAAC,GAAgB,CAAC,EAAS,KAAM,GAAS,EAAK,KAAK,EACtD,MAAU,MAAM,WAAW,EAAO,6CAA6C,EAKjF,GAAM,CAAE,YAAW,aAAc,EAA4B,EAAU,EAAc,CAAO,EACtF,EAAS,EAAS,IAAK,GAAS,EAAY,EAAM,EAAW,CAAS,CAAC,EACvE,EAAS,EAAuB,EAAQ,CAAW,EAErD,EAAW,KACb,EAAgB,EAAY,EAAW,EAAQ,EAAQ,EAAQ,CAAQ,EAEvE,EAAgB,EAAY,EAAW,EAAQ,EAAQ,CAAQ,EAG7D,EAAO,OAAS,EAClB,QAAQ,SAAW,EACV,EAAO,WAAW,OAAS,IACpC,QAAQ,SAAW,EAEvB,CAGA,eAAe,EAAmB,EAAqF,CACrH,GAAI,CACF,MAAO,CAAE,gBAAiB,MAAM,EAAS,CAAc,EAAG,aAAc,EAAK,CAC/E,MAAQ,CACN,MAAO,CAAE,gBAAiB,EAAgB,aAAc,EAAM,CAChE,CACF,CAcA,eAAe,EACb,EACA,EACA,EACA,EACyB,CACzB,IAAM,EAAa,IAAI,IAAI,EAAQ,aAAa,KAAK,CAAE,eAAc,UAAW,CAAC,EAAc,CAAI,CAAC,CAAC,EAG/F,EAAU,MAAM,EAAgB,EAAc,EAAG,qBAAqB,EAAI,EAAG,KAAO,IAAY,CACpG,IAAM,EAAuB,CAAC,EACxB,EAAyB,CAAC,EAEhC,MAAO,CAAE,KAAA,MADU,EAAmB,EAAS,EAAS,EAAY,EAAY,CAAY,EAC7E,aAAY,cAAa,CAC1C,CAAC,EACK,EAA2B,CAAC,EAClC,IAAK,GAAM,CAAE,OAAM,aAAY,kBAAkB,EAC/C,EAAO,KAAK,GAAG,CAAU,EACzB,EAAS,KAAK,GAAG,CAAY,EACzB,GACF,EAAS,KAAK,CAAI,EAGtB,OAAO,CACT,CAGA,eAAe,EAAsB,EAAqB,EAAe,EAA4C,CACnH,IAAM,EAAe,CAAC,EAClB,EAAY,EAShB,OADA,MAAM,QAAQ,IAAI,MAAM,KAAK,CAAE,OAAQ,KAAK,IAAI,EAAO,EAAM,MAAM,CAAE,EAAG,SAPhC,CACtC,KAAO,EAAY,EAAM,QAAQ,CAC/B,IAAM,EAAQ,EACd,GAAa,EACb,EAAQ,GAAS,MAAM,EAAI,EAAM,EAAW,CAC9C,CACF,CAC4E,CAAC,EACtE,CACT,CAEA,eAAe,EACb,EACA,EACA,EACA,EACA,EACmC,CAGnC,IAAM,EACJ,EAAQ,SAAW,WACnB,EAAc,EAAQ,SAAU,EAAQ,OAAO,GAC/C,CAAE,MAAM,EAAe,EAAK,KAAK,EAAQ,SAAU,EAAQ,QAAQ,CAAC,EAIhE,EACJ,EAAQ,WAAa,IAAA,IACrB,EAAc,EAAQ,SAAU,EAAQ,OAAO,GAC/C,CAAC,EAAQ,iBAAiB,IAAI,EAAQ,QAAQ,EAChD,GAAI,CAAC,GAAiB,CAAC,EACrB,OAGF,IAAM,EAAc,EAAQ,SAAW,UAAa,EAAQ,SAAsB,EAAQ,SACpF,EAAW,EAAgB,EAAW,IAAI,EAAQ,QAAQ,EAAI,IAAA,GACpE,GAAI,GAAiB,CAAC,EAAU,CAC9B,EAA4B,EAAQ,SAAU,EAAQ,WAAY,CAAM,EACxE,MACF,CAEA,IAAM,EAAqB,CACzB,UACA,cAGA,MACE,GAAiB,EAAQ,SAAW,UAChC,EAAe,EAAK,KAAK,EAAQ,SAAU,CAAW,EAAG,EAAQ,eAAe,EAChF,GACN,UACF,EAEI,OAAmB,MAAM,EAAoB,EAAM,EAAQ,SAAoB,EAAS,EAAQ,CAAQ,EAQ5G,OAJI,GACF,MAAM,EAA0B,EAAM,EAAU,EAAS,CAAQ,EAG5D,CACT,CAQA,SAAS,EAA4B,EAAkB,EAA+B,EAAwB,CACvG,EAAW,KAAM,GAAU,EAAM,WAAW,GAAG,EAAS,EAAE,CAAC,GAC9D,EAAO,KAAK,GAAG,EAAS,gCAAgC,CAE5D,CAEA,eAAe,EACb,EACA,EACA,EACA,EACe,CACf,GAAI,CACF,IAAM,EAAc,MAAM,EAAS,EAAS,KAAM,MAAM,EACxD,EAAK,mBAAqB,EAA8B,EAAa,CACnE,SAAU,EAAY,EAAK,QAAQ,SAAU,EAAQ,OAAO,EAC5D,YAAa,EAAQ,QAAQ,WAC/B,CAAC,CACH,OAAS,EAAO,CAEd,EAAS,KAAK,GAAG,EAAK,YAAY,0CAA0C,EAAY,CAAK,GAAG,CAClG,CACF,CAQA,eAAe,EACb,EACA,EACA,EACA,EACA,EACkB,CAClB,IAAM,EAAiB,CACrB,SAAU,EAAY,EAAU,EAAQ,OAAO,EAC/C,YAAa,EAAQ,QAAQ,WAC/B,EACI,EACJ,GAAI,CACF,EAAc,MAAM,EAAmB,EAAQ,SAAU,EAAQ,UAAW,CAAQ,EACpF,IAAM,EAAW,MAAM,EAAyB,EAAa,CAAc,EAC3E,EAAK,YAAc,EAAS,QAC5B,EAAK,eAAiB,EAAS,cAC3B,EAAS,iBAAmB,IAAA,IAC9B,EAAS,KAAK,GAAG,EAAS,wDAAwD,EAAS,gBAAgB,CAE/G,OAAS,EAAO,CAEd,OADA,EAAO,KAAK,GAAG,EAAS,oBAAoB,EAAY,CAAK,GAAG,EACzD,EACT,CACA,GAAI,CACF,EAAK,mBAAqB,EAA8B,EAAa,CAAc,CACrF,OAAS,EAAO,CACd,EAAS,KAAK,GAAG,EAAS,0DAA0D,EAAY,CAAK,GAAG,CAC1G,CACA,MAAO,EACT,CAEA,eAAe,EAAe,EAAwC,CAEpE,OAAO,MADa,EAAM,CAAY,CAAC,CAAC,UAAY,CAAC,CAAC,EAAA,EACxC,eAAe,GAAK,EACpC,CAGA,eAAe,EAAuB,EAAoC,CACxE,IAAI,EAAU,EACd,OAAa,CAEX,IAAI,MADgB,EAAK,CAAO,CAAC,CAAC,UAAY,CAAC,CAAC,EAAA,EACrC,YAAY,EACrB,OAAO,EAET,IAAM,EAAS,EAAK,QAAQ,CAAO,EACnC,GAAI,IAAW,EACb,OAAO,EAET,EAAU,CACZ,CACF,CAEA,SAAS,EAAe,EAAmB,EAAkC,CAC3E,IAAM,EAAW,EAAK,SAAS,EAAiB,CAAS,EACzD,OAAO,IAAa,IAAO,CAAC,EAAS,WAAW,KAAK,EAAK,KAAK,GAAK,IAAa,MAAQ,CAAC,EAAK,WAAW,CAAQ,CACpH,CAEA,SAAS,EACP,EACA,EACA,EACsF,CACtF,IAAM,EAAc,EAAa,SAAS,CAAE,eAAc,UACxD,EAAK,sBAAwB,CAAC,CAAE,KAAM,EAAc,GAAG,EAAK,qBAAsB,CAAC,EAAI,CAAC,CAC1F,EAEM,EAAmB,IAAI,IAC3B,EAAS,QAAS,GAAU,EAAK,QAAQ,SAAW,UAAY,CAAC,EAAI,CAAC,EAAK,QAAQ,QAAQ,CAAE,CAC/F,EACM,EAAc,EAAY,OAAQ,GAAW,CAAC,EAAiB,IAAI,EAAO,IAAI,CAAC,EACrF,IAAK,IAAM,KAAQ,EACb,EAAK,gBAAkB,EAAK,QAAQ,WAAa,IAAA,IACnD,EAAY,KAAK,CAAE,KAAM,EAAK,QAAQ,SAAU,GAAG,EAAK,cAAe,CAAC,EAI5E,MAAO,CACL,UAAW,EAAY,QAAU,EAAI,EAA4B,EAAa,EAAQ,WAAW,EAAI,IAAA,GACrG,UAAW,EAAY,QAAU,EAAI,EAA4B,EAAa,EAAQ,WAAW,EAAI,IAAA,EACvG,CACF,CAEA,SAAS,EACP,EACA,EACA,EACe,CACf,MAAO,CACL,KAAM,EAAK,YACX,YAAa,EAAK,YAClB,YAAa,EAAK,UAAU,QAC5B,mBAAoB,EAAK,mBACzB,mBAAoB,EAAK,mBACzB,wBACE,EAAK,cAAgB,IAAA,IAAa,EAAK,QAAQ,WAAa,IAAA,GACxD,EACA,EAAqB,EAAK,YAAa,EAAW,EAAK,QAAQ,QAAQ,EAC7E,wBACE,EAAK,QAAQ,SAAW,UACpB,EACA,EAAqB,EAAK,UAAU,QAAS,EAAW,EAAK,QAAQ,QAAQ,EACnF,oBAAqB,EAAgB,EAAW,EAAK,QAAQ,QAAQ,EACrE,MAAO,EAAK,KACd,CACF,CAEA,SAAS,EACP,EACA,EACA,EACQ,CACR,OAAO,EAA6B,EAAS,EAAO,CAAI,CAAC,CAAC,IAC5D,CAEA,SAAS,EAAgB,EAAgD,EAAwB,CAC/F,GAAI,CAAC,EACH,MAAO,CAAC,EAEV,IAAM,EAAW,IAAI,IACrB,IAAK,IAAM,KAAS,EAAM,OACxB,GAAI,EAAM,MAAM,SAAS,CAAI,EACtB,IAAA,IAAM,KAAW,EAAM,MACtB,IAAY,GACd,EAAS,IAAI,CAAO,EAK5B,MAAO,CAAC,GAAG,CAAQ,CAAC,CAAC,SAAS,CAChC,CAEA,SAAS,EACP,EACA,EACA,EACA,EACA,EACM,CACN,IAAM,EAAY,EAAU,MAAM,EAAG,EAAE,EACnC,EAAO,OAAS,GAElB,EACE,uCAAuC,EAAO,OAAO,2CAChD,EAAO,WAAW,OAAS,EAAI,KAAK,EAAO,WAAW,OAAO,mCAAqC,GAAG,SAAS,EAAW,KAAK,eAAe,EAAU,KAC9J,EACA,EAAgB,CAAM,GACb,EAAO,WAAW,SAAW,EACtC,EACE,2BAA2B,EAAO,iBAAiB,kBAAkB,EAAO,qBAAqB,2BAA2B,EAAW,KAAK,eAAe,EAAU,KACvK,GAEA,EACE,sBAAsB,EAAW,KAAK,eAAe,EAAU,KAAK,EAAO,WAAW,OAAO,cAC/F,EACA,EAAgB,CAAM,GAGpB,EAAW,MACb,EAAiB,CAAM,EAGzB,IAAK,IAAM,KAAW,EACpB,EAAY,YAAY,EAAQ,GAAG,EAErC,IAAK,IAAM,KAAS,EAClB,EAAY,UAAU,EAAM,GAAG,CAEnC,CAEA,SAAS,EAAgB,EAA0B,CACjD,IAAK,GAAM,CAAC,EAAO,KAAc,EAAO,WAAW,QAAQ,EACzD,EAAY,GAAG,EAAQ,EAAE,IAAI,EAAU,QAAQ,GAAG,CAEtD,CAGA,SAAS,EAAiB,EAA0B,CAC9C,KAAO,iBAAiB,SAAW,EAGvC,GAAY;;CAAuC,EACnD,IAAK,IAAM,KAAU,EAAO,iBAC1B,EAAY,KAAK,EAAqB,CAAM,EAAE,GAAG,CAFA,CAIrD,CAEA,SAAS,EAAqB,EAAuC,CACnE,IAAM,GACJ,EACA,EAAoC,SACzB,CACX,IAAM,EAAO,EAAO,EAAO,EAAO,IAAI,CAAC,EACvC,OAAO,EAAO,KAAO,GAAG,EAAO,EAAO,EAAO,IAAI,CAAC,EAAE,MAAM,IAAS,CACrE,EACM,EAAS,CACb,aAAa,EAAO,GAAO,EAAG,mBAAmB,IACjD,QAAQ,EAAO,GAAO,EAAG,IAAI,IAC7B,WAAW,EAAO,GAAO,EAAG,YAAY,IACxC,aAAa,EAAO,GAAO,EAAG,SAAS,IACvC,UAAU,EACP,GAAO,EAAG,eACV,GAAU,EAAM,QAAQ,CAAC,CAC5B,GACF,EACA,MAAO,GAAG,EAAO,KAAK,GAAG,EAAO,UAAU,GAAG,EAAO,QAAQ,GAAG,EAAO,OAAO,EAAO,KAAO,GAAK,SAAS,IAAI,EAAO,KAAK,IAAI,GAC/H,CAEA,SAAS,EACP,EACA,EACA,EACA,EACA,EACA,EACM,CACN,IAAM,EAAkC,CACtC,KAAM,EAAW,KACjB,YACA,OAAQ,EAAO,WAAW,SAAW,GAAK,EAAO,SAAW,EAC5D,WAAY,EAAO,WACnB,iBAAkB,EAAO,iBACzB,qBAAsB,EAAO,qBAC7B,iBAAkB,EAAO,iBACzB,SACA,UACF,EACI,EAAW,OACb,EAAO,MAAQ,EACZ,OAAQ,GAAU,EAAM,QAAU,EAAK,CAAC,CACxC,IAAK,IAAW,CACf,KAAM,EAAM,KACZ,kBAAmB,EAAM,aAAa,UAAU,QAAU,EAC1D,kBAAmB,EAAM,aAAa,UAAU,QAAU,EAC1D,SAAU,EAAM,aAAa,WAAa,EAC1C,SAAU,EAAM,aAAa,WAAa,EAC1C,2BAA4B,EAAM,aAAa,wBAA0B,EACzE,2BAA4B,EAAM,aAAa,wBAA0B,EACzE,wBAAyB,EAAM,wBAC/B,wBAAyB,EAAM,wBAC/B,oBAAqB,EAAM,oBAC3B,UAAW,EAAO,iBAAiB,OAAQ,GAAO,EAAG,OAAS,EAAM,IAAI,CAC1E,EAAE,GAEN,EAAY,KAAK,UAAU,EAAQ,IAAA,GAAW,CAAC,EAAI;CAAI,CACzD"}
|
|
1
|
+
{"version":3,"file":"diffCommand.js","names":[],"sources":["../src/diffCommand.ts"],"sourcesContent":["import { lstat, readFile, realpath, stat } from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { loadConfig, resolveGateOptions, resolveOptions, type ResolvedOptions } from './cliConfig.js';\nimport {\n measureCrossFileDuplication,\n type CrossFileDuplicateOccurrence,\n type CrossFileDuplicationMetrics,\n} from './crossFileDuplication.js';\nimport type { CrossFileDuplicationFileData, Token } from './duplication.js';\nimport { keepPaths, loadRepositoryExclusion, type Exclusion } from './exclusion.js';\nimport {\n listChangedFiles,\n listLineHunks,\n listRepositoryFiles,\n listSymlinkPathsAtRevision,\n readFileAtRevision,\n resolveMergeBase,\n resolveRepoRoot,\n type ChangedFile,\n} from './git.js';\nimport { collectFunctionTokenSequences } from './metrics.js';\nimport {\n findNewlyDuplicatedLines,\n indexPartnersByLine,\n type CloneOccurrence,\n type DuplicationChange,\n type LineHunk,\n} from './newDuplication.js';\nimport {\n evaluateRegressionGate,\n type CheckedFunctionReport,\n type GateFileInput,\n type GateFunctionValues,\n type GateResult,\n} from './regressionGate.js';\nimport {\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatError,\n formatPath,\n getLanguage,\n isScannedPath,\n measureWithCrossFileData,\n resolveTarget,\n scanListedFiles,\n writeStderr,\n writeStdout,\n type FileMetrics,\n} from './scan.js';\nimport type { CodeMetrics, LanguageName } from './types.js';\n\n/** Raw options of the `diff` subcommand; every field but base is undefined unless the flag was passed. */\nexport interface DiffCliOptions {\n base: string;\n config?: string;\n duplicationMinTokens?: number;\n duplicationMaxGapTokens?: number;\n duplicationMinSimilarityPercent?: number;\n includeTests?: boolean;\n json?: boolean;\n full?: boolean;\n}\n\n/** One changed file measured at both revisions, plus its duplication-universe contribution. */\ninterface PreparedFile {\n changed: ChangedFile;\n /** Repository-relative display path: the head path, or the base path for deleted files. */\n displayFile: string;\n /** Whether the file is gated (under the target directory); others only feed the base universe. */\n gated: boolean;\n headFile?: FileMetrics;\n headContent?: string;\n baseMetrics?: CodeMetrics;\n baseContent?: string;\n /**\n * The line diff from the measured base to the measured head; with one side unmeasured, every\n * line of the other counts as added or deleted.\n */\n hunks?: LineHunk[];\n baseCandidates?: CrossFileDuplicationFileData;\n baseFunctionTokens?: Int32Array[];\n headFunctionTokens?: Int32Array[];\n}\n\n/** A scanned file that git considers part of the project, keyed by its repository-relative path. */\ninterface ScannedFile {\n relativePath: string;\n file: FileMetrics;\n}\n\n/**\n * Runs the regression gate: measures the files changed relative to the merge-base with the base\n * ref, at both revisions (`git cat-file`; no checkout, no persisted baseline), and reports only\n * violations. Exit codes: 0 all gates passed, 1 violations, 2 changed files could not be measured.\n */\nexport async function runDiffCommand(target: string, cliOptions: DiffCliOptions): Promise<void> {\n try {\n await runGate(target, cliOptions);\n } catch (error) {\n writeStderr(`Error: ${formatError(error)}\\n`);\n process.exitCode = 2;\n }\n}\n\nasync function runGate(target: string, cliOptions: DiffCliOptions): Promise<void> {\n const resolvedTarget = resolveTarget(target);\n const config = await loadConfig(cliOptions.config, await configSearchDirectory(resolvedTarget));\n const options = resolveOptions(cliOptions, config);\n const gateOptions = resolveGateOptions(config);\n\n // The target may be a typo'd path whose ancestors don't exist either; repository discovery must\n // still run so the mistyped target gets its own diagnostic instead of a git spawn failure.\n const repoRoot = await realpath(\n await resolveRepoRoot(await firstExistingDirectory(await configSearchDirectory(resolvedTarget)))\n );\n const mergeBase = await resolveMergeBase(repoRoot, cliOptions.base);\n const changedFiles = await listChangedFiles(repoRoot, mergeBase);\n\n // Every git-visible file (tracked or untracked non-ignored) is measured at head: that provides\n // the head metrics of changed files and the project-wide duplication universe, so copy-paste\n // from unchanged code into changed files is caught. Scanning the explicit git list (instead of\n // walking the tree) keeps ignored artifact directories from ever being parsed: they exist in\n // neither the base commit nor CI, so they would only cost time and skew duplication counts.\n // Unchanged files are byte-identical at both revisions, so the base universe is the same scan\n // with the changed files' contents swapped for their merge-base blobs.\n const repositoryFiles = await listRepositoryFiles(repoRoot);\n const baseSymlinkPaths = await listSymlinkPathsAtRevision(repoRoot, mergeBase);\n const { canonicalTarget, targetExists } = await canonicalizeTarget(resolvedTarget);\n const targetStat = await stat(canonicalTarget).catch(() => {});\n const targetFile = targetStat?.isFile() ? canonicalTarget : undefined;\n // A targeted file git does not list (an ignored one) is still measured, as an addition.\n const unlistedTarget = targetFile && path.relative(repoRoot, targetFile).split(path.sep).join('/');\n if (unlistedTarget !== undefined && !unlistedTarget.startsWith('../') && !repositoryFiles.has(unlistedTarget)) {\n repositoryFiles.add(unlistedTarget);\n changedFiles.push({ status: 'added', headPath: unlistedTarget });\n }\n const explicitFiles = listExplicitlyTargetedPaths(targetFile, repoRoot, changedFiles);\n // Base blobs are excluded by the attributes of the revision they come from. A changed\n // .gitattributes can re-include files whose bytes did not change, so then every file's base\n // attributes are needed to find them.\n const attributesChanged = changedFiles.some((changed) =>\n [changed.headPath, changed.basePath].some(\n (file) => file !== undefined && path.posix.basename(file) === '.gitattributes'\n )\n );\n const basePaths = changedFiles.flatMap((changed) => (changed.basePath === undefined ? [] : [changed.basePath]));\n const [headAttributesExclusion, baseAttributesExclusion] = await Promise.all([\n loadRepositoryExclusion(repoRoot, repositoryFiles, options.exclude),\n loadRepositoryExclusion(\n repoRoot,\n attributesChanged ? [...new Set([...basePaths, ...repositoryFiles])] : basePaths,\n options.exclude,\n mergeBase\n ),\n ]);\n const headExclusion = keepPaths(headAttributesExclusion, explicitFiles);\n const baseExclusion = keepPaths(baseAttributesExclusion, explicitFiles);\n const scan = await scanListedFiles(\n repoRoot,\n repositoryFiles,\n { ...options, loadExclusion: () => Promise.resolve(headExclusion) },\n explicitFiles\n );\n // A run-wide failure (a missing native addon) invalidates the whole gate: surface it once as\n // the fatal error (exit 2) instead of diagnosing every changed file as unmeasured.\n if (scan.fatalError) {\n throw new Error(scan.fatalError);\n }\n const scannedFiles: ScannedFile[] = scan.files.map((file) => ({\n relativePath: formatPath(file.file, scan.displayRoot),\n file,\n }));\n if (attributesChanged) {\n changedFiles.push(\n ...(await listReincludedFiles(scannedFiles, changedFiles, repoRoot, headExclusion, baseExclusion))\n );\n }\n\n // A measurement failure on ANY scannable changed file forces exit 2 — deliberately including\n // files outside a scoped target, because cross-file function matching and the base duplication\n // universe for the gated files depend on them. Failures elsewhere (unchanged files) and\n // unsupported changed paths degrade to warnings.\n const changedPaths = new Set(\n changedFiles\n .flatMap((changed) => [changed.headPath, ...(changed.basePath === undefined ? [] : [changed.basePath])])\n .filter((changedPath) => isScannedPath(changedPath, options, explicitFiles.has(path.join(repoRoot, changedPath))))\n );\n const errors: string[] = [];\n const warnings = [...scan.warnings];\n for (const error of scan.errors) {\n if ([...changedPaths].some((changedPath) => error.startsWith(`${changedPath}:`))) {\n errors.push(error);\n } else {\n warnings.push(error);\n }\n }\n\n const prepared = await prepareChangedFiles(\n changedFiles,\n {\n repoRoot,\n mergeBase,\n canonicalTarget,\n options,\n scannedFiles,\n baseSymlinkPaths,\n scanErrors: [...errors],\n headExclusion,\n baseExclusion,\n explicitFiles,\n generatedHeadFiles: new Set(scan.generatedFiles),\n },\n errors,\n warnings\n );\n // A gate must not fail open on a mistyped target: a nonexistent path is only acceptable when it\n // still matches changed files (e.g. a fully deleted directory).\n if (!targetExists && !prepared.some((file) => file.gated)) {\n throw new Error(`target \"${target}\" does not exist and matches no changed file`);\n }\n\n // Non-gated files (outside the target, or renamed out of scan scope) still feed function\n // matching and the duplication universes; the evaluator reports nothing for them.\n const { baseCross, headCross } = measureDuplicationUniverses(prepared, scannedFiles, options);\n const renamedPaths = new Map(\n prepared.flatMap(({ changed }) =>\n changed.status === 'renamed' && changed.basePath !== undefined\n ? [[changed.basePath, changed.headPath] as const]\n : []\n )\n );\n const headPathOf = (basePath: string): string => renamedPaths.get(basePath) ?? basePath;\n const lineSignaturesOf = createLineSignatureIndex(scannedFiles);\n const newlyDuplicatedLines = findNewlyDuplicatedLines(\n prepared.map((file) => toDuplicationChange(file, baseCross, headCross, headPathOf, lineSignaturesOf))\n );\n const inputs = prepared.map((file, index) => toGateInput(file, newlyDuplicatedLines[index] ?? [], headCross));\n const result = evaluateRegressionGate(inputs, gateOptions);\n\n if (cliOptions.json) {\n printJsonReport(cliOptions, mergeBase, result, inputs, errors, warnings);\n } else {\n printTextReport(cliOptions, mergeBase, result, errors, warnings);\n }\n\n if (errors.length > 0) {\n process.exitCode = 2;\n } else if (result.violations.length > 0) {\n process.exitCode = 1;\n }\n}\n\n/** Content with a generated-code marker, to ask whether an exclusion honors the marker for a path. */\nconst markedCode = '/* @generated */';\n\n/**\n * Unchanged files the head scan measured but the merge-base would not have: excluded by its\n * attributes, or generated by content without the `-linguist-generated` override the working tree\n * adds. Their code is newly measured, so it gates as a modification from an unmeasurable base. The\n * head side comes from the scan, which already applied scope, symbolic-link, and exclusion rules.\n */\nasync function listReincludedFiles(\n scannedFiles: readonly ScannedFile[],\n changedFiles: readonly ChangedFile[],\n repoRoot: string,\n headExclusion: Exclusion,\n baseExclusion: Exclusion\n): Promise<ChangedFile[]> {\n const changedPaths = new Set(changedFiles.flatMap((changed) => [changed.headPath, changed.basePath]));\n const reincluded: ChangedFile[] = [];\n for (const { relativePath, file } of scannedFiles) {\n const absolutePath = path.join(repoRoot, relativePath);\n if (changedPaths.has(relativePath)) {\n continue;\n }\n // The same content is generated at one revision only when its override differs, so the\n // content is read just for files the working tree newly marks -linguist-generated.\n const overrideAdded =\n baseExclusion.isGeneratedCode(absolutePath, markedCode) &&\n !headExclusion.isGeneratedCode(absolutePath, markedCode);\n if (\n baseExclusion.isExcludedPath(absolutePath) ||\n (overrideAdded && baseExclusion.isGeneratedCode(absolutePath, await readFile(file.file, 'utf8')))\n ) {\n reincluded.push({ status: 'modified', basePath: relativePath, headPath: relativePath });\n }\n }\n return reincluded;\n}\n\n/**\n * An explicitly targeted file is measured even when excluded, like the ranking command's single\n * file target: the file itself, and its base path when it was renamed.\n */\nfunction listExplicitlyTargetedPaths(\n targetFile: string | undefined,\n repoRoot: string,\n changedFiles: ChangedFile[]\n): Set<string> {\n if (targetFile === undefined) {\n return new Set();\n }\n const paths = new Set([targetFile]);\n for (const changed of changedFiles) {\n if (changed.basePath !== undefined && path.join(repoRoot, changed.headPath) === targetFile) {\n paths.add(path.join(repoRoot, changed.basePath));\n }\n }\n return paths;\n}\n\n/** The target may not exist (e.g. only deleted files under it); fall back to the resolved path. */\nasync function canonicalizeTarget(resolvedTarget: string): Promise<{ canonicalTarget: string; targetExists: boolean }> {\n try {\n return { canonicalTarget: await realpath(resolvedTarget), targetExists: true };\n } catch {\n return { canonicalTarget: resolvedTarget, targetExists: false };\n }\n}\n\ninterface GateContext {\n repoRoot: string;\n mergeBase: string;\n canonicalTarget: string;\n options: ResolvedOptions;\n scannedFiles: ScannedFile[];\n /** Paths that are symbolic links at the merge-base; like head symlinks, they are not gated. */\n baseSymlinkPaths: Set<string>;\n /** Errors the head scan recorded against changed files. */\n scanErrors: readonly string[];\n headExclusion: Exclusion;\n baseExclusion: Exclusion;\n /** The explicitly targeted file (and its renamed base path), measured despite every exclusion. */\n explicitFiles: ReadonlySet<string>;\n /** Absolute paths the head scan skipped as generated code. */\n generatedHeadFiles: ReadonlySet<string>;\n}\n\nasync function prepareChangedFiles(\n changedFiles: ChangedFile[],\n context: GateContext,\n errors: string[],\n warnings: string[]\n): Promise<PreparedFile[]> {\n const headByPath = new Map(context.scannedFiles.map(({ relativePath, file }) => [relativePath, file]));\n // Files are prepared concurrently (git reads and base measurements); each records its diagnostics\n // on its own, and they are appended in changed-file order so the report stays deterministic.\n const results = await mapConcurrently(changedFiles, os.availableParallelism() * 2, async (changed) => {\n const fileErrors: string[] = [];\n const fileWarnings: string[] = [];\n const file = await prepareChangedFile(changed, context, headByPath, fileErrors, fileWarnings);\n return { file, fileErrors, fileWarnings };\n });\n const prepared: PreparedFile[] = [];\n for (const { file, fileErrors, fileWarnings } of results) {\n errors.push(...fileErrors);\n warnings.push(...fileWarnings);\n if (file) {\n prepared.push(file);\n }\n }\n return prepared;\n}\n\n/** `Promise.all(items.map(map))` with at most `limit` calls pending at once, results in input order. */\nasync function mapConcurrently<T, R>(items: readonly T[], limit: number, map: (item: T) => Promise<R>): Promise<R[]> {\n const results: R[] = [];\n let nextIndex = 0;\n const work = async (): Promise<void> => {\n while (nextIndex < items.length) {\n const index = nextIndex;\n nextIndex += 1;\n results[index] = await map(items[index] as T);\n }\n };\n await Promise.all(Array.from({ length: Math.min(limit, items.length) }, work));\n return results;\n}\n\nasync function prepareChangedFile(\n changed: ChangedFile,\n context: GateContext,\n headByPath: Map<string, FileMetrics>,\n errors: string[],\n warnings: string[]\n): Promise<PreparedFile | undefined> {\n // Symbolic links are skipped on both sides, mirroring scanListedFiles: git stores only the\n // target string, so a symlink blob is not measurable source.\n const absoluteHeadPath = path.join(context.repoRoot, changed.headPath);\n const headScannable =\n changed.status !== 'deleted' &&\n isScannedPath(changed.headPath, context.options, context.explicitFiles.has(absoluteHeadPath)) &&\n !context.headExclusion.isExcludedPath(absoluteHeadPath) &&\n !context.generatedHeadFiles.has(absoluteHeadPath) &&\n !(await isSymbolicLink(absoluteHeadPath));\n // A base path outside the scan scope (renamed from a test/ignored directory, or an unsupported\n // extension) was never measurable code: its content gates as new code instead of ratcheting\n // against a blob the scanner would not have measured.\n const absoluteBasePath = changed.basePath === undefined ? '' : path.join(context.repoRoot, changed.basePath);\n const baseScannable =\n changed.basePath !== undefined &&\n isScannedPath(changed.basePath, context.options, context.explicitFiles.has(absoluteBasePath)) &&\n !context.baseExclusion.isExcludedPath(absoluteBasePath) &&\n !context.baseSymlinkPaths.has(changed.basePath);\n if (!headScannable && !baseScannable) {\n return undefined;\n }\n\n const displayFile = changed.status === 'deleted' ? (changed.basePath as string) : changed.headPath;\n const headFile = headScannable ? headByPath.get(changed.headPath) : undefined;\n if (headScannable && !headFile) {\n reportUnmeasuredChangedFile(changed.headPath, context.scanErrors, errors);\n return undefined;\n }\n\n const file: PreparedFile = {\n changed,\n displayFile,\n // A file whose head left the scan scope still contributes its base functions to matching and\n // its base blob to the base universe, but nothing about it is gated or reported.\n gated:\n headScannable || changed.status === 'deleted'\n ? isWithinTarget(path.join(context.repoRoot, displayFile), context.canonicalTarget)\n : false,\n headFile,\n };\n\n if (baseScannable) {\n const outcome = await measureBaseRevision(file, changed.basePath as string, context, errors, warnings);\n // A base that turns out to be generated code gates its head as new code, like any other base\n // outside the scan scope.\n if (outcome === 'failed' || (outcome === 'generated' && !headFile)) {\n return undefined;\n }\n }\n\n try {\n if (headFile) {\n // The duplication gate needs the head lines, so an unreadable head fails like an unmeasured one.\n file.headContent = await readFile(headFile.file, 'utf8');\n collectHeadFunctionTokens(file, file.headContent, context, warnings);\n }\n file.hunks = await diffMeasuredRevisions(file, context);\n } catch (error) {\n errors.push(`${displayFile}: ${formatError(error)}`);\n return undefined;\n }\n return file;\n}\n\nasync function diffMeasuredRevisions(file: PreparedFile, context: GateContext): Promise<LineHunk[]> {\n const { changed, baseContent, headContent } = file;\n if (baseContent !== undefined && headContent !== undefined) {\n return await listLineHunks(context.repoRoot, context.mergeBase, changed.basePath as string, changed.headPath);\n }\n return [\n {\n baseStart: 1,\n baseCount: baseContent === undefined ? 0 : splitLines(baseContent).length,\n headStart: 1,\n headCount: headContent === undefined ? 0 : splitLines(headContent).length,\n },\n ];\n}\n\n/** Line numbering of the measured metrics (classifyLines splits on every line terminator). */\nfunction splitLines(content: string): string[] {\n return content.split(/\\r\\n|\\n|\\r/u);\n}\n\n/**\n * The scan covers exactly the git-visible list, so a scannable changed path can only be missing\n * after a measurement failure (already recorded as an error) or a silent exclusion (an alias of\n * an already-visited file, or absence from the git list). Failing loudly keeps the gate from\n * passing with the file unchecked.\n */\nfunction reportUnmeasuredChangedFile(headPath: string, scanErrors: readonly string[], errors: string[]): void {\n if (!scanErrors.some((error) => error.startsWith(`${headPath}:`))) {\n errors.push(`${headPath}: changed file was not measured`);\n }\n}\n\nfunction collectHeadFunctionTokens(\n file: PreparedFile,\n headContent: string,\n context: GateContext,\n warnings: string[]\n): void {\n try {\n file.headFunctionTokens = collectFunctionTokenSequences(headContent, {\n language: languageOf(file.changed.headPath, context),\n duplication: context.options.duplication,\n });\n } catch (error) {\n // Only rename re-matching degrades without token sequences; the head metrics still gate.\n warnings.push(`${file.displayFile}: function token sequences unavailable: ${formatError(error)}`);\n }\n}\n\n/**\n * Measures the merge-base blob into `file`; 'failed' (with an error recorded) only when the\n * metrics themselves cannot be measured, and 'generated' (nothing measured) for generated code.\n * The auxiliary collections (duplication candidates, token sequences) may fail independently of\n * the metrics, so their failure only degrades duplication data and rename re-matching — the\n * function-level ratchets still run.\n */\nasync function measureBaseRevision(\n file: PreparedFile,\n basePath: string,\n context: GateContext,\n errors: string[],\n warnings: string[]\n): Promise<'measured' | 'generated' | 'failed'> {\n const measureOptions = {\n language: languageOf(basePath, context),\n duplication: context.options.duplication,\n };\n let baseContent;\n try {\n baseContent = await readFileAtRevision(context.repoRoot, context.mergeBase, basePath);\n if (context.baseExclusion.isGeneratedCode(path.join(context.repoRoot, basePath), baseContent)) {\n return 'generated';\n }\n const measured = await measureWithCrossFileData(baseContent, measureOptions);\n file.baseMetrics = measured.metrics;\n file.baseContent = baseContent;\n file.baseCandidates = measured.crossFileData;\n if (measured.crossFileError !== undefined) {\n warnings.push(`${basePath} (at merge-base): duplication candidates unavailable: ${measured.crossFileError}`);\n }\n } catch (error) {\n errors.push(`${basePath} (at merge-base): ${formatError(error)}`);\n return 'failed';\n }\n try {\n file.baseFunctionTokens = collectFunctionTokenSequences(baseContent, measureOptions);\n } catch (error) {\n warnings.push(`${basePath} (at merge-base): function token sequences unavailable: ${formatError(error)}`);\n }\n return 'measured';\n}\n\n/** The language of a scannable path; an explicitly targeted file skips the test-file name rules. */\nfunction languageOf(relativePath: string, context: GateContext): LanguageName {\n const explicitTarget = context.explicitFiles.has(path.join(context.repoRoot, relativePath));\n return getLanguage(relativePath, context.options, explicitTarget) as LanguageName;\n}\n\nasync function isSymbolicLink(absolutePath: string): Promise<boolean> {\n const stats = await lstat(absolutePath).catch(() => {});\n return stats?.isSymbolicLink() ?? false;\n}\n\n/** Walks up to the nearest existing DIRECTORY, so git commands never spawn in a missing or non-directory cwd. */\nasync function firstExistingDirectory(directory: string): Promise<string> {\n let current = directory;\n while (true) {\n const stats = await stat(current).catch(() => {});\n if (stats?.isDirectory()) {\n return current;\n }\n const parent = path.dirname(current);\n if (parent === current) {\n return current;\n }\n current = parent;\n }\n}\n\nfunction isWithinTarget(candidate: string, targetDirectory: string): boolean {\n const relative = path.relative(targetDirectory, candidate);\n return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));\n}\n\nfunction measureDuplicationUniverses(\n prepared: PreparedFile[],\n scannedFiles: ScannedFile[],\n options: ResolvedOptions\n): { baseCross?: CrossFileDuplicationMetrics; headCross?: CrossFileDuplicationMetrics } {\n const headSources = scannedFiles.flatMap(({ relativePath, file }) =>\n file.duplicationCandidates ? [{ file: relativePath, ...file.duplicationCandidates }] : []\n );\n\n const changedHeadPaths = new Set(\n prepared.flatMap((file) => (file.changed.status === 'deleted' ? [] : [file.changed.headPath]))\n );\n const baseSources = headSources.filter((source) => !changedHeadPaths.has(source.file));\n for (const file of prepared) {\n if (file.baseCandidates && file.changed.basePath !== undefined) {\n baseSources.push({ file: file.changed.basePath, ...file.baseCandidates });\n }\n }\n\n return {\n baseCross: baseSources.length >= 2 ? measureCrossFileDuplication(baseSources, options.duplication) : undefined,\n headCross: headSources.length >= 2 ? measureCrossFileDuplication(headSources, options.duplication) : undefined,\n };\n}\n\nfunction toDuplicationChange(\n file: PreparedFile,\n baseCross: CrossFileDuplicationMetrics | undefined,\n headCross: CrossFileDuplicationMetrics | undefined,\n headPathOf: (basePath: string) => string,\n lineSignaturesOf: (file: string) => LineSignatures | undefined\n): DuplicationChange {\n const headOccurrences = collectOccurrences(file.headFile?.metrics, headCross, file.changed.headPath);\n return {\n baseLines: file.baseContent === undefined ? undefined : splitLines(file.baseContent),\n headLines: file.headContent === undefined ? undefined : splitLines(file.headContent),\n baseDuplicatedLines:\n file.baseMetrics === undefined || file.changed.basePath === undefined\n ? new Set()\n : collectDuplicatedLineNumbers(file.baseMetrics, baseCross, file.changed.basePath),\n headDuplicatedLines:\n file.headFile === undefined\n ? new Set()\n : keepRepeatedLines(\n collectDuplicatedLineNumbers(file.headFile.metrics, headCross, file.changed.headPath),\n file.changed.headPath,\n headOccurrences,\n lineSignaturesOf\n ),\n baseOccurrences: collectOccurrences(file.baseMetrics, baseCross, file.changed.basePath, headPathOf),\n headOccurrences,\n hunks: file.hunks ?? [],\n };\n}\n\n/** A file's per-line normalized token signatures and how many lines carry each. */\ninterface LineSignatures {\n byLine: Map<number, string>;\n lineCounts: Map<string, number>;\n}\n\n/** Line signatures of the scanned head files, computed on first use. */\nfunction createLineSignatureIndex(scannedFiles: ScannedFile[]): (file: string) => LineSignatures | undefined {\n const tokensByFile = new Map(\n scannedFiles.map(({ relativePath, file }) => [relativePath, file.duplicationCandidates?.tokens])\n );\n const signaturesByFile = new Map<string, LineSignatures | undefined>();\n return (file) => {\n if (!signaturesByFile.has(file)) {\n const tokens = tokensByFile.get(file);\n signaturesByFile.set(file, tokens && signLines(tokens));\n }\n return signaturesByFile.get(file);\n };\n}\n\n/**\n * Each line's tokens with identifiers numbered by first occurrence, as clone detection anonymizes\n * them.\n */\nfunction signLines(tokens: readonly Token[]): LineSignatures {\n const byLine = new Map<number, string>();\n const identifierIndexesByLine = new Map<number, Map<string, number>>();\n for (const token of tokens) {\n const line = token.startRow + 1;\n let text = token.text;\n if (token.kind === 'id') {\n // Numbered by first occurrence within the line, so `a + a` and `a + b` stay distinct.\n const identifierIndexes = identifierIndexesByLine.get(line) ?? new Map<string, number>();\n identifierIndexesByLine.set(line, identifierIndexes);\n if (!identifierIndexes.has(token.text)) {\n identifierIndexes.set(token.text, identifierIndexes.size);\n }\n text = `\\u0001${identifierIndexes.get(token.text)}`;\n }\n byLine.set(line, `${byLine.get(line) ?? ''}${text}\\u0000`);\n }\n const lineCounts = new Map<string, number>();\n for (const signature of byLine.values()) {\n lineCounts.set(signature, (lineCounts.get(signature) ?? 0) + 1);\n }\n return { byLine, lineCounts };\n}\n\n/**\n * The duplicated lines whose normalized tokens a partner file of their clones (the file itself,\n * on another line, for `''`) repeats on some line. Near-miss coverage also marks lines the copies\n * do not share, such as a new line inserted into an old near-miss clone, which copied nothing.\n */\nfunction keepRepeatedLines(\n lines: ReadonlySet<number>,\n file: string,\n occurrences: readonly CloneOccurrence[],\n lineSignaturesOf: (file: string) => LineSignatures | undefined\n): Set<number> {\n const own = lineSignaturesOf(file);\n if (own === undefined) {\n return new Set(lines);\n }\n const repeats = (signature: string, partner: string): boolean => {\n if (partner === '') {\n return (own.lineCounts.get(signature) ?? 0) > 1;\n }\n const partnerSignatures = lineSignaturesOf(partner);\n return partnerSignatures === undefined || partnerSignatures.lineCounts.has(signature);\n };\n const partnersByLine = indexPartnersByLine(occurrences);\n return new Set(\n [...lines].filter((line) => {\n const signature = own.byLine.get(line);\n return (\n signature === undefined || [...(partnersByLine.get(line) ?? [])].some((partner) => repeats(signature, partner))\n );\n })\n );\n}\n\n/**\n * The file's clone occurrences with their partner files (`''` for the file itself), partner paths\n * renamed by `renamePartner` so base partners compare with head ones.\n */\nfunction collectOccurrences(\n metrics: CodeMetrics | undefined,\n cross: CrossFileDuplicationMetrics | undefined,\n file: string | undefined,\n renamePartner: (partner: string) => string = (partner) => partner\n): CloneOccurrence[] {\n if (metrics === undefined || file === undefined) {\n return [];\n }\n const withinFile = metrics.duplication.duplicateBlockGroups.flatMap((group) =>\n group.map(({ startLine, endLine }) => ({ startLine, endLine, partners: [''] }))\n );\n const crossFile = (cross?.groups ?? []).flatMap((group) => {\n const own = group.occurrences.filter((occurrence) => occurrence.file === file);\n const partners = [\n ...group.files.filter((partner) => partner !== file).map(renamePartner),\n ...(own.length > 1 ? [''] : []),\n ];\n return own.map(({ startLine, endLine }) => ({ startLine, endLine, partners }));\n });\n return [...withinFile, ...crossFile];\n}\n\nfunction toGateInput(\n file: PreparedFile,\n newlyDuplicatedLines: number[],\n headCross: CrossFileDuplicationMetrics | undefined\n): GateFileInput {\n return {\n file: file.displayFile,\n baseMetrics: file.baseMetrics,\n headMetrics: file.headFile?.metrics,\n baseFunctionTokens: file.baseFunctionTokens,\n headFunctionTokens: file.headFunctionTokens,\n newlyDuplicatedLines,\n duplicationPartners: collectPartners(file, newlyDuplicatedLines, headCross),\n gated: file.gated,\n };\n}\n\n/** Other files sharing a cross-file clone that covers the given lines. */\nfunction collectPartners(\n file: PreparedFile,\n lines: number[],\n headCross: CrossFileDuplicationMetrics | undefined\n): string[] {\n const headPath = file.changed.headPath;\n const covers = (occurrence: CrossFileDuplicateOccurrence): boolean =>\n occurrence.file === headPath && lines.some((line) => line >= occurrence.startLine && line <= occurrence.endLine);\n const partners = new Set<string>();\n for (const group of headCross?.groups ?? []) {\n if (group.occurrences.some(covers)) {\n for (const partner of group.files) {\n if (partner !== headPath) {\n partners.add(partner);\n }\n }\n }\n }\n return [...partners].toSorted();\n}\n\nfunction printTextReport(\n cliOptions: DiffCliOptions,\n mergeBase: string,\n result: GateResult,\n errors: string[],\n warnings: string[]\n): void {\n const shortBase = mergeBase.slice(0, 12);\n if (errors.length > 0) {\n // Unmeasured files were not gated, so \"0 violations\" would be vacuous; never claim a pass.\n writeStdout(\n `Regression gate could not complete: ${errors.length} measurement failures (details on stderr)` +\n `${result.violations.length > 0 ? `; ${result.violations.length} violations in the measured files` : ''} (base ${cliOptions.base}, merge-base ${shortBase}).\\n`\n );\n printViolations(result);\n } else if (result.violations.length === 0) {\n writeStdout(\n `Regression gate passed: ${result.checkedFileCount} changed files, ${result.checkedFunctionCount} functions checked (base ${cliOptions.base}, merge-base ${shortBase}).\\n`\n );\n } else {\n writeStdout(\n `Regression gate vs ${cliOptions.base} (merge-base ${shortBase}): ${result.violations.length} violations\\n`\n );\n printViolations(result);\n }\n\n if (cliOptions.full) {\n printFullDetails(result);\n }\n\n for (const warning of warnings) {\n writeStderr(`Warning: ${warning}\\n`);\n }\n for (const error of errors) {\n writeStderr(`Error: ${error}\\n`);\n }\n}\n\nfunction printViolations(result: GateResult): void {\n for (const [index, violation] of result.violations.entries()) {\n writeStdout(`${index + 1}. ${violation.message}\\n`);\n }\n}\n\n/** Base -> head values of every checked function; kept behind --full for humans and trending. */\nfunction printFullDetails(result: GateResult): void {\n if (result.checkedFunctions.length === 0) {\n return;\n }\n writeStdout('\\nChecked functions (base -> head):\\n');\n for (const report of result.checkedFunctions) {\n writeStdout(`- ${formatFunctionReport(report)}\\n`);\n }\n}\n\nfunction formatFunctionReport(report: CheckedFunctionReport): string {\n const range = (\n select: (values: GateFunctionValues) => number,\n format: (value: number) => string = String\n ): string => {\n const head = format(select(report.head));\n return report.base ? `${format(select(report.base))} -> ${head}` : head;\n };\n const values = [\n `cognitive ${range((fn) => fn.cognitiveComplexity)}`,\n `NCSS ${range((fn) => fn.ncss)}`,\n `nesting ${range((fn) => fn.nestingDepth)}`,\n `DepDegree ${range((fn) => fn.depDegree)}`,\n `volume ${range(\n (fn) => fn.halsteadVolume,\n (value) => value.toFixed(1)\n )}`,\n ];\n return `${report.file}:${report.startLine}-${report.endLine} ${report.name}${report.base ? '' : ' (new)'}: ${values.join(', ')}`;\n}\n\nfunction printJsonReport(\n cliOptions: DiffCliOptions,\n mergeBase: string,\n result: GateResult,\n inputs: GateFileInput[],\n errors: string[],\n warnings: string[]\n): void {\n const report: Record<string, unknown> = {\n base: cliOptions.base,\n mergeBase,\n passed: result.violations.length === 0 && errors.length === 0,\n violations: result.violations,\n checkedFileCount: result.checkedFileCount,\n checkedFunctionCount: result.checkedFunctionCount,\n newFunctionCount: result.newFunctionCount,\n errors,\n warnings,\n };\n if (cliOptions.full) {\n report.files = inputs\n .filter((input) => input.gated !== false)\n .map((input) => ({\n file: input.file,\n baseFunctionCount: input.baseMetrics?.functions.length ?? 0,\n headFunctionCount: input.headMetrics?.functions.length ?? 0,\n baseNcss: input.baseMetrics?.ncssCount ?? 0,\n headNcss: input.headMetrics?.ncssCount ?? 0,\n baseMaxCognitiveComplexity: input.baseMetrics?.maxCognitiveComplexity ?? 0,\n headMaxCognitiveComplexity: input.headMetrics?.maxCognitiveComplexity ?? 0,\n newlyDuplicatedLines: input.newlyDuplicatedLines,\n duplicationPartners: input.duplicationPartners,\n functions: result.checkedFunctions.filter((fn) => fn.file === input.file),\n }));\n }\n writeStdout(JSON.stringify(report, undefined, 2) + '\\n');\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";function e(e,r,i){let a
|
|
1
|
+
"use strict";function e(e,r,i){let a=/* @__PURE__ */ new Map;for(let t of e){let e=a.get(t.fingerprint)??[];e.push(t),a.set(t.fingerprint,e)}let o=[...a.values()].map(n).filter(r),s=new Map(o.map(e=>[e[0]?.fingerprint??``,e.length])),c=e=>e.tokenCount*(s.get(e.fingerprint)??1),l=o.flat();l.sort((e,t)=>c(t)-c(e)||(i?i(e,t):0));for(let e=0;;e+=1){let n=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,a=/* @__PURE__ */ new Map;for(let e of l){let t=n.get(e.regionBucket??0)??[],r=!1,o=!1,s;for(let n of t)if(!(n.startIndex>=e.endIndex||e.startIndex>=n.endIndex)){if(n.startIndex<=e.startIndex&&e.endIndex<=n.endIndex){r=!0;break}if(e.startIndex<=n.startIndex&&n.endIndex<=e.endIndex)(s??=[]).push(n);else{o=!0;break}}if(r){let t=a.get(e.fingerprint)??[];t.push({...e,nestedInLargerGroup:!0}),a.set(e.fingerprint,t);continue}if(o)continue;let c=s;for(let e of c??[]){let t=i.get(e.fingerprint)??[],n=t.indexOf(e);n!==-1&&(t[n]={...e,nestedInLargerGroup:!0})}let l=c?t.filter(e=>!c.includes(e)):t;l.push(e),n.set(e.regionBucket??0,l);let u=i.get(e.fingerprint)??[];u.push(e),i.set(e.fingerprint,u)}for(let[e,t]of a)i.get(e)?.push(...t);for(let[e,t]of i)t.every(e=>e.nestedInLargerGroup)&&i.delete(e);let o,s=-1;for(let[e,t]of i){let n=t[0]?.tokenCount??0;!r(t)&&n>s&&(o=e,s=n)}if(o===void 0)return i;if(e>=20)return t(i,r),i;l=l.filter(e=>e.fingerprint!==o)}}function t(e,t){for(let n=!0;n;){n=!1;for(let[r,i]of e)(!t(i)||i.every(e=>e.nestedInLargerGroup))&&(e.delete(r),n=!0);let r=[...e.values()].flat().filter(e=>!e.nestedInLargerGroup);for(let[t,i]of e){let a=i.filter(e=>!e.nestedInLargerGroup||r.some(t=>(t.regionBucket??0)===(e.regionBucket??0)&&t.startIndex<=e.startIndex&&e.endIndex<=t.endIndex));a.length!==i.length&&(e.set(t,a),n=!0)}}}function n(e){let t=/* @__PURE__ */ new Map;for(let n of e){let e=`${n.regionBucket??0}:${n.startIndex}:${n.endIndex}`,r=t.get(e);(!r||n.tokenCount>r.tokenCount)&&t.set(e,n)}return[...t.values()]}exports.dedupeByRegion=n,exports.selectMaximalGroups=e;
|
|
2
2
|
//# sourceMappingURL=duplicateSelection.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"duplicateSelection.cjs","names":[],"sources":["../src/duplicateSelection.ts"],"sourcesContent":["/**\n * Maximal, non-overlapping duplicate-group selection for the cross-file detector (the native\n * within-file detector mirrors its greedy ranking and shedding, but not the nested-copy retention\n * below, which is cross-file only). Candidates are grouped by fingerprint, ranked by total\n * coverage, kept greedily without overlapping a kept region, and groups that fall below the\n * survivor requirement are shed one at a time (largest first) so their regions stop blocking\n * smaller groups. A copy lying entirely inside a larger group's region stays with its group as a\n * nested copy (whichever group the greedy order kept first), so a standalone copy elsewhere is\n * still reported as duplicating it.\n */\n\nexport interface SelectableRegion {\n fingerprint: string;\n tokenCount: number;\n startIndex: number;\n endIndex: number;\n /**\n * Regions can only overlap within the same bucket. The within-file detector uses one bucket;\n * the cross-file detector buckets by file index.\n */\n regionBucket?: number;\n /**\n * Set by selectMaximalGroups on a copy nested inside a larger group's region: it is reported with\n * its group, but its span is already counted by that larger group.\n */\n nestedInLargerGroup?: boolean;\n}\n\n/** Caps how often the maximal-region selection reruns after shedding failed duplicate groups. */\nconst maxSelectionRerunCount = 20;\n\n/**\n * @param isSurvivingGroup whether a selected group counts (e.g. at least two occurrences, or\n * occurrences spanning at least two files); failing groups are shed and re-selected without.\n * @param compareTies optional deterministic tie-break applied after the coverage ranking.\n */\nexport function selectMaximalGroups<T extends SelectableRegion>(\n candidates: T[],\n isSurvivingGroup: (group: T[]) => boolean,\n compareTies?: (left: T, right: T) => number\n): Map<string, T[]> {\n const byFingerprint = new Map<string, T[]>();\n for (const candidate of candidates) {\n const group = byFingerprint.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n byFingerprint.set(candidate.fingerprint, group);\n }\n\n const groups = [...byFingerprint.values()].map(dedupeByRegion).filter(isSurvivingGroup);\n // Greedy order ranks by total coverage (region size × copies): a 3×3-statement group must beat\n // a 2×4-statement group overlapping two of its copies, or the third copy is silently dropped\n // and the reported duplication shrinks as more copies are added.\n const groupSizeByFingerprint = new Map(groups.map((group) => [group[0]?.fingerprint ?? '', group.length]));\n const coverage = (candidate: T): number =>\n candidate.tokenCount * (groupSizeByFingerprint.get(candidate.fingerprint) ?? 1);\n let duplicates = groups.flat();\n duplicates.sort((left, right) => coverage(right) - coverage(left) || (compareTies ? compareTies(left, right) : 0));\n\n // Greedy selection can keep a candidate whose group ends up below the survivor requirement;\n // such an uncounted region must not block smaller groups, so the largest failed group is\n // removed and the selection reruns. One group at a time: freeing a failed group's regions can\n // rescue another. The rerun cap bounds degenerate inputs; past it the remaining failed groups\n // are dropped, trading a sliver of recall on such files for bounded runtime.\n for (let rerun = 0; ; rerun += 1) {\n const keptRegionsByBucket = new Map<number, T[]>();\n const counted = new Map<string, T[]>();\n const nestedByFingerprint = new Map<string, T[]>();\n for (const candidate of duplicates) {\n const keptRegions = keptRegionsByBucket.get(candidate.regionBucket ?? 0) ?? [];\n // A plain loop: this runs once per candidate over every kept region of the bucket, so\n // allocating a filtered array per candidate would dominate project-scale runs. Kept regions\n // never overlap each other, so a candidate inside one cannot partially overlap another.\n let containedInKept = false;\n let partiallyOverlaps = false;\n let enclosedKept: T[] | undefined;\n for (const region of keptRegions) {\n if (region.startIndex >= candidate.endIndex || candidate.startIndex >= region.endIndex) {\n continue;\n }\n if (region.startIndex <= candidate.startIndex && candidate.endIndex <= region.endIndex) {\n containedInKept = true;\n break;\n }\n if (candidate.startIndex <= region.startIndex && region.endIndex <= candidate.endIndex) {\n (enclosedKept ??= []).push(region);\n } else {\n partiallyOverlaps = true;\n break;\n }\n }\n if (containedInKept) {\n const nested = nestedByFingerprint.get(candidate.fingerprint) ?? [];\n nested.push({ ...candidate, nestedInLargerGroup: true });\n nestedByFingerprint.set(candidate.fingerprint, nested);\n continue;\n }\n if (partiallyOverlaps) {\n continue;\n }\n // Containment must not depend on greedy order: a candidate enclosing kept copies of smaller\n // groups occupies its region, and those copies become nested copies of their groups.\n const enclosed = enclosedKept;\n for (const inner of enclosed ?? []) {\n const group = counted.get(inner.fingerprint) ?? [];\n const index = group.indexOf(inner);\n if (index !== -1) {\n group[index] = { ...inner, nestedInLargerGroup: true };\n }\n }\n // The enclosed regions give way to the enclosing one, keeping kept regions mutually\n // non-overlapping: a later candidate inside this region must see it, not a region it\n // swallowed (which the candidate could straddle instead).\n const occupied = enclosed ? keptRegions.filter((region) => !enclosed.includes(region)) : keptRegions;\n occupied.push(candidate);\n keptRegionsByBucket.set(candidate.regionBucket ?? 0, occupied);\n const group = counted.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n counted.set(candidate.fingerprint, group);\n }\n // Nested copies join only a group that kept a standalone copy; on their own they would merely\n // restate the larger group.\n for (const [fingerprint, nested] of nestedByFingerprint) {\n counted.get(fingerprint)?.push(...nested);\n }\n for (const [fingerprint, group] of counted) {\n if (group.every((candidate) => candidate.nestedInLargerGroup)) {\n counted.delete(fingerprint);\n }\n }\n\n let failedFingerprint: string | undefined;\n let failedTokenCount = -1;\n for (const [fingerprint, group] of counted) {\n const tokenCount = group[0]?.tokenCount ?? 0;\n if (!isSurvivingGroup(group) && tokenCount > failedTokenCount) {\n failedFingerprint = fingerprint;\n failedTokenCount = tokenCount;\n }\n }\n // No failed fingerprint means every counted group met the survivor requirement.\n if (failedFingerprint === undefined) {\n return counted;\n }\n if (rerun >= maxSelectionRerunCount) {\n dropFailedGroups(counted, isSurvivingGroup);\n return counted;\n }\n\n duplicates = duplicates.filter((candidate) => candidate.fingerprint !== failedFingerprint);\n }\n}\n\n/**\n * Past the rerun cap, still-failing groups are dropped without another selection pass. A dropped\n * group's regions may have been what nested copies of surviving groups lay inside, and such a copy\n * would then be counted by no group at all, so those copies are dropped too and the shrunk groups\n * are re-checked until nothing changes.\n */\nfunction dropFailedGroups<T extends SelectableRegion>(\n counted: Map<string, T[]>,\n isSurvivingGroup: (group: T[]) => boolean\n): void {\n for (let changed = true; changed;) {\n changed = false;\n for (const [fingerprint, group] of counted) {\n if (!isSurvivingGroup(group) || group.every((candidate) => candidate.nestedInLargerGroup)) {\n counted.delete(fingerprint);\n changed = true;\n }\n }\n const standalone = [...counted.values()].flat().filter((candidate) => !candidate.nestedInLargerGroup);\n for (const [fingerprint, group] of counted) {\n const kept = group.filter(\n (candidate) =>\n !candidate.nestedInLargerGroup ||\n standalone.some(\n (region) =>\n (region.regionBucket ?? 0) === (candidate.regionBucket ?? 0) &&\n region.startIndex <= candidate.startIndex &&\n candidate.endIndex <= region.endIndex\n )\n );\n if (kept.length !== group.length) {\n counted.set(fingerprint, kept);\n changed = true;\n }\n }\n }\n}\n\n/** Drops candidates covering the same source region (a block and the statement run spanning it). */\nexport function dedupeByRegion<T extends SelectableRegion>(group: T[]): T[] {\n const byRegion = new Map<string, T>();\n for (const candidate of group) {\n const key = `${candidate.regionBucket ?? 0}:${candidate.startIndex}:${candidate.endIndex}`;\n const existing = byRegion.get(key);\n if (!existing || candidate.tokenCount > existing.tokenCount) {\n byRegion.set(key, candidate);\n }\n }\n return [...byRegion.values()];\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"duplicateSelection.cjs","names":[],"sources":["../src/duplicateSelection.ts"],"sourcesContent":["/**\n * Maximal, non-overlapping duplicate-group selection for the cross-file detector (the native\n * within-file detector mirrors its greedy ranking and shedding, but not the nested-copy retention\n * below, which is cross-file only). Candidates are grouped by fingerprint, ranked by total\n * coverage, kept greedily without overlapping a kept region, and groups that fall below the\n * survivor requirement are shed one at a time (largest first) so their regions stop blocking\n * smaller groups. A copy lying entirely inside a larger group's region stays with its group as a\n * nested copy (whichever group the greedy order kept first), so a standalone copy elsewhere is\n * still reported as duplicating it.\n */\n\nexport interface SelectableRegion {\n fingerprint: string;\n tokenCount: number;\n startIndex: number;\n endIndex: number;\n /**\n * Regions can only overlap within the same bucket. The within-file detector uses one bucket;\n * the cross-file detector buckets by file index.\n */\n regionBucket?: number;\n /**\n * Set by selectMaximalGroups on a copy nested inside a larger group's region: it is reported with\n * its group, but its span is already counted by that larger group.\n */\n nestedInLargerGroup?: boolean;\n}\n\n/** Caps how often the maximal-region selection reruns after shedding failed duplicate groups. */\nconst maxSelectionRerunCount = 20;\n\n/**\n * @param isSurvivingGroup whether a selected group counts (e.g. at least two occurrences, or\n * occurrences spanning at least two files); failing groups are shed and re-selected without.\n * @param compareTies optional deterministic tie-break applied after the coverage ranking.\n */\nexport function selectMaximalGroups<T extends SelectableRegion>(\n candidates: T[],\n isSurvivingGroup: (group: T[]) => boolean,\n compareTies?: (left: T, right: T) => number\n): Map<string, T[]> {\n const byFingerprint = new Map<string, T[]>();\n for (const candidate of candidates) {\n const group = byFingerprint.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n byFingerprint.set(candidate.fingerprint, group);\n }\n\n const groups = [...byFingerprint.values()].map(dedupeByRegion).filter(isSurvivingGroup);\n // Greedy order ranks by total coverage (region size × copies): a 3×3-statement group must beat\n // a 2×4-statement group overlapping two of its copies, or the third copy is silently dropped\n // and the reported duplication shrinks as more copies are added.\n const groupSizeByFingerprint = new Map(groups.map((group) => [group[0]?.fingerprint ?? '', group.length]));\n const coverage = (candidate: T): number =>\n candidate.tokenCount * (groupSizeByFingerprint.get(candidate.fingerprint) ?? 1);\n let duplicates = groups.flat();\n duplicates.sort((left, right) => coverage(right) - coverage(left) || (compareTies ? compareTies(left, right) : 0));\n\n // Greedy selection can keep a candidate whose group ends up below the survivor requirement;\n // such an uncounted region must not block smaller groups, so the largest failed group is\n // removed and the selection reruns. One group at a time: freeing a failed group's regions can\n // rescue another. The rerun cap bounds degenerate inputs; past it the remaining failed groups\n // are dropped, trading a sliver of recall on such files for bounded runtime.\n for (let rerun = 0; ; rerun += 1) {\n const keptRegionsByBucket = new Map<number, T[]>();\n const counted = new Map<string, T[]>();\n const nestedByFingerprint = new Map<string, T[]>();\n for (const candidate of duplicates) {\n const keptRegions = keptRegionsByBucket.get(candidate.regionBucket ?? 0) ?? [];\n // A plain loop: this runs once per candidate over every kept region of the bucket, so\n // allocating a filtered array per candidate would dominate project-scale runs. Kept regions\n // never overlap each other, so a candidate inside one cannot partially overlap another.\n let containedInKept = false;\n let partiallyOverlaps = false;\n let enclosedKept: T[] | undefined;\n for (const region of keptRegions) {\n if (region.startIndex >= candidate.endIndex || candidate.startIndex >= region.endIndex) {\n continue;\n }\n if (region.startIndex <= candidate.startIndex && candidate.endIndex <= region.endIndex) {\n containedInKept = true;\n break;\n }\n if (candidate.startIndex <= region.startIndex && region.endIndex <= candidate.endIndex) {\n (enclosedKept ??= []).push(region);\n } else {\n partiallyOverlaps = true;\n break;\n }\n }\n if (containedInKept) {\n const nested = nestedByFingerprint.get(candidate.fingerprint) ?? [];\n nested.push({ ...candidate, nestedInLargerGroup: true });\n nestedByFingerprint.set(candidate.fingerprint, nested);\n continue;\n }\n if (partiallyOverlaps) {\n continue;\n }\n // Containment must not depend on greedy order: a candidate enclosing kept copies of smaller\n // groups occupies its region, and those copies become nested copies of their groups.\n const enclosed = enclosedKept;\n for (const inner of enclosed ?? []) {\n const group = counted.get(inner.fingerprint) ?? [];\n const index = group.indexOf(inner);\n if (index !== -1) {\n group[index] = { ...inner, nestedInLargerGroup: true };\n }\n }\n // The enclosed regions give way to the enclosing one, keeping kept regions mutually\n // non-overlapping: a later candidate inside this region must see it, not a region it\n // swallowed (which the candidate could straddle instead).\n const occupied = enclosed ? keptRegions.filter((region) => !enclosed.includes(region)) : keptRegions;\n occupied.push(candidate);\n keptRegionsByBucket.set(candidate.regionBucket ?? 0, occupied);\n const group = counted.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n counted.set(candidate.fingerprint, group);\n }\n // Nested copies join only a group that kept a standalone copy; on their own they would merely\n // restate the larger group.\n for (const [fingerprint, nested] of nestedByFingerprint) {\n counted.get(fingerprint)?.push(...nested);\n }\n for (const [fingerprint, group] of counted) {\n if (group.every((candidate) => candidate.nestedInLargerGroup)) {\n counted.delete(fingerprint);\n }\n }\n\n let failedFingerprint: string | undefined;\n let failedTokenCount = -1;\n for (const [fingerprint, group] of counted) {\n const tokenCount = group[0]?.tokenCount ?? 0;\n if (!isSurvivingGroup(group) && tokenCount > failedTokenCount) {\n failedFingerprint = fingerprint;\n failedTokenCount = tokenCount;\n }\n }\n // No failed fingerprint means every counted group met the survivor requirement.\n if (failedFingerprint === undefined) {\n return counted;\n }\n if (rerun >= maxSelectionRerunCount) {\n dropFailedGroups(counted, isSurvivingGroup);\n return counted;\n }\n\n duplicates = duplicates.filter((candidate) => candidate.fingerprint !== failedFingerprint);\n }\n}\n\n/**\n * Past the rerun cap, still-failing groups are dropped without another selection pass. A dropped\n * group's regions may have been what nested copies of surviving groups lay inside, and such a copy\n * would then be counted by no group at all, so those copies are dropped too and the shrunk groups\n * are re-checked until nothing changes.\n */\nfunction dropFailedGroups<T extends SelectableRegion>(\n counted: Map<string, T[]>,\n isSurvivingGroup: (group: T[]) => boolean\n): void {\n for (let changed = true; changed;) {\n changed = false;\n for (const [fingerprint, group] of counted) {\n if (!isSurvivingGroup(group) || group.every((candidate) => candidate.nestedInLargerGroup)) {\n counted.delete(fingerprint);\n changed = true;\n }\n }\n const standalone = [...counted.values()].flat().filter((candidate) => !candidate.nestedInLargerGroup);\n for (const [fingerprint, group] of counted) {\n const kept = group.filter(\n (candidate) =>\n !candidate.nestedInLargerGroup ||\n standalone.some(\n (region) =>\n (region.regionBucket ?? 0) === (candidate.regionBucket ?? 0) &&\n region.startIndex <= candidate.startIndex &&\n candidate.endIndex <= region.endIndex\n )\n );\n if (kept.length !== group.length) {\n counted.set(fingerprint, kept);\n changed = true;\n }\n }\n }\n}\n\n/** Drops candidates covering the same source region (a block and the statement run spanning it). */\nexport function dedupeByRegion<T extends SelectableRegion>(group: T[]): T[] {\n const byRegion = new Map<string, T>();\n for (const candidate of group) {\n const key = `${candidate.regionBucket ?? 0}:${candidate.startIndex}:${candidate.endIndex}`;\n const existing = byRegion.get(key);\n if (!existing || candidate.tokenCount > existing.tokenCount) {\n byRegion.set(key, candidate);\n }\n }\n return [...byRegion.values()];\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
function e(e,r,i){let a
|
|
1
|
+
function e(e,r,i){let a=/* @__PURE__ */ new Map;for(let t of e){let e=a.get(t.fingerprint)??[];e.push(t),a.set(t.fingerprint,e)}let o=[...a.values()].map(n).filter(r),s=new Map(o.map(e=>[e[0]?.fingerprint??``,e.length])),c=e=>e.tokenCount*(s.get(e.fingerprint)??1),l=o.flat();l.sort((e,t)=>c(t)-c(e)||(i?i(e,t):0));for(let e=0;;e+=1){let n=/* @__PURE__ */ new Map,i=/* @__PURE__ */ new Map,a=/* @__PURE__ */ new Map;for(let e of l){let t=n.get(e.regionBucket??0)??[],r=!1,o=!1,s;for(let n of t)if(!(n.startIndex>=e.endIndex||e.startIndex>=n.endIndex)){if(n.startIndex<=e.startIndex&&e.endIndex<=n.endIndex){r=!0;break}if(e.startIndex<=n.startIndex&&n.endIndex<=e.endIndex)(s??=[]).push(n);else{o=!0;break}}if(r){let t=a.get(e.fingerprint)??[];t.push({...e,nestedInLargerGroup:!0}),a.set(e.fingerprint,t);continue}if(o)continue;let c=s;for(let e of c??[]){let t=i.get(e.fingerprint)??[],n=t.indexOf(e);n!==-1&&(t[n]={...e,nestedInLargerGroup:!0})}let l=c?t.filter(e=>!c.includes(e)):t;l.push(e),n.set(e.regionBucket??0,l);let u=i.get(e.fingerprint)??[];u.push(e),i.set(e.fingerprint,u)}for(let[e,t]of a)i.get(e)?.push(...t);for(let[e,t]of i)t.every(e=>e.nestedInLargerGroup)&&i.delete(e);let o,s=-1;for(let[e,t]of i){let n=t[0]?.tokenCount??0;!r(t)&&n>s&&(o=e,s=n)}if(o===void 0)return i;if(e>=20)return t(i,r),i;l=l.filter(e=>e.fingerprint!==o)}}function t(e,t){for(let n=!0;n;){n=!1;for(let[r,i]of e)(!t(i)||i.every(e=>e.nestedInLargerGroup))&&(e.delete(r),n=!0);let r=[...e.values()].flat().filter(e=>!e.nestedInLargerGroup);for(let[t,i]of e){let a=i.filter(e=>!e.nestedInLargerGroup||r.some(t=>(t.regionBucket??0)===(e.regionBucket??0)&&t.startIndex<=e.startIndex&&e.endIndex<=t.endIndex));a.length!==i.length&&(e.set(t,a),n=!0)}}}function n(e){let t=/* @__PURE__ */ new Map;for(let n of e){let e=`${n.regionBucket??0}:${n.startIndex}:${n.endIndex}`,r=t.get(e);(!r||n.tokenCount>r.tokenCount)&&t.set(e,n)}return[...t.values()]}export{n as dedupeByRegion,e as selectMaximalGroups};
|
|
2
2
|
//# sourceMappingURL=duplicateSelection.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"duplicateSelection.js","names":[],"sources":["../src/duplicateSelection.ts"],"sourcesContent":["/**\n * Maximal, non-overlapping duplicate-group selection for the cross-file detector (the native\n * within-file detector mirrors its greedy ranking and shedding, but not the nested-copy retention\n * below, which is cross-file only). Candidates are grouped by fingerprint, ranked by total\n * coverage, kept greedily without overlapping a kept region, and groups that fall below the\n * survivor requirement are shed one at a time (largest first) so their regions stop blocking\n * smaller groups. A copy lying entirely inside a larger group's region stays with its group as a\n * nested copy (whichever group the greedy order kept first), so a standalone copy elsewhere is\n * still reported as duplicating it.\n */\n\nexport interface SelectableRegion {\n fingerprint: string;\n tokenCount: number;\n startIndex: number;\n endIndex: number;\n /**\n * Regions can only overlap within the same bucket. The within-file detector uses one bucket;\n * the cross-file detector buckets by file index.\n */\n regionBucket?: number;\n /**\n * Set by selectMaximalGroups on a copy nested inside a larger group's region: it is reported with\n * its group, but its span is already counted by that larger group.\n */\n nestedInLargerGroup?: boolean;\n}\n\n/** Caps how often the maximal-region selection reruns after shedding failed duplicate groups. */\nconst maxSelectionRerunCount = 20;\n\n/**\n * @param isSurvivingGroup whether a selected group counts (e.g. at least two occurrences, or\n * occurrences spanning at least two files); failing groups are shed and re-selected without.\n * @param compareTies optional deterministic tie-break applied after the coverage ranking.\n */\nexport function selectMaximalGroups<T extends SelectableRegion>(\n candidates: T[],\n isSurvivingGroup: (group: T[]) => boolean,\n compareTies?: (left: T, right: T) => number\n): Map<string, T[]> {\n const byFingerprint = new Map<string, T[]>();\n for (const candidate of candidates) {\n const group = byFingerprint.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n byFingerprint.set(candidate.fingerprint, group);\n }\n\n const groups = [...byFingerprint.values()].map(dedupeByRegion).filter(isSurvivingGroup);\n // Greedy order ranks by total coverage (region size × copies): a 3×3-statement group must beat\n // a 2×4-statement group overlapping two of its copies, or the third copy is silently dropped\n // and the reported duplication shrinks as more copies are added.\n const groupSizeByFingerprint = new Map(groups.map((group) => [group[0]?.fingerprint ?? '', group.length]));\n const coverage = (candidate: T): number =>\n candidate.tokenCount * (groupSizeByFingerprint.get(candidate.fingerprint) ?? 1);\n let duplicates = groups.flat();\n duplicates.sort((left, right) => coverage(right) - coverage(left) || (compareTies ? compareTies(left, right) : 0));\n\n // Greedy selection can keep a candidate whose group ends up below the survivor requirement;\n // such an uncounted region must not block smaller groups, so the largest failed group is\n // removed and the selection reruns. One group at a time: freeing a failed group's regions can\n // rescue another. The rerun cap bounds degenerate inputs; past it the remaining failed groups\n // are dropped, trading a sliver of recall on such files for bounded runtime.\n for (let rerun = 0; ; rerun += 1) {\n const keptRegionsByBucket = new Map<number, T[]>();\n const counted = new Map<string, T[]>();\n const nestedByFingerprint = new Map<string, T[]>();\n for (const candidate of duplicates) {\n const keptRegions = keptRegionsByBucket.get(candidate.regionBucket ?? 0) ?? [];\n // A plain loop: this runs once per candidate over every kept region of the bucket, so\n // allocating a filtered array per candidate would dominate project-scale runs. Kept regions\n // never overlap each other, so a candidate inside one cannot partially overlap another.\n let containedInKept = false;\n let partiallyOverlaps = false;\n let enclosedKept: T[] | undefined;\n for (const region of keptRegions) {\n if (region.startIndex >= candidate.endIndex || candidate.startIndex >= region.endIndex) {\n continue;\n }\n if (region.startIndex <= candidate.startIndex && candidate.endIndex <= region.endIndex) {\n containedInKept = true;\n break;\n }\n if (candidate.startIndex <= region.startIndex && region.endIndex <= candidate.endIndex) {\n (enclosedKept ??= []).push(region);\n } else {\n partiallyOverlaps = true;\n break;\n }\n }\n if (containedInKept) {\n const nested = nestedByFingerprint.get(candidate.fingerprint) ?? [];\n nested.push({ ...candidate, nestedInLargerGroup: true });\n nestedByFingerprint.set(candidate.fingerprint, nested);\n continue;\n }\n if (partiallyOverlaps) {\n continue;\n }\n // Containment must not depend on greedy order: a candidate enclosing kept copies of smaller\n // groups occupies its region, and those copies become nested copies of their groups.\n const enclosed = enclosedKept;\n for (const inner of enclosed ?? []) {\n const group = counted.get(inner.fingerprint) ?? [];\n const index = group.indexOf(inner);\n if (index !== -1) {\n group[index] = { ...inner, nestedInLargerGroup: true };\n }\n }\n // The enclosed regions give way to the enclosing one, keeping kept regions mutually\n // non-overlapping: a later candidate inside this region must see it, not a region it\n // swallowed (which the candidate could straddle instead).\n const occupied = enclosed ? keptRegions.filter((region) => !enclosed.includes(region)) : keptRegions;\n occupied.push(candidate);\n keptRegionsByBucket.set(candidate.regionBucket ?? 0, occupied);\n const group = counted.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n counted.set(candidate.fingerprint, group);\n }\n // Nested copies join only a group that kept a standalone copy; on their own they would merely\n // restate the larger group.\n for (const [fingerprint, nested] of nestedByFingerprint) {\n counted.get(fingerprint)?.push(...nested);\n }\n for (const [fingerprint, group] of counted) {\n if (group.every((candidate) => candidate.nestedInLargerGroup)) {\n counted.delete(fingerprint);\n }\n }\n\n let failedFingerprint: string | undefined;\n let failedTokenCount = -1;\n for (const [fingerprint, group] of counted) {\n const tokenCount = group[0]?.tokenCount ?? 0;\n if (!isSurvivingGroup(group) && tokenCount > failedTokenCount) {\n failedFingerprint = fingerprint;\n failedTokenCount = tokenCount;\n }\n }\n // No failed fingerprint means every counted group met the survivor requirement.\n if (failedFingerprint === undefined) {\n return counted;\n }\n if (rerun >= maxSelectionRerunCount) {\n dropFailedGroups(counted, isSurvivingGroup);\n return counted;\n }\n\n duplicates = duplicates.filter((candidate) => candidate.fingerprint !== failedFingerprint);\n }\n}\n\n/**\n * Past the rerun cap, still-failing groups are dropped without another selection pass. A dropped\n * group's regions may have been what nested copies of surviving groups lay inside, and such a copy\n * would then be counted by no group at all, so those copies are dropped too and the shrunk groups\n * are re-checked until nothing changes.\n */\nfunction dropFailedGroups<T extends SelectableRegion>(\n counted: Map<string, T[]>,\n isSurvivingGroup: (group: T[]) => boolean\n): void {\n for (let changed = true; changed;) {\n changed = false;\n for (const [fingerprint, group] of counted) {\n if (!isSurvivingGroup(group) || group.every((candidate) => candidate.nestedInLargerGroup)) {\n counted.delete(fingerprint);\n changed = true;\n }\n }\n const standalone = [...counted.values()].flat().filter((candidate) => !candidate.nestedInLargerGroup);\n for (const [fingerprint, group] of counted) {\n const kept = group.filter(\n (candidate) =>\n !candidate.nestedInLargerGroup ||\n standalone.some(\n (region) =>\n (region.regionBucket ?? 0) === (candidate.regionBucket ?? 0) &&\n region.startIndex <= candidate.startIndex &&\n candidate.endIndex <= region.endIndex\n )\n );\n if (kept.length !== group.length) {\n counted.set(fingerprint, kept);\n changed = true;\n }\n }\n }\n}\n\n/** Drops candidates covering the same source region (a block and the statement run spanning it). */\nexport function dedupeByRegion<T extends SelectableRegion>(group: T[]): T[] {\n const byRegion = new Map<string, T>();\n for (const candidate of group) {\n const key = `${candidate.regionBucket ?? 0}:${candidate.startIndex}:${candidate.endIndex}`;\n const existing = byRegion.get(key);\n if (!existing || candidate.tokenCount > existing.tokenCount) {\n byRegion.set(key, candidate);\n }\n }\n return [...byRegion.values()];\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"duplicateSelection.js","names":[],"sources":["../src/duplicateSelection.ts"],"sourcesContent":["/**\n * Maximal, non-overlapping duplicate-group selection for the cross-file detector (the native\n * within-file detector mirrors its greedy ranking and shedding, but not the nested-copy retention\n * below, which is cross-file only). Candidates are grouped by fingerprint, ranked by total\n * coverage, kept greedily without overlapping a kept region, and groups that fall below the\n * survivor requirement are shed one at a time (largest first) so their regions stop blocking\n * smaller groups. A copy lying entirely inside a larger group's region stays with its group as a\n * nested copy (whichever group the greedy order kept first), so a standalone copy elsewhere is\n * still reported as duplicating it.\n */\n\nexport interface SelectableRegion {\n fingerprint: string;\n tokenCount: number;\n startIndex: number;\n endIndex: number;\n /**\n * Regions can only overlap within the same bucket. The within-file detector uses one bucket;\n * the cross-file detector buckets by file index.\n */\n regionBucket?: number;\n /**\n * Set by selectMaximalGroups on a copy nested inside a larger group's region: it is reported with\n * its group, but its span is already counted by that larger group.\n */\n nestedInLargerGroup?: boolean;\n}\n\n/** Caps how often the maximal-region selection reruns after shedding failed duplicate groups. */\nconst maxSelectionRerunCount = 20;\n\n/**\n * @param isSurvivingGroup whether a selected group counts (e.g. at least two occurrences, or\n * occurrences spanning at least two files); failing groups are shed and re-selected without.\n * @param compareTies optional deterministic tie-break applied after the coverage ranking.\n */\nexport function selectMaximalGroups<T extends SelectableRegion>(\n candidates: T[],\n isSurvivingGroup: (group: T[]) => boolean,\n compareTies?: (left: T, right: T) => number\n): Map<string, T[]> {\n const byFingerprint = new Map<string, T[]>();\n for (const candidate of candidates) {\n const group = byFingerprint.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n byFingerprint.set(candidate.fingerprint, group);\n }\n\n const groups = [...byFingerprint.values()].map(dedupeByRegion).filter(isSurvivingGroup);\n // Greedy order ranks by total coverage (region size × copies): a 3×3-statement group must beat\n // a 2×4-statement group overlapping two of its copies, or the third copy is silently dropped\n // and the reported duplication shrinks as more copies are added.\n const groupSizeByFingerprint = new Map(groups.map((group) => [group[0]?.fingerprint ?? '', group.length]));\n const coverage = (candidate: T): number =>\n candidate.tokenCount * (groupSizeByFingerprint.get(candidate.fingerprint) ?? 1);\n let duplicates = groups.flat();\n duplicates.sort((left, right) => coverage(right) - coverage(left) || (compareTies ? compareTies(left, right) : 0));\n\n // Greedy selection can keep a candidate whose group ends up below the survivor requirement;\n // such an uncounted region must not block smaller groups, so the largest failed group is\n // removed and the selection reruns. One group at a time: freeing a failed group's regions can\n // rescue another. The rerun cap bounds degenerate inputs; past it the remaining failed groups\n // are dropped, trading a sliver of recall on such files for bounded runtime.\n for (let rerun = 0; ; rerun += 1) {\n const keptRegionsByBucket = new Map<number, T[]>();\n const counted = new Map<string, T[]>();\n const nestedByFingerprint = new Map<string, T[]>();\n for (const candidate of duplicates) {\n const keptRegions = keptRegionsByBucket.get(candidate.regionBucket ?? 0) ?? [];\n // A plain loop: this runs once per candidate over every kept region of the bucket, so\n // allocating a filtered array per candidate would dominate project-scale runs. Kept regions\n // never overlap each other, so a candidate inside one cannot partially overlap another.\n let containedInKept = false;\n let partiallyOverlaps = false;\n let enclosedKept: T[] | undefined;\n for (const region of keptRegions) {\n if (region.startIndex >= candidate.endIndex || candidate.startIndex >= region.endIndex) {\n continue;\n }\n if (region.startIndex <= candidate.startIndex && candidate.endIndex <= region.endIndex) {\n containedInKept = true;\n break;\n }\n if (candidate.startIndex <= region.startIndex && region.endIndex <= candidate.endIndex) {\n (enclosedKept ??= []).push(region);\n } else {\n partiallyOverlaps = true;\n break;\n }\n }\n if (containedInKept) {\n const nested = nestedByFingerprint.get(candidate.fingerprint) ?? [];\n nested.push({ ...candidate, nestedInLargerGroup: true });\n nestedByFingerprint.set(candidate.fingerprint, nested);\n continue;\n }\n if (partiallyOverlaps) {\n continue;\n }\n // Containment must not depend on greedy order: a candidate enclosing kept copies of smaller\n // groups occupies its region, and those copies become nested copies of their groups.\n const enclosed = enclosedKept;\n for (const inner of enclosed ?? []) {\n const group = counted.get(inner.fingerprint) ?? [];\n const index = group.indexOf(inner);\n if (index !== -1) {\n group[index] = { ...inner, nestedInLargerGroup: true };\n }\n }\n // The enclosed regions give way to the enclosing one, keeping kept regions mutually\n // non-overlapping: a later candidate inside this region must see it, not a region it\n // swallowed (which the candidate could straddle instead).\n const occupied = enclosed ? keptRegions.filter((region) => !enclosed.includes(region)) : keptRegions;\n occupied.push(candidate);\n keptRegionsByBucket.set(candidate.regionBucket ?? 0, occupied);\n const group = counted.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n counted.set(candidate.fingerprint, group);\n }\n // Nested copies join only a group that kept a standalone copy; on their own they would merely\n // restate the larger group.\n for (const [fingerprint, nested] of nestedByFingerprint) {\n counted.get(fingerprint)?.push(...nested);\n }\n for (const [fingerprint, group] of counted) {\n if (group.every((candidate) => candidate.nestedInLargerGroup)) {\n counted.delete(fingerprint);\n }\n }\n\n let failedFingerprint: string | undefined;\n let failedTokenCount = -1;\n for (const [fingerprint, group] of counted) {\n const tokenCount = group[0]?.tokenCount ?? 0;\n if (!isSurvivingGroup(group) && tokenCount > failedTokenCount) {\n failedFingerprint = fingerprint;\n failedTokenCount = tokenCount;\n }\n }\n // No failed fingerprint means every counted group met the survivor requirement.\n if (failedFingerprint === undefined) {\n return counted;\n }\n if (rerun >= maxSelectionRerunCount) {\n dropFailedGroups(counted, isSurvivingGroup);\n return counted;\n }\n\n duplicates = duplicates.filter((candidate) => candidate.fingerprint !== failedFingerprint);\n }\n}\n\n/**\n * Past the rerun cap, still-failing groups are dropped without another selection pass. A dropped\n * group's regions may have been what nested copies of surviving groups lay inside, and such a copy\n * would then be counted by no group at all, so those copies are dropped too and the shrunk groups\n * are re-checked until nothing changes.\n */\nfunction dropFailedGroups<T extends SelectableRegion>(\n counted: Map<string, T[]>,\n isSurvivingGroup: (group: T[]) => boolean\n): void {\n for (let changed = true; changed;) {\n changed = false;\n for (const [fingerprint, group] of counted) {\n if (!isSurvivingGroup(group) || group.every((candidate) => candidate.nestedInLargerGroup)) {\n counted.delete(fingerprint);\n changed = true;\n }\n }\n const standalone = [...counted.values()].flat().filter((candidate) => !candidate.nestedInLargerGroup);\n for (const [fingerprint, group] of counted) {\n const kept = group.filter(\n (candidate) =>\n !candidate.nestedInLargerGroup ||\n standalone.some(\n (region) =>\n (region.regionBucket ?? 0) === (candidate.regionBucket ?? 0) &&\n region.startIndex <= candidate.startIndex &&\n candidate.endIndex <= region.endIndex\n )\n );\n if (kept.length !== group.length) {\n counted.set(fingerprint, kept);\n changed = true;\n }\n }\n }\n}\n\n/** Drops candidates covering the same source region (a block and the statement run spanning it). */\nexport function dedupeByRegion<T extends SelectableRegion>(group: T[]): T[] {\n const byRegion = new Map<string, T>();\n for (const candidate of group) {\n const key = `${candidate.regionBucket ?? 0}:${candidate.startIndex}:${candidate.endIndex}`;\n const existing = byRegion.get(key);\n if (!existing || candidate.tokenCount > existing.tokenCount) {\n byRegion.set(key, candidate);\n }\n }\n return [...byRegion.values()];\n}\n"],"mappings":"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"}
|
package/dist/duplication.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";const e={minTokens:40,maxGapTokens:30,minSimilarityPercent:70};function t(t){return{minTokens:n(t?.minTokens,e.minTokens),maxGapTokens:n(t?.maxGapTokens,e.maxGapTokens),minSimilarityPercent:n(t?.minSimilarityPercent,e.minSimilarityPercent)}}function n(e,t){return e===void 0||Number.isNaN(e)?t:Math.min(Math.max(Math.trunc(e),0),4294967295)}function r(e,t){return e*5>=t}function i(e){let t=new Int32Array(e.length+1);for(let[n,r]of e.entries())t[n+1]=(t[n]??0)+(r.literalHash===void 0?0:1);return t}function a(e,t,n){let r=[],i=[],a=[];for(let[t,n]of e.entries())for(let e of n.containers)i.push(t),a.push(e);let c=t=>e[i[t]??0],l
|
|
1
|
+
"use strict";const e={minTokens:40,maxGapTokens:30,minSimilarityPercent:70};function t(t){return{minTokens:n(t?.minTokens,e.minTokens),maxGapTokens:n(t?.maxGapTokens,e.maxGapTokens),minSimilarityPercent:n(t?.minSimilarityPercent,e.minSimilarityPercent)}}function n(e,t){return e===void 0||Number.isNaN(e)?t:Math.min(Math.max(Math.trunc(e),0),4294967295)}function r(e,t){return e*5>=t}function i(e){let t=new Int32Array(e.length+1);for(let[n,r]of e.entries())t[n+1]=(t[n]??0)+(r.literalHash===void 0?0:1);return t}function a(e,t,n){let r=[],i=[],a=[];for(let[t,n]of e.entries())for(let e of n.containers)i.push(t),a.push(e);let c=t=>e[i[t]??0],l=/* @__PURE__ */ new Map,u=a.map((e,n)=>s(c(n)?.tokens??[],e,t));for(let[e,t]of u.entries()){let n=i[e]??0;for(let[r,i]of t.windowKeysByStart.entries())for(let t of i){if(t===void 0)continue;let i=l.get(t);i?(i.count+=1,i.containerIndex!==e&&(i.containerIndex=-1),i.contextIndex!==n&&(i.contextIndex=-1),i.minStart=Math.min(i.minStart,r),i.maxStart=Math.max(i.maxStart,r)):l.set(t,{count:1,containerIndex:e,contextIndex:n,minStart:r,maxStart:r})}}let f=(e,t)=>{if(e===void 0)return!1;let r=l.get(e);return r===void 0||r.count<2?!1:n?r.contextIndex===-1:r.containerIndex===-1||r.maxStart-r.minStart>=t},p=e=>{let t=u[e.containerIndex]?.statementHashes??[],n=t[e.start];for(let r=e.start+1;r<e.start+e.length;r+=1)if(t[r]!==n)return!0;return!1},m=[];for(let[e,t]of u.entries())for(let[n,r]of t.windowKeysByStart.entries())for(let[i,a]of r.entries()){if(!f(a,i)||!p({containerIndex:e,start:n,length:i}))continue;let r=t.windowKeysByStart[n]?.[i+1],o=t.windowKeysByStart[n-1]?.[i+1];f(r,i+1)||f(o,i+1)||m.push({containerIndex:e,start:n,length:i})}let h=new Set(m.map(o)),_=m;for(;_.length>0;){let e=[];for(let t of _){let n=a[t.containerIndex],o=n?.[t.start],s=n?.[t.start+t.length-1],l=c(t.containerIndex);if(!o||!s||!l)continue;let u=`s:${g(l.tokens,l.literalCountPrefix,o.startTokenIndex,s.endTokenIndex)}`;r.push({candidate:d(u,o.startTokenIndex,s.endTokenIndex,o,s),contextIndex:i[t.containerIndex]??0}),e.push(t)}_=[];for(let t of e)for(let e of[t.start,t.start+1]){let n={containerIndex:t.containerIndex,start:e,length:t.length-1},r=u[t.containerIndex]?.windowKeysByStart[e]?.[n.length];!h.has(o(n))&&f(r,n.length)&&p(n)&&(h.add(o(n)),_.push(n))}}return r}function o(e){return`${e.containerIndex}:${e.start}:${e.length}`}function s(e,t,n){let r=t.map(t=>_(e,t.startTokenIndex,t.endTokenIndex)),i=[];for(let e=0;e<t.length;e+=1){let a=[],o=5381,s=0,c=Math.min(t.length,e+100);for(let i=e;i<c;i+=1){let c=t[i],l=r[i];if(!c||l===void 0)break;o=x(o,l),s+=c.endTokenIndex-c.startTokenIndex;let u=i-e+1;a[u]=u>=2&&s>=n?x(o,u):void 0}i.push(a)}return{windowKeysByStart:i,statementHashes:r}}function c(e,t){return l(e)(t)}function l(e){let t=e.length+31>>>5,n=/* @__PURE__ */ new Map;for(let[r,i]of e.entries()){let e=n.get(i);e||(e=new Uint32Array(t),n.set(i,e));let a=r>>>5;e[a]=(e[a]??0)|1<<(r&31)}let r=new Uint32Array(t);return e=>{r.fill(0);for(let i of e){let e=n.get(i),a=1,o=0;for(let n=0;n<t;n+=1){let t=r[n]??0,i=((e?.[n]??0)|t)>>>0,s=(t<<1|a)>>>0;a=t>>>31;let c=i-s-o;o=+(c<0),r[n]=i&~c}}let i=0;for(let e of r)i+=u(e);return i}}function u(e){let t=e-(e>>>1&1431655765);return t=(t&858993459)+(t>>>2&858993459),Math.imul(t+(t>>>4)&252645135,16843009)>>>24&255}function d(e,t,n,r,i){return{fingerprint:e,tokenCount:n-t,startTokenIndex:t,endTokenIndex:n,startIndex:r.startIndex,endIndex:i.endIndex,startLine:r.startLine,endLine:i.endLine}}const f=[],p=[];function m(e){let t=f[e];return t===void 0&&(t=y(`$${e}`),f[e]=t),t}function h(e){let t=p[e];return t===void 0&&(t=b(`$${e}`),p[e]=t),t}function g(e,t,n,i){let[a,o]=v(e,n,i,r((t[i]??0)-(t[n]??0),i-n));return`${a}:${o}:${i-n}`}function _(e,t,n){let[r,i]=v(e,t,n,!1);return r^Math.imul(i,31)}function v(e,t,n,r){let i=/* @__PURE__ */ new Map,a=5381,o=52711;for(let s=t;s<n;s+=1){let t=e[s];if(!t)continue;let n,c;if(t.kind===`id`){let e=i.get(t.text);e===void 0&&(e=i.size,i.set(t.text,e)),n=m(e),c=h(e)}else n=t.textHash,c=t.textHash2;a=Math.imul(a,31)+n|0,o=Math.imul(o,37)^c,r&&t.literalHash!==void 0&&t.literalHash2!==void 0&&(a=Math.imul(a,31)+t.literalHash|0,o=Math.imul(o,37)^t.literalHash2)}return[a,o]}function y(e){let t=5381;for(let n=0;n<e.length;n+=1)t=Math.imul(t,33)^e.charCodeAt(n);return t}function b(e){let t=-2128831035;for(let n=0;n<e.length;n+=1)t=Math.imul(t^e.charCodeAt(n),16777619);return t}function x(e,t){return Math.imul(e,31)+t}function S(e,t,n=()=>!0){if(t<=0||e.length<2)return e;e.sort(T);for(let r=!0;r;){r=!1;let i=C(e,t);for(let a=0;a<e.length&&!r;a+=1)for(let o of i[a]??[]){let i=e[a],s=e[o];if(!i||!s)continue;let c=E(i,s,t,n),l=c??E(s,i,t,n);if(!l)continue;let u=c?l.firstReplaced:l.secondReplaced,d=c?l.secondReplaced:l.firstReplaced;u&&d?(e[a]=l.merged,e.splice(o,1)):d?e[o]=l.merged:u?e[a]=l.merged:e.push(l.merged);for(let e of l.pairedRetained)e.spanCountedElsewhere=!0;e.sort(T),r=!0;break}}return e}function C(e,t){let n=[];for(let[t,r]of e.entries())for(let e of r)n.push({startTokenIndex:e.startTokenIndex,groupIndex:t});n.sort((e,t)=>e.startTokenIndex-t.startTokenIndex);let r=e.map(()=>/* @__PURE__ */ new Set);for(let[i,a]of e.entries())for(let e of a)for(let a=w(n,e.endTokenIndex);a<n.length&&(n[a]?.startTokenIndex??0)<=e.endTokenIndex+t;a+=1){let e=n[a]?.groupIndex??i;e!==i&&r[Math.min(e,i)]?.add(Math.max(e,i))}return r.map(e=>[...e].toSorted((e,t)=>e-t))}function w(e,t){let n=0,r=e.length;for(;n<r;){let i=n+r>>>1;(e[i]?.startTokenIndex??0)<t?n=i+1:r=i}return n}function T(e,t){let n=e[0],r=t[0];return(n?.startTokenIndex??0)-(r?.startTokenIndex??0)||(n?.endTokenIndex??0)-(r?.endTokenIndex??0)}function E(e,t,n,r){let[i,a]=D(e),[o,s]=D(t),c=[],l=0,u=-1;for(let e of o){for(;l<i.length;){let t=i[l];if(t&&t.endTokenIndex+n<e.startTokenIndex)l+=1;else break}let t=i[l];t&&t.endTokenIndex<=e.startTokenIndex&&t.startTokenIndex>=u&&(c.push([t,e]),u=e.endTokenIndex,l+=1)}let d=c.length===a,f=c.length===s;if(c.length<2||!d&&!f)return;let p=d&&!e.some(e=>e.nestedInLargerGroup),m=f&&!t.some(e=>e.nestedInLargerGroup),h=c.map(([e,t])=>({...e,spanCountedElsewhere:void 0,nestedInLargerGroup:void 0,segments:[...e.segments,...t.segments],tokenCount:e.tokenCount+t.tokenCount,endTokenIndex:t.endTokenIndex,endIndex:t.endIndex,endLine:t.endLine}));if(r(h))return{merged:h,firstReplaced:p,secondReplaced:m,pairedRetained:[...p?[]:c.map(([e])=>e),...m?[]:c.map(([,e])=>e)]}}function D(e){let t=[],n=0;for(let r of e)r.nestedInLargerGroup||(n+=1),!r.spanCountedElsewhere&&!r.nestedInLargerGroup&&t.push(r);return[t,n]}function O(e){let t=0,n=0,r=!1;for(let i of e){if(i.spanCountedElsewhere){r=!0;continue}t+=i.segments.length,n=Math.max(n,i.segments.length)}return r?t:t-n}function k(e,t,n,r){for(let i=e.startTokenIndex;i<e.endTokenIndex;i+=1){let e=t[i];for(let t=e?.startRow??0;t<=(e?.endRow??-1);t+=1)(!n||n.has(t+1))&&r.add(t+1)}}exports.buildLiteralCountPrefix=i,exports.collectSegmentLines=k,exports.collectSequenceWindowCandidates=a,exports.countRedundantFragments=O,exports.createLcsLengthCounter=l,exports.defaultDuplicationOptions=e,exports.lcsLength=c,exports.mergeAdjacentGroups=S,exports.resolveDuplicationOptions=t;
|
|
2
2
|
//# sourceMappingURL=duplication.cjs.map
|