code-gauge 4.2.1 → 4.3.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 +12 -3
- package/dist/cliConfig.cjs +1 -1
- package/dist/cliConfig.cjs.map +1 -1
- package/dist/cliConfig.js +1 -1
- package/dist/crossFileDuplication.cjs +1 -1
- package/dist/crossFileDuplication.cjs.map +1 -1
- package/dist/crossFileDuplication.d.ts +3 -1
- package/dist/crossFileDuplication.js +1 -1
- package/dist/crossFileDuplication.js.map +1 -1
- package/dist/diffCommand.cjs +1 -1
- package/dist/diffCommand.cjs.map +1 -1
- package/dist/diffCommand.js +1 -1
- package/dist/diffCommand.js.map +1 -1
- package/dist/duplicateSelection.cjs +1 -1
- package/dist/duplicateSelection.cjs.map +1 -1
- package/dist/duplicateSelection.d.ts +13 -4
- 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.d.ts +16 -7
- package/dist/duplication.js +1 -1
- package/dist/duplication.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/languages.cjs +1 -1
- package/dist/languages.cjs.map +1 -1
- package/dist/languages.d.ts +3 -1
- package/dist/languages.js +1 -1
- package/dist/languages.js.map +1 -1
- package/dist/metrics.cjs +1 -1
- package/dist/metrics.cjs.map +1 -1
- package/dist/metrics.js +1 -1
- package/dist/metrics.js.map +1 -1
- package/dist/nativeMetrics.cjs +1 -1
- package/dist/nativeMetrics.cjs.map +1 -1
- package/dist/nativeMetrics.js +1 -1
- package/dist/nativeMetrics.js.map +1 -1
- package/dist/scan.cjs +1 -1
- package/dist/scan.cjs.map +1 -1
- package/dist/scan.js +1 -1
- package/dist/scan.js.map +1 -1
- package/dist/types.d.ts +15 -2
- package/native/src/complexity.rs +134 -42
- package/native/src/cyclomatic.rs +79 -0
- package/native/src/dep_degree.rs +102 -11
- package/native/src/functions.rs +828 -22
- package/native/src/languages.rs +16 -0
- package/native/src/lib.rs +2 -1
- package/native/src/measure.rs +2 -0
- package/native/src/types.rs +2 -0
- package/package.json +14 -14
package/dist/diffCommand.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{measureCrossFileDuplication as e}from"./crossFileDuplication.js";import{collectCrossFileDuplicationFileData as t,collectFunctionTokenSequences as n,measureCode as r}from"./metrics.js";import{evaluateRegressionGate as i}from"./regressionGate.js";import{loadConfig as a,resolveGateOptions as o,resolveOptions as s}from"./cliConfig.js";import{listChangedFiles as c,listRepositoryFiles as l,listSymlinkPathsAtRevision as u,readFileAtRevision as d,resolveMergeBase as f,resolveRepoRoot as p}from"./git.js";import{collectDuplicatedLineNumbers as m,configSearchDirectory as h,formatError as g,formatPath as _,getLanguage as v,isScannedPath as y,resolveTarget as b,scanListedFiles as x,writeStderr as S,writeStdout as C}from"./scan.js";import{lstat as
|
|
1
|
+
import{measureCrossFileDuplication as e}from"./crossFileDuplication.js";import{collectCrossFileDuplicationFileData as t,collectFunctionTokenSequences as n,measureCode as r}from"./metrics.js";import{evaluateRegressionGate as i}from"./regressionGate.js";import{loadConfig as a,resolveGateOptions as o,resolveOptions as s}from"./cliConfig.js";import{listChangedFiles as c,listRepositoryFiles as l,listSymlinkPathsAtRevision as u,readFileAtRevision as d,resolveMergeBase as f,resolveRepoRoot as p}from"./git.js";import{collectDuplicatedLineNumbers as m,configSearchDirectory as h,formatError as g,formatPath as _,getLanguage as v,isScannedPath as y,resolveTarget as b,scanListedFiles as x,writeStderr as S,writeStdout as C}from"./scan.js";import w from"node:path";import{lstat as T,readFile as E,realpath as D,stat as O}from"node:fs/promises";async function k(e,t){try{await A(e,t)}catch(e){S(`Error: ${g(e)}\n`),process.exitCode=2}}async function A(e,t){let n=b(e),r=await a(t.config,await h(n)),d=s(t,r),m=o(r),g=await D(await p(await R(await h(n)))),v=await f(g,t.base),S=await c(g,v),C=await l(g),w=await u(g,v),T=await x(g,C,d);if(T.fatalError)throw Error(T.fatalError);let E=T.files.map(e=>({relativePath:_(e.file,T.displayRoot),file:e})),O=new Set(S.flatMap(e=>[e.headPath,...e.basePath===void 0?[]:[e.basePath]]).filter(e=>y(e,d))),k=[],A=[...T.warnings];for(let e of T.errors)[...O].some(t=>e.startsWith(`${t}:`))?k.push(e):A.push(e);let{canonicalTarget:N,targetExists:P}=await j(n),F=await M(S,{repoRoot:g,mergeBase:v,canonicalTarget:N,options:d,scannedFiles:E,baseSymlinkPaths:w},k,A);if(!P&&!F.some(e=>e.gated))throw Error(`target "${e}" does not exist and matches no changed file`);let{baseCross:I,headCross:L}=B(F,E,d),z=F.map(e=>V(e,I,L)),H=i(z,m);t.json?J(t,v,H,z,k,A):W(t,v,H,k,A),k.length>0?process.exitCode=2:H.violations.length>0&&(process.exitCode=1)}async function j(e){try{return{canonicalTarget:await D(e),targetExists:!0}}catch{return{canonicalTarget:e,targetExists:!1}}}async function M(e,t,n,r){let i=new Map(t.scannedFiles.map(({relativePath:e,file:t})=>[e,t])),a=[];for(let o of e){let e=await N(o,t,i,n,r);e&&a.push(e)}return a}async function N(e,t,n,r,i){let a=e.status!==`deleted`&&y(e.headPath,t.options)&&!await L(w.join(t.repoRoot,e.headPath)),o=e.basePath!==void 0&&y(e.basePath,t.options)&&!t.baseSymlinkPaths.has(e.basePath);if(!a&&!o)return;let s=e.status===`deleted`?e.basePath:e.headPath,c=a?n.get(e.headPath):void 0;if(a&&!c){P(e.headPath,r);return}let l={changed:e,displayFile:s,gated:a||e.status===`deleted`?z(w.join(t.repoRoot,s),t.canonicalTarget):!1,headFile:c};if(!o||await I(l,e.basePath,t,r,i))return c&&await F(l,c,t,i),l}function P(e,t){t.some(t=>t.startsWith(`${e}:`))||t.push(`${e}: changed file was not measured`)}async function F(e,t,r,i){try{let i=await E(t.file,`utf8`);e.headFunctionTokens=n(i,{language:v(e.changed.headPath,r.options),duplication:r.options.duplication})}catch(t){i.push(`${e.displayFile}: function token sequences unavailable: ${g(t)}`)}}async function I(e,i,a,o,s){let c={language:v(i,a.options),duplication:a.options.duplication},l;try{l=await d(a.repoRoot,a.mergeBase,i),e.baseMetrics=r(l,c)}catch(e){return o.push(`${i} (at merge-base): ${g(e)}`),!1}try{e.baseCandidates=t(l,c),e.baseFunctionTokens=n(l,c)}catch(e){s.push(`${i} (at merge-base): duplication candidates and token sequences unavailable: ${g(e)}`)}return!0}async function L(e){return(await T(e).catch(()=>{}))?.isSymbolicLink()??!1}async function R(e){let t=e;for(;;){if((await O(t).catch(()=>{}))?.isDirectory())return t;let e=w.dirname(t);if(e===t)return t;t=e}}function z(e,t){let n=w.relative(t,e);return n===``||!n.startsWith(`..${w.sep}`)&&n!==`..`&&!w.isAbsolute(n)}function B(t,n,r){let i=n.flatMap(({relativePath:e,file:t})=>t.duplicationCandidates?[{file:e,...t.duplicationCandidates}]:[]),a=new Set(t.flatMap(e=>e.changed.status===`deleted`?[]:[e.changed.headPath])),o=i.filter(e=>!a.has(e.file));for(let e of t)e.baseCandidates&&e.changed.basePath!==void 0&&o.push({file:e.changed.basePath,...e.baseCandidates});return{baseCross:o.length>=2?e(o,r.duplication):void 0,headCross:i.length>=2?e(i,r.duplication):void 0}}function V(e,t,n){return{file:e.displayFile,baseMetrics:e.baseMetrics,headMetrics:e.headFile?.metrics,baseFunctionTokens:e.baseFunctionTokens,headFunctionTokens:e.headFunctionTokens,baseDuplicatedLineCount:e.baseMetrics===void 0||e.changed.basePath===void 0?0:H(e.baseMetrics,t,e.changed.basePath),headDuplicatedLineCount:e.changed.status===`deleted`?0:H(e.headFile?.metrics,n,e.changed.headPath),duplicationPartners:U(n,e.changed.headPath),gated:e.gated}}function H(e,t,n){return m(e,t,n).size}function U(e,t){if(!e)return[];let n=new Set;for(let r of e.groups)if(r.files.includes(t))for(let e of r.files)e!==t&&n.add(e);return[...n].toSorted()}function W(e,t,n,r,i){let a=t.slice(0,12);r.length>0?(C(`Regression gate could not complete: ${r.length} measurement failures (details on stderr)${n.violations.length>0?`; ${n.violations.length} violations in the measured files`:``} (base ${e.base}, merge-base ${a}).\n`),G(n)):n.violations.length===0?C(`Regression gate passed: ${n.checkedFileCount} changed files, ${n.checkedFunctionCount} functions checked (base ${e.base}, merge-base ${a}).\n`):(C(`Regression gate vs ${e.base} (merge-base ${a}): ${n.violations.length} violations\n`),G(n)),e.full&&K(n);for(let e of i)S(`Warning: ${e}\n`);for(let e of r)S(`Error: ${e}\n`)}function G(e){for(let[t,n]of e.violations.entries())C(`${t+1}. ${n.message}\n`)}function K(e){if(e.checkedFunctions.length!==0){C(`
|
|
2
2
|
Checked functions (base -> head):
|
|
3
3
|
`);for(let t of e.checkedFunctions)C(`- ${q(t)}\n`)}}function q(e){let t=(t,n=String)=>{let r=n(t(e.head));return e.base?`${n(t(e.base))} -> ${r}`:r},n=[`cognitive ${t(e=>e.cognitiveComplexity)}`,`NCSS ${t(e=>e.ncss)}`,`nesting ${t(e=>e.nestingDepth)}`,`DepDegree ${t(e=>e.depDegree)}`,`volume ${t(e=>e.halsteadVolume,e=>e.toFixed(1))}`];return`${e.file}:${e.startLine}-${e.endLine} ${e.name}${e.base?``:` (new)`}: ${n.join(`, `)}`}function J(e,t,n,r,i,a){let o={base:e.base,mergeBase:t,passed:n.violations.length===0&&i.length===0,violations:n.violations,checkedFileCount:n.checkedFileCount,checkedFunctionCount:n.checkedFunctionCount,newFunctionCount:n.newFunctionCount,errors:i,warnings:a};e.full&&(o.files=r.filter(e=>e.gated!==!1).map(e=>({file:e.file,baseFunctionCount:e.baseMetrics?.functions.length??0,headFunctionCount:e.headMetrics?.functions.length??0,baseNcss:e.baseMetrics?.ncssCount??0,headNcss:e.headMetrics?.ncssCount??0,baseMaxCognitiveComplexity:e.baseMetrics?.maxCognitiveComplexity??0,headMaxCognitiveComplexity:e.headMetrics?.maxCognitiveComplexity??0,baseDuplicatedLineCount:e.baseDuplicatedLineCount,headDuplicatedLineCount:e.headDuplicatedLineCount,duplicationPartners:e.duplicationPartners,functions:n.checkedFunctions.filter(t=>t.file===e.file)}))),C(JSON.stringify(o,void 0,2)+`
|
|
4
4
|
`)}export{k as runDiffCommand};
|
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 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 { collectCrossFileDuplicationFileData, collectFunctionTokenSequences, measureCode } 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 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 },\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}\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 const prepared: PreparedFile[] = [];\n for (const changed of changedFiles) {\n const file = await prepareChangedFile(changed, context, headByPath, errors, warnings);\n if (file) {\n prepared.push(file);\n }\n }\n return prepared;\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, 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, errors: string[]): void {\n if (!errors.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 file.baseMetrics = measureCode(baseContent, measureOptions);\n } catch (error) {\n errors.push(`${basePath} (at merge-base): ${formatError(error)}`);\n return false;\n }\n try {\n file.baseCandidates = collectCrossFileDuplicationFileData(baseContent, measureOptions);\n file.baseFunctionTokens = collectFunctionTokenSequences(baseContent, measureOptions);\n } catch (error) {\n warnings.push(\n `${basePath} (at merge-base): duplication candidates and token sequences unavailable: ${formatError(error)}`\n );\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":"u0BA0EA,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,kBAAiB,EAChF,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,CAYA,eAAe,EACb,EACA,EACA,EACA,EACyB,CACzB,IAAM,EAAa,IAAI,IAAI,EAAQ,aAAa,KAAK,CAAE,eAAc,UAAW,CAAC,EAAc,CAAI,CAAC,CAAC,EAC/F,EAA2B,CAAC,EAClC,IAAK,IAAM,KAAW,EAAc,CAClC,IAAM,EAAO,MAAM,EAAmB,EAAS,EAAS,EAAY,EAAQ,CAAQ,EAChF,GACF,EAAS,KAAK,CAAI,CAEtB,CACA,OAAO,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,CAAM,EACpD,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,QAAiB,CAAE,MAAM,EAAoB,EAAM,EAAQ,SAAoB,EAAS,EAAQ,CAAQ,GAQ5G,OAJI,GACF,MAAM,EAA0B,EAAM,EAAU,EAAS,CAAQ,EAG5D,CACT,CAQA,SAAS,EAA4B,EAAkB,EAAwB,CACxE,EAAO,KAAM,GAAU,EAAM,WAAW,GAAG,EAAS,EAAE,CAAC,GAC1D,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,EAAK,YAAc,EAAY,EAAa,CAAc,CAC5D,OAAS,EAAO,CAEd,OADA,EAAO,KAAK,GAAG,EAAS,oBAAoB,EAAY,CAAK,GAAG,EACzD,EACT,CACA,GAAI,CACF,EAAK,eAAiB,EAAoC,EAAa,CAAc,EACrF,EAAK,mBAAqB,EAA8B,EAAa,CAAc,CACrF,OAAS,EAAO,CACd,EAAS,KACP,GAAG,EAAS,4EAA4E,EAAY,CAAK,GAC3G,CACF,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 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 { collectCrossFileDuplicationFileData, collectFunctionTokenSequences, measureCode } 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 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 },\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}\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 const prepared: PreparedFile[] = [];\n for (const changed of changedFiles) {\n const file = await prepareChangedFile(changed, context, headByPath, errors, warnings);\n if (file) {\n prepared.push(file);\n }\n }\n return prepared;\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, 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, errors: string[]): void {\n if (!errors.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 file.baseMetrics = measureCode(baseContent, measureOptions);\n } catch (error) {\n errors.push(`${basePath} (at merge-base): ${formatError(error)}`);\n return false;\n }\n try {\n file.baseCandidates = collectCrossFileDuplicationFileData(baseContent, measureOptions);\n file.baseFunctionTokens = collectFunctionTokenSequences(baseContent, measureOptions);\n } catch (error) {\n warnings.push(\n `${basePath} (at merge-base): duplication candidates and token sequences unavailable: ${formatError(error)}`\n );\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":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";function e(e,
|
|
1
|
+
"use strict";function e(e,r,i){let a=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=new Map,i=new Map,a=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=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 shared by the within-file and cross-file\n * detectors. Candidates are grouped by fingerprint, ranked by total coverage, kept greedily\n * without overlapping a kept region, and groups that fall below the survivor requirement are shed\n * one at a time (largest first) so their regions stop blocking smaller groups.\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\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, { startIndex: number; endIndex: number }[]>();\n const counted = new Map<string, T[]>();\n for (const candidate of duplicates) {\n const keptRegions = keptRegionsByBucket.get(candidate.regionBucket ?? 0) ?? [];\n if (\n keptRegions.some((region) => region.startIndex < candidate.endIndex && candidate.startIndex < region.endIndex)\n ) {\n continue;\n }\n keptRegions.push(candidate);\n keptRegionsByBucket.set(candidate.regionBucket ?? 0, keptRegions);\n const group = counted.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n counted.set(candidate.fingerprint, group);\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 for (const [fingerprint, group] of counted) {\n if (!isSurvivingGroup(group)) {\n counted.delete(fingerprint);\n }\n }\n return counted;\n }\n\n duplicates = duplicates.filter((candidate) => candidate.fingerprint !== failedFingerprint);\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":"aA2BA,SAAgB,EACd,EACA,EACA,EACkB,CAClB,IAAM,EAAgB,IAAI,IAC1B,IAAK,IAAM,KAAa,EAAY,CAClC,IAAM,EAAQ,EAAc,IAAI,EAAU,WAAW,GAAK,CAAC,EAC3D,EAAM,KAAK,CAAS,EACpB,EAAc,IAAI,EAAU,YAAa,CAAK,CAChD,CAEA,IAAM,EAAS,CAAC,GAAG,EAAc,OAAO,CAAC,CAAC,CAAC,IAAI,CAAc,CAAC,CAAC,OAAO,CAAgB,EAIhF,EAAyB,IAAI,IAAI,EAAO,IAAK,GAAU,CAAC,EAAM,EAAE,EAAE,aAAe,GAAI,EAAM,MAAM,CAAC,CAAC,EACnG,EAAY,GAChB,EAAU,YAAc,EAAuB,IAAI,EAAU,WAAW,GAAK,GAC3E,EAAa,EAAO,KAAK,EAC7B,EAAW,MAAM,EAAM,IAAU,EAAS,CAAK,EAAI,EAAS,CAAI,IAAM,EAAc,EAAY,EAAM,CAAK,EAAI,EAAE,EAOjH,IAAK,IAAI,EAAQ,GAAK,GAAS,EAAG,CAChC,IAAM,EAAsB,IAAI,IAC1B,EAAU,IAAI,IACpB,IAAK,IAAM,KAAa,EAAY,CAClC,IAAM,EAAc,EAAoB,IAAI,EAAU,cAAgB,CAAC,GAAK,CAAC,EAC7E,GACE,EAAY,KAAM,GAAW,EAAO,WAAa,EAAU,UAAY,EAAU,WAAa,EAAO,QAAQ,EAE7G,SAEF,EAAY,KAAK,CAAS,EAC1B,EAAoB,IAAI,EAAU,cAAgB,EAAG,CAAW,EAChE,IAAM,EAAQ,EAAQ,IAAI,EAAU,WAAW,GAAK,CAAC,EACrD,EAAM,KAAK,CAAS,EACpB,EAAQ,IAAI,EAAU,YAAa,CAAK,CAC1C,CAEA,IAAI,EACA,EAAmB,GACvB,IAAK,GAAM,CAAC,EAAa,KAAU,EAAS,CAC1C,IAAM,EAAa,EAAM,EAAE,EAAE,YAAc,EACvC,CAAC,EAAiB,CAAK,GAAK,EAAa,IAC3C,EAAoB,EACpB,EAAmB,EAEvB,CAEA,GAAI,IAAsB,IAAA,GACxB,OAAO,EAET,GAAI,GAAS,GAAwB,CACnC,IAAK,GAAM,CAAC,EAAa,KAAU,EAC5B,EAAiB,CAAK,GACzB,EAAQ,OAAO,CAAW,EAG9B,OAAO,CACT,CAEA,EAAa,EAAW,OAAQ,GAAc,EAAU,cAAgB,CAAiB,CAC3F,CACF,CAGA,SAAgB,EAA2C,EAAiB,CAC1E,IAAM,EAAW,IAAI,IACrB,IAAK,IAAM,KAAa,EAAO,CAC7B,IAAM,EAAM,GAAG,EAAU,cAAgB,EAAE,GAAG,EAAU,WAAW,GAAG,EAAU,WAC1E,EAAW,EAAS,IAAI,CAAG,GAC7B,CAAC,GAAY,EAAU,WAAa,EAAS,aAC/C,EAAS,IAAI,EAAK,CAAS,CAE/B,CACA,MAAO,CAAC,GAAG,EAAS,OAAO,CAAC,CAC9B"}
|
|
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,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Maximal, non-overlapping duplicate-group selection
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Maximal, non-overlapping duplicate-group selection for the cross-file detector (the native
|
|
3
|
+
* within-file detector mirrors its greedy ranking and shedding, but not the nested-copy retention
|
|
4
|
+
* below, which is cross-file only). Candidates are grouped by fingerprint, ranked by total
|
|
5
|
+
* coverage, kept greedily without overlapping a kept region, and groups that fall below the
|
|
6
|
+
* survivor requirement are shed one at a time (largest first) so their regions stop blocking
|
|
7
|
+
* smaller groups. A copy lying entirely inside a larger group's region stays with its group as a
|
|
8
|
+
* nested copy (whichever group the greedy order kept first), so a standalone copy elsewhere is
|
|
9
|
+
* still reported as duplicating it.
|
|
6
10
|
*/
|
|
7
11
|
export interface SelectableRegion {
|
|
8
12
|
fingerprint: string;
|
|
@@ -14,6 +18,11 @@ export interface SelectableRegion {
|
|
|
14
18
|
* the cross-file detector buckets by file index.
|
|
15
19
|
*/
|
|
16
20
|
regionBucket?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Set by selectMaximalGroups on a copy nested inside a larger group's region: it is reported with
|
|
23
|
+
* its group, but its span is already counted by that larger group.
|
|
24
|
+
*/
|
|
25
|
+
nestedInLargerGroup?: boolean;
|
|
17
26
|
}
|
|
18
27
|
/**
|
|
19
28
|
* @param isSurvivingGroup whether a selected group counts (e.g. at least two occurrences, or
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
function e(e,
|
|
1
|
+
function e(e,r,i){let a=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=new Map,i=new Map,a=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=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 shared by the within-file and cross-file\n * detectors. Candidates are grouped by fingerprint, ranked by total coverage, kept greedily\n * without overlapping a kept region, and groups that fall below the survivor requirement are shed\n * one at a time (largest first) so their regions stop blocking smaller groups.\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\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, { startIndex: number; endIndex: number }[]>();\n const counted = new Map<string, T[]>();\n for (const candidate of duplicates) {\n const keptRegions = keptRegionsByBucket.get(candidate.regionBucket ?? 0) ?? [];\n if (\n keptRegions.some((region) => region.startIndex < candidate.endIndex && candidate.startIndex < region.endIndex)\n ) {\n continue;\n }\n keptRegions.push(candidate);\n keptRegionsByBucket.set(candidate.regionBucket ?? 0, keptRegions);\n const group = counted.get(candidate.fingerprint) ?? [];\n group.push(candidate);\n counted.set(candidate.fingerprint, group);\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 for (const [fingerprint, group] of counted) {\n if (!isSurvivingGroup(group)) {\n counted.delete(fingerprint);\n }\n }\n return counted;\n }\n\n duplicates = duplicates.filter((candidate) => candidate.fingerprint !== failedFingerprint);\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":"AA2BA,SAAgB,EACd,EACA,EACA,EACkB,CAClB,IAAM,EAAgB,IAAI,IAC1B,IAAK,IAAM,KAAa,EAAY,CAClC,IAAM,EAAQ,EAAc,IAAI,EAAU,WAAW,GAAK,CAAC,EAC3D,EAAM,KAAK,CAAS,EACpB,EAAc,IAAI,EAAU,YAAa,CAAK,CAChD,CAEA,IAAM,EAAS,CAAC,GAAG,EAAc,OAAO,CAAC,CAAC,CAAC,IAAI,CAAc,CAAC,CAAC,OAAO,CAAgB,EAIhF,EAAyB,IAAI,IAAI,EAAO,IAAK,GAAU,CAAC,EAAM,EAAE,EAAE,aAAe,GAAI,EAAM,MAAM,CAAC,CAAC,EACnG,EAAY,GAChB,EAAU,YAAc,EAAuB,IAAI,EAAU,WAAW,GAAK,GAC3E,EAAa,EAAO,KAAK,EAC7B,EAAW,MAAM,EAAM,IAAU,EAAS,CAAK,EAAI,EAAS,CAAI,IAAM,EAAc,EAAY,EAAM,CAAK,EAAI,EAAE,EAOjH,IAAK,IAAI,EAAQ,GAAK,GAAS,EAAG,CAChC,IAAM,EAAsB,IAAI,IAC1B,EAAU,IAAI,IACpB,IAAK,IAAM,KAAa,EAAY,CAClC,IAAM,EAAc,EAAoB,IAAI,EAAU,cAAgB,CAAC,GAAK,CAAC,EAC7E,GACE,EAAY,KAAM,GAAW,EAAO,WAAa,EAAU,UAAY,EAAU,WAAa,EAAO,QAAQ,EAE7G,SAEF,EAAY,KAAK,CAAS,EAC1B,EAAoB,IAAI,EAAU,cAAgB,EAAG,CAAW,EAChE,IAAM,EAAQ,EAAQ,IAAI,EAAU,WAAW,GAAK,CAAC,EACrD,EAAM,KAAK,CAAS,EACpB,EAAQ,IAAI,EAAU,YAAa,CAAK,CAC1C,CAEA,IAAI,EACA,EAAmB,GACvB,IAAK,GAAM,CAAC,EAAa,KAAU,EAAS,CAC1C,IAAM,EAAa,EAAM,EAAE,EAAE,YAAc,EACvC,CAAC,EAAiB,CAAK,GAAK,EAAa,IAC3C,EAAoB,EACpB,EAAmB,EAEvB,CAEA,GAAI,IAAsB,IAAA,GACxB,OAAO,EAET,GAAI,GAAS,GAAwB,CACnC,IAAK,GAAM,CAAC,EAAa,KAAU,EAC5B,EAAiB,CAAK,GACzB,EAAQ,OAAO,CAAW,EAG9B,OAAO,CACT,CAEA,EAAa,EAAW,OAAQ,GAAc,EAAU,cAAgB,CAAiB,CAC3F,CACF,CAGA,SAAgB,EAA2C,EAAiB,CAC1E,IAAM,EAAW,IAAI,IACrB,IAAK,IAAM,KAAa,EAAO,CAC7B,IAAM,EAAM,GAAG,EAAU,cAAgB,EAAE,GAAG,EAAU,WAAW,GAAG,EAAU,WAC1E,EAAW,EAAS,IAAI,CAAG,GAC7B,CAAC,GAAY,EAAU,WAAa,EAAS,aAC/C,EAAS,IAAI,EAAK,CAAS,CAE/B,CACA,MAAO,CAAC,GAAG,EAAS,OAAO,CAAC,CAC9B"}
|
|
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=new Map,d=a.map((e,n)=>s(c(n)?.tokens??[],e,t));for(let[e,t]of d.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=d[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 d.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 g=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 d=`s:${h(l.tokens,l.literalCountPrefix,o.startTokenIndex,s.endTokenIndex)}`;r.push({candidate:u(d,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=d[t.containerIndex]?.windowKeysByStart[e]?.[n.length]
|
|
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=new Map,d=a.map((e,n)=>s(c(n)?.tokens??[],e,t));for(let[e,t]of d.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=d[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 d.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 g=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 d=`s:${h(l.tokens,l.literalCountPrefix,o.startTokenIndex,s.endTokenIndex)}`;r.push({candidate:u(d,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=d[t.containerIndex]?.windowKeysByStart[e]?.[n.length];!g.has(o(n))&&f(r,n.length)&&p(n)&&(g.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=>g(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=b(o,l),s+=c.endTokenIndex-c.startTokenIndex;let u=i-e+1;a[u]=u>=2&&s>=n?b(o,u):void 0}i.push(a)}return{windowKeysByStart:i,statementHashes:r}}function c(e,t){let n=e.length+31>>>5,r=new Map;for(let[t,i]of e.entries()){let e=r.get(i);e||(e=new Uint32Array(n),r.set(i,e));let a=t>>>5;e[a]=(e[a]??0)|1<<(t&31)}let i=new Uint32Array(n);for(let e of t){let t=r.get(e),a=1,o=0;for(let e=0;e<n;e+=1){let n=i[e]??0,r=((t?.[e]??0)|n)>>>0,s=(n<<1|a)>>>0;a=n>>>31;let c=r-s-o;o=+(c<0),i[e]=r&~c}}let a=0;for(let e of i)a+=l(e);return a}function l(e){let t=e-(e>>>1&1431655765);return t=(t&858993459)+(t>>>2&858993459),Math.imul(t+(t>>>4)&252645135,16843009)>>>24&255}function u(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 d=[],f=[];function p(e){let t=d[e];return t===void 0&&(t=v(`$${e}`),d[e]=t),t}function m(e){let t=f[e];return t===void 0&&(t=y(`$${e}`),f[e]=t),t}function h(e,t,n,i){let[a,o]=_(e,n,i,r((t[i]??0)-(t[n]??0),i-n));return`${a}:${o}:${i-n}`}function g(e,t,n){let[r,i]=_(e,t,n,!1);return r^Math.imul(i,31)}function _(e,t,n,r){let i=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=p(e),c=m(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 v(e){let t=5381;for(let n=0;n<e.length;n+=1)t=Math.imul(t,33)^e.charCodeAt(n);return t}function y(e){let t=-2128831035;for(let n=0;n<e.length;n+=1)t=Math.imul(t^e.charCodeAt(n),16777619);return t}function b(e,t){return Math.imul(e,31)+t}function x(e,t,n=()=>!0){if(t<=0||e.length<2)return e;e.sort(S);for(let r=!0;r;){r=!1;for(let i=0;i<e.length&&!r;i+=1)for(let a=i+1;a<e.length;a+=1){let o=e[i],s=e[a];if(!o||!s)continue;let c=C(o,s,t,n),l=c??C(s,o,t,n);if(!l)continue;let u=c?l.firstReplaced:l.secondReplaced,d=c?l.secondReplaced:l.firstReplaced;u&&d?(e[i]=l.merged,e.splice(a,1)):d?e[a]=l.merged:u?e[i]=l.merged:e.push(l.merged);for(let e of l.pairedRetained)e.spanCountedElsewhere=!0;e.sort(S),r=!0;break}}return e}function S(e,t){let n=e[0],r=t[0];return(n?.startTokenIndex??0)-(r?.startTokenIndex??0)||(n?.endTokenIndex??0)-(r?.endTokenIndex??0)}function C(e,t,n,r){let[i,a]=w(e),[o,s]=w(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 w(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 T(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 E(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=E,exports.collectSequenceWindowCandidates=a,exports.countRedundantFragments=T,exports.defaultDuplicationOptions=e,exports.lcsLength=c,exports.mergeAdjacentGroups=x,exports.resolveDuplicationOptions=t;
|
|
2
2
|
//# sourceMappingURL=duplication.cjs.map
|
package/dist/duplication.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"duplication.cjs","names":[],"sources":["../src/duplication.ts"],"sourcesContent":["import type { DuplicationOptions } from './types.js';\n\n/**\n * Project-level duplication machinery operating on normalized token streams. Tokenization itself\n * (parsing, identifier anonymization, literal normalization) happens in the Rust addon, which\n * serializes each file's Token stream and statement structure; the helpers here match statement\n * windows across files, merge gap-adjacent groups, and count duplicated lines over that data.\n */\n\nexport const defaultDuplicationOptions: Required<DuplicationOptions> = {\n minTokens: 40,\n maxGapTokens: 30,\n minSimilarityPercent: 70,\n};\n\n/**\n * Fills defaults for absent settings, applying the same normalization as the native boundary's\n * clampToU32 — NaN (e.g. `Number(unsetEnvVariable)`) counts as absent, and other values truncate\n * and clamp to [0, u32::MAX] — so the TypeScript half of cross-file matching cannot diverge from\n * the natively collected candidates on such input.\n */\nexport function resolveDuplicationOptions(options?: DuplicationOptions): Required<DuplicationOptions> {\n return {\n minTokens: resolveOption(options?.minTokens, defaultDuplicationOptions.minTokens),\n maxGapTokens: resolveOption(options?.maxGapTokens, defaultDuplicationOptions.maxGapTokens),\n minSimilarityPercent: resolveOption(options?.minSimilarityPercent, defaultDuplicationOptions.minSimilarityPercent),\n };\n}\n\nfunction resolveOption(value: number | undefined, fallback: number): number {\n return value === undefined || Number.isNaN(value)\n ? fallback\n : Math.min(Math.max(Math.trunc(value), 0), 0xFF_FF_FF_FF);\n}\n\n/** Minimum consecutive statements for a statement-sequence duplicate candidate. */\nconst minSequenceStatementCount = 2;\n/**\n * Caps the window length so statement-sequence enumeration stays linear in the statement count.\n * Heterogeneous clones longer than the cap are reported as capped windows (a deliberate\n * conservative undercount trading completeness for bounded discovery cost).\n */\nconst maxSequenceStatementCount = 100;\n\n/**\n * A region whose normalized tokens are at least 20% literal values is data-like (a lookup table, a\n * constant list, a value-mapping switch), not logic: literal values re-enter its fingerprint so\n * tables that merely share their shape stop counting as copy-paste. Compared in integer math\n * (5 * literals >= total) so the TypeScript and native sides cannot disagree on the boundary.\n */\nfunction isLiteralDense(literalCount: number, tokenCount: number): boolean {\n return literalCount * 5 >= tokenCount;\n}\n\nexport interface Token {\n /** Normalization target: identifiers to anonymize, literal kind tags, or the raw token text. */\n kind: 'id' | 'text';\n text: string;\n /**\n * Two INDEPENDENT hashes of `text` (djb2 and FNV-1a), precomputed so fingerprinting nested\n * regions never re-hashes a token. Feeding the same per-token hash to both fingerprint\n * accumulators would collapse the key to 32 effective bits: one djb2 collision between two\n * token texts would then equate whole regions.\n */\n textHash: number;\n textHash2: number;\n /** Hash pair of a value-carrying literal's value, folded into data-like region fingerprints. */\n literalHash?: number;\n literalHash2?: number;\n /**\n * True for verbatim-kept NAMES (member/callee/type names, named grammar leaves): together with\n * value-carrying literals these are the content-bearing tokens the near-miss content gate\n * counts. Keywords, operators, and punctuation come from unnamed nodes and stay false.\n */\n isName?: boolean;\n /** 0-based source rows the token occupies, so line coverage counts only matched-token lines. */\n startRow: number;\n endRow: number;\n}\n\n/** A token span with its source position; plain data so cross-file matching can retain it. */\nexport interface TokenRange {\n startTokenIndex: number;\n endTokenIndex: number;\n startIndex: number;\n endIndex: number;\n startLine: number;\n endLine: number;\n}\n\n/** A contiguous run of matched tokens; gapped (merged) duplicates carry several per occurrence. */\ninterface TokenSegment {\n startTokenIndex: number;\n endTokenIndex: number;\n}\n\ninterface DuplicateCandidate {\n fingerprint: string;\n tokenCount: number;\n startTokenIndex: number;\n endTokenIndex: number;\n startIndex: number;\n endIndex: number;\n startLine: number;\n endLine: number;\n}\n\nexport interface CountedOccurrence {\n /** Matched token runs; more than one once gapped groups are merged. */\n segments: TokenSegment[];\n /**\n * Set on a retained group's occurrences that a partial gapped merge also paired into a merged\n * group: their spans are counted there, so block counting must not count them again.\n */\n sharedWithMergedGroup?: boolean;\n /** Sum of segment token counts (the gap tokens are not matched content). */\n tokenCount: number;\n startTokenIndex: number;\n endTokenIndex: number;\n startIndex: number;\n endIndex: number;\n startLine: number;\n endLine: number;\n}\n\n/** A duplicate region found in one file, exported for cross-file matching by fingerprint. */\nexport interface CrossFileDuplicateCandidate {\n /** Content key: equal fingerprints mean equal normalized token sequences (up to hash collision). */\n fingerprint: string;\n tokenCount: number;\n /** Token positions within the owning file, for cross-file gapped (Type-3) merging. */\n startTokenIndex: number;\n endTokenIndex: number;\n startIndex: number;\n endIndex: number;\n startLine: number;\n endLine: number;\n}\n\n/**\n * One file's contribution to cross-file clone detection: catalogued candidates plus the normalized\n * token stream and statement structure, so the project-level pass can match partial statement runs\n * (windows that a single file cannot know repeat elsewhere) and merge gap-adjacent groups.\n */\nexport interface CrossFileDuplicationFileData {\n candidates: CrossFileDuplicateCandidate[];\n tokens: Token[];\n containerStatements: TokenRange[][];\n /**\n * 1-based lines that are neither blank nor comment-only, so cross-file line coverage counts only\n * code lines (blank rows inside multi-row tokens such as template literals carry no content).\n * Optional for backward compatibility; without it, every matched-token row counts.\n */\n codeLineNumbers?: Set<number>;\n}\n\n/** literalCountPrefix[i] = value-carrying literal tokens in tokens[0..i), for O(1) density checks. */\nexport function buildLiteralCountPrefix(tokens: Token[]): Int32Array {\n const prefix = new Int32Array(tokens.length + 1);\n for (const [index, token] of tokens.entries()) {\n prefix[index + 1] = (prefix[index] ?? 0) + (token.literalHash === undefined ? 0 : 1);\n }\n return prefix;\n}\n\ninterface WindowOccurrences {\n count: number;\n /** -1 once occurrences span more than one container. */\n containerIndex: number;\n /** -1 once occurrences span more than one context (file). */\n contextIndex: number;\n minStart: number;\n maxStart: number;\n}\n\ninterface SequenceWindow {\n containerIndex: number;\n start: number;\n length: number;\n}\n\n/** One file's token stream and statement containers, as a window-matching context. */\nexport interface SequenceWindowContext {\n tokens: Token[];\n literalCountPrefix: Int32Array;\n containers: TokenRange[][];\n}\n\nexport interface ContextualSequenceCandidate {\n candidate: DuplicateCandidate;\n contextIndex: number;\n}\n\n/**\n * Enumerates runs of consecutive sibling statements over one or more contexts (files). Every\n * container statement participates; only the window length is capped, so enumeration stays linear\n * in the statement count. Windows are grouped by a cheap rolling hash of per-statement\n * fingerprints, and only locally maximal repeated windows — those whose one-statement extensions\n * stop repeating — become candidates with an exact (window-consistent) fingerprint. Without the\n * maximality filter a degenerate file of near-identical statements would fingerprint every\n * sub-window of every repeated region. With `requireMultipleContexts` a window only counts as\n * repeated when its occurrences span at least two contexts (CPD-style cross-file matching): a\n * repeat confined to one file is that file's own concern, and emitting it here would flood the\n * project-level selection with unusable single-file groups.\n */\nexport function collectSequenceWindowCandidates(\n contexts: SequenceWindowContext[],\n minTokens: number,\n requireMultipleContexts: boolean\n): ContextualSequenceCandidate[] {\n const candidates: ContextualSequenceCandidate[] = [];\n const contextIndexByContainer: number[] = [];\n const containers: TokenRange[][] = [];\n for (const [contextIndex, context] of contexts.entries()) {\n for (const statements of context.containers) {\n contextIndexByContainer.push(contextIndex);\n containers.push(statements);\n }\n }\n const contextAt = (containerIndex: number): SequenceWindowContext | undefined =>\n contexts[contextIndexByContainer[containerIndex] ?? 0];\n const occurrencesByWindowKey = new Map<number, WindowOccurrences>();\n const containerWindows = containers.map((statements, containerIndex) =>\n enumerateContainerWindows(contextAt(containerIndex)?.tokens ?? [], statements, minTokens)\n );\n for (const [containerIndex, windows] of containerWindows.entries()) {\n const contextIndex = contextIndexByContainer[containerIndex] ?? 0;\n for (const [start, row] of windows.windowKeysByStart.entries()) {\n for (const windowKey of row) {\n if (windowKey === undefined) {\n continue;\n }\n const occurrences = occurrencesByWindowKey.get(windowKey);\n if (occurrences) {\n occurrences.count += 1;\n if (occurrences.containerIndex !== containerIndex) {\n occurrences.containerIndex = -1;\n }\n if (occurrences.contextIndex !== contextIndex) {\n occurrences.contextIndex = -1;\n }\n occurrences.minStart = Math.min(occurrences.minStart, start);\n occurrences.maxStart = Math.max(occurrences.maxStart, start);\n } else {\n occurrencesByWindowKey.set(windowKey, {\n count: 1,\n containerIndex,\n contextIndex,\n minStart: start,\n maxStart: start,\n });\n }\n }\n }\n }\n\n // A window only \"repeats\" when two of its occurrences can coexist without overlapping: sliding\n // matches inside a homogeneous run (start spread smaller than the window length) can never both\n // be counted and must neither qualify a window nor dominate its sub-windows. Cross-context\n // matching instead requires occurrences in two contexts, which coexist by construction.\n const repeats = (windowKey: number | undefined, length: number): boolean => {\n if (windowKey === undefined) {\n return false;\n }\n const occurrences = occurrencesByWindowKey.get(windowKey);\n if (occurrences === undefined || occurrences.count < 2) {\n return false;\n }\n if (requireMultipleContexts) {\n return occurrences.contextIndex === -1;\n }\n return occurrences.containerIndex === -1 || occurrences.maxStart - occurrences.minStart >= length;\n };\n\n // A window whose statements all share one normalized shape (sixteen `let x = 0;` declarations,\n // a constant table) is a homogeneous preamble, not a copy-paste: requiring two distinct\n // per-statement shapes keeps such runs out of duplicate groups and the duplication ratio.\n const hasDistinctStatements = (window: SequenceWindow): boolean => {\n const hashes = containerWindows[window.containerIndex]?.statementHashes ?? [];\n const firstHash = hashes[window.start];\n for (let index = window.start + 1; index < window.start + window.length; index += 1) {\n if (hashes[index] !== firstHash) {\n return true;\n }\n }\n return false;\n };\n\n const maximalWindows: SequenceWindow[] = [];\n for (const [containerIndex, windows] of containerWindows.entries()) {\n for (const [start, row] of windows.windowKeysByStart.entries()) {\n for (const [length, windowKey] of row.entries()) {\n if (!repeats(windowKey, length) || !hasDistinctStatements({ containerIndex, start, length })) {\n continue;\n }\n // Dominated windows are skipped: the one-statement extension also repeats, so a larger\n // candidate covering this window exists.\n const extendedRight = windows.windowKeysByStart[start]?.[length + 1];\n const extendedLeft = windows.windowKeysByStart[start - 1]?.[length + 1];\n if (repeats(extendedRight, length + 1) || repeats(extendedLeft, length + 1)) {\n continue;\n }\n maximalWindows.push({ containerIndex, start, length });\n }\n }\n }\n\n // The rolling hash anonymizes identifiers per statement, so a window can look repeated coarsely\n // while its exact (window-consistent) fingerprints differ, and a longer window's match can\n // dominate sub-windows that other copies still need (three copies where only two extend one\n // statement further). Every emitted window therefore exposes its repeating, unvisited\n // sub-windows; `visited` bounds the worklist and lengths strictly decrease, so it terminates.\n const visited = new Set(maximalWindows.map(windowId));\n let frontier = maximalWindows;\n while (frontier.length > 0) {\n const emitted: SequenceWindow[] = [];\n for (const window of frontier) {\n const statements = containers[window.containerIndex];\n const first = statements?.[window.start];\n const last = statements?.[window.start + window.length - 1];\n const context = contextAt(window.containerIndex);\n if (!first || !last || !context) {\n continue;\n }\n const fingerprint = `s:${fingerprintKey(context.tokens, context.literalCountPrefix, first.startTokenIndex, last.endTokenIndex)}`;\n candidates.push({\n candidate: toCandidate(fingerprint, first.startTokenIndex, last.endTokenIndex, first, last),\n contextIndex: contextIndexByContainer[window.containerIndex] ?? 0,\n });\n emitted.push(window);\n }\n frontier = [];\n for (const window of emitted) {\n for (const start of [window.start, window.start + 1]) {\n const subWindow = { containerIndex: window.containerIndex, start, length: window.length - 1 };\n const subWindowKey = containerWindows[window.containerIndex]?.windowKeysByStart[start]?.[subWindow.length];\n if (\n visited.has(windowId(subWindow)) ||\n !repeats(subWindowKey, subWindow.length) ||\n !hasDistinctStatements(subWindow)\n ) {\n continue;\n }\n visited.add(windowId(subWindow));\n frontier.push(subWindow);\n }\n }\n }\n return candidates;\n}\n\nfunction windowId(window: SequenceWindow): string {\n return `${window.containerIndex}:${window.start}:${window.length}`;\n}\n\ninterface ContainerWindows {\n /** windowKeysByStart[start][length] is the rolling-hash key of the window, or undefined if it is below the size thresholds. */\n windowKeysByStart: (number | undefined)[][];\n /** Per-statement fingerprint hashes, for the distinct-shape requirement on windows. */\n statementHashes: number[];\n}\n\nfunction enumerateContainerWindows(tokens: Token[], statements: TokenRange[], minTokens: number): ContainerWindows {\n const statementHashes = statements.map((statement) =>\n fingerprintHash(tokens, statement.startTokenIndex, statement.endTokenIndex)\n );\n const windowKeysByStart: (number | undefined)[][] = [];\n for (let start = 0; start < statements.length; start += 1) {\n const row: (number | undefined)[] = [];\n let hash = 5381;\n let tokenCount = 0;\n const maxEnd = Math.min(statements.length, start + maxSequenceStatementCount);\n for (let end = start; end < maxEnd; end += 1) {\n const statement = statements[end];\n const statementHash = statementHashes[end];\n if (!statement || statementHash === undefined) {\n break;\n }\n hash = combineHashes(hash, statementHash);\n tokenCount += statement.endTokenIndex - statement.startTokenIndex;\n const statementCount = end - start + 1;\n row[statementCount] =\n statementCount >= minSequenceStatementCount && tokenCount >= minTokens\n ? combineHashes(hash, statementCount)\n : undefined;\n }\n windowKeysByStart.push(row);\n }\n return { windowKeysByStart, statementHashes };\n}\n\n/**\n * Longest-common-subsequence LENGTH of two symbol sequences via the Allison–Dix bit-parallel\n * recurrence (O(|a|/32 · |b|) words): per symbol of `b`, `x = match | v` and\n * `v = x & ~(x - ((v << 1) | 1))` over multi-word bit vectors; the set bits of `v` count the LCS.\n */\nexport function lcsLength(a: Int32Array, b: Int32Array): number {\n const wordCount = (a.length + 31) >>> 5;\n const positionMasks = new Map<number, Uint32Array>();\n for (const [index, symbol] of a.entries()) {\n let mask = positionMasks.get(symbol);\n if (!mask) {\n mask = new Uint32Array(wordCount);\n positionMasks.set(symbol, mask);\n }\n const word = index >>> 5;\n // The Uint32Array store wraps the signed int32 bit pattern to unsigned. The `?? 0` guards in\n // this function are required by noUncheckedIndexedAccess (typed-array reads type as\n // `number | undefined`), not redundancy: every index is in bounds.\n mask[word] = (mask[word] ?? 0) | (1 << (index & 31));\n }\n\n const v = new Uint32Array(wordCount);\n for (const symbol of b) {\n const matchMask = positionMasks.get(symbol);\n // `(v << 1) | 1` shifts a carry bit across words; subtraction borrows across words.\n let shiftCarry = 1;\n let borrow = 0;\n for (let word = 0; word < wordCount; word += 1) {\n const previous = v[word] ?? 0;\n // oxlint-disable-next-line unicorn/prefer-math-trunc -- `>>> 0` reinterprets the signed int32 bit pattern as unsigned so the borrow subtraction below compares magnitudes; Math.trunc would keep it negative.\n const x = ((matchMask?.[word] ?? 0) | previous) >>> 0;\n // oxlint-disable-next-line unicorn/prefer-math-trunc -- same unsigned reinterpretation as `x`.\n const shifted = ((previous << 1) | shiftCarry) >>> 0;\n shiftCarry = previous >>> 31;\n const difference = x - shifted - borrow;\n borrow = difference < 0 ? 1 : 0;\n // The Uint32Array store wraps the signed int32 bit pattern to unsigned.\n v[word] = x & ~difference;\n }\n }\n\n let length = 0;\n for (const word of v) {\n length += popCount(word);\n }\n return length;\n}\n\nfunction popCount(value: number): number {\n let count = value - ((value >>> 1) & 0x55_55_55_55);\n count = (count & 0x33_33_33_33) + ((count >>> 2) & 0x33_33_33_33);\n return (Math.imul((count + (count >>> 4)) & 0x0F_0F_0F_0F, 0x01_01_01_01) >>> 24) & 0xFF;\n}\n\nfunction toCandidate(\n fingerprint: string,\n startTokenIndex: number,\n endTokenIndex: number,\n first: TokenRange,\n last: TokenRange\n): DuplicateCandidate {\n return {\n fingerprint,\n tokenCount: endTokenIndex - startTokenIndex,\n startTokenIndex,\n endTokenIndex,\n startIndex: first.startIndex,\n endIndex: last.endIndex,\n startLine: first.startLine,\n endLine: last.endLine,\n };\n}\n\n/** Caches of hashText/hashText2 over '$0', '$1', ... so anonymized identifiers hash without allocating. */\nconst anonymizedIndexHashes: number[] = [];\nconst anonymizedIndexHashes2: number[] = [];\n\nfunction anonymizedIndexHash(index: number): number {\n let hash = anonymizedIndexHashes[index];\n if (hash === undefined) {\n hash = hashText(`$${index}`);\n anonymizedIndexHashes[index] = hash;\n }\n return hash;\n}\n\nfunction anonymizedIndexHash2(index: number): number {\n let hash = anonymizedIndexHashes2[index];\n if (hash === undefined) {\n hash = hashText2(`$${index}`);\n anonymizedIndexHashes2[index] = hash;\n }\n return hash;\n}\n\n/**\n * Content key of a token range: two independent 32-bit hashes over the normalized token sequence\n * (identifiers anonymized consistently by first-occurrence order) plus the token count. Regions\n * with equal keys are treated as equal content; a collision would need both 32-bit hashes and the\n * length to coincide, which is negligible for a metrics report. The format and arithmetic match\n * fingerprint_key in native/src/duplication.rs exactly, so window candidates fingerprinted here\n * group together with the per-file candidates the addon catalogues.\n */\nfunction fingerprintKey(\n tokens: Token[],\n literalCountPrefix: Int32Array,\n startTokenIndex: number,\n endTokenIndex: number\n): string {\n const literalCount = (literalCountPrefix[endTokenIndex] ?? 0) - (literalCountPrefix[startTokenIndex] ?? 0);\n const literalDense = isLiteralDense(literalCount, endTokenIndex - startTokenIndex);\n const [primary, secondary] = fingerprintHashPair(tokens, startTokenIndex, endTokenIndex, literalDense);\n return `${primary}:${secondary}:${endTokenIndex - startTokenIndex}`;\n}\n\n/**\n * A single 32-bit summary of a range for the coarse rolling-hash phase. Deliberately\n * density-agnostic: density is a property of the final candidate REGION, and folding literal\n * values into per-statement hashes would make a dense statement inside a logic-heavy window\n * (`const weights = [1, 2, 3];`) block the window from ever being enumerated. The coarse phase\n * over-approximates on shape alone; the exact region fingerprint still applies the density rule.\n */\nfunction fingerprintHash(tokens: Token[], startTokenIndex: number, endTokenIndex: number): number {\n const [primary, secondary] = fingerprintHashPair(tokens, startTokenIndex, endTokenIndex, false);\n // XOR already coerces to int32, matching the native side's i32 arithmetic.\n return primary ^ Math.imul(secondary, 31);\n}\n\nfunction fingerprintHashPair(\n tokens: Token[],\n startTokenIndex: number,\n endTokenIndex: number,\n foldLiteralValues: boolean\n): [number, number] {\n const indexByIdentifier = new Map<string, number>();\n let primary = 5381;\n let secondary = 52_711;\n for (let index = startTokenIndex; index < endTokenIndex; index += 1) {\n const token = tokens[index];\n if (!token) {\n continue;\n }\n // Each accumulator consumes its own independent per-token hash: sharing one would collapse\n // the key to 32 effective bits (a single djb2 collision would equate whole regions).\n let part: number;\n let part2: number;\n if (token.kind === 'id') {\n let identifierIndex = indexByIdentifier.get(token.text);\n if (identifierIndex === undefined) {\n identifierIndex = indexByIdentifier.size;\n indexByIdentifier.set(token.text, identifierIndex);\n }\n part = anonymizedIndexHash(identifierIndex);\n part2 = anonymizedIndexHash2(identifierIndex);\n } else {\n part = token.textHash;\n part2 = token.textHash2;\n }\n // oxlint-disable-next-line unicorn/prefer-math-trunc -- `| 0` wraps the sum to int32 (Math.trunc does not), which must match the native side's wrapping i32 arithmetic.\n primary = (Math.imul(primary, 31) + part) | 0;\n secondary = Math.imul(secondary, 37) ^ part2;\n if (foldLiteralValues && token.literalHash !== undefined && token.literalHash2 !== undefined) {\n // oxlint-disable-next-line unicorn/prefer-math-trunc -- `| 0` wraps the sum to int32 (Math.trunc does not), which must match the native side's wrapping i32 arithmetic.\n primary = (Math.imul(primary, 31) + token.literalHash) | 0;\n secondary = Math.imul(secondary, 37) ^ token.literalHash2;\n }\n }\n return [primary, secondary];\n}\n\n/** djb2-style hash; XOR keeps the value in signed 32-bit range, which is fine for a grouping key. */\nfunction hashText(text: string): number {\n let hash = 5381;\n for (let index = 0; index < text.length; index += 1) {\n // oxlint-disable-next-line unicorn/prefer-code-point -- djb2 hashes UTF-16 code units; codePointAt would hash surrogate pairs twice (full code point, then the lone low surrogate).\n hash = Math.imul(hash, 33) ^ text.charCodeAt(index);\n }\n return hash;\n}\n\n/** FNV-1a over UTF-16 code units: independent of hashText so the two accumulators never share input. */\nfunction hashText2(text: string): number {\n let hash = -2_128_831_035; // 2166136261 as int32 (the FNV-1a offset basis)\n for (let index = 0; index < text.length; index += 1) {\n // oxlint-disable-next-line unicorn/prefer-code-point -- hashes UTF-16 code units like hashText.\n hash = Math.imul(hash ^ text.charCodeAt(index), 16_777_619);\n }\n return hash;\n}\n\nfunction combineHashes(hash: number, value: number): number {\n return Math.imul(hash, 31) + value;\n}\n\n/**\n * Merges duplicate groups separated by a small token gap into one gapped (Type-3) clone group: a\n * copy edited in one spot splits into two exact groups whose occurrences sit side by side in the\n * same order. Occurrences are paired greedily in source order; a merge happens when the pairing\n * fully consumes at least one group with at least two pairs. Equal-cardinality groups whose\n * occurrences all pair merge into one group as before. When cardinalities differ (a fragment also\n * occurs standalone: prefix ×3, suffix ×2), the fully-paired group is subsumed into the merged\n * gapped group while the other group is RETAINED with ALL its occurrences: dropping the leftover\n * would lose duplicated-line coverage, and reporting it alone would make a single-occurrence group\n * (contradicting duplicateBlockGroupCount's \"appears more than once\" meaning). Line coverage\n * unions ranges, so the overlap between the retained exact group and the merged group is harmless.\n * Merging repeats to a fixpoint so a clone edited in several spots still reassembles; it\n * terminates because a full merge shrinks the group count and a partial merge keeps it while\n * strictly growing the bounded total span of merged occurrences. Gap tokens are not matched\n * content: line coverage and sizes count only the matched segments. Generic so cross-file merging\n * can thread file identity through occurrences.\n */\nexport function mergeAdjacentGroups<T extends CountedOccurrence>(groups: T[][], maxGapTokens: number): T[][] {\n if (maxGapTokens <= 0 || groups.length < 2) {\n return groups;\n }\n // Deterministic processing order (mirrored by the native side): by first occurrence position.\n groups.sort(compareGroups);\n for (let restart = true; restart;) {\n restart = false;\n for (let leftIndex = 0; leftIndex < groups.length && !restart; leftIndex += 1) {\n for (let rightIndex = leftIndex + 1; rightIndex < groups.length; rightIndex += 1) {\n const left = groups[leftIndex];\n const right = groups[rightIndex];\n if (!left || !right) {\n continue;\n }\n const forward = mergeGroups(left, right, maxGapTokens);\n const result = forward ?? mergeGroups(right, left, maxGapTokens);\n if (!result) {\n continue;\n }\n const leftConsumed = forward ? result.firstConsumed : result.secondConsumed;\n const rightConsumed = forward ? result.secondConsumed : result.firstConsumed;\n if (leftConsumed && rightConsumed) {\n groups[leftIndex] = result.merged;\n groups.splice(rightIndex, 1);\n } else if (rightConsumed) {\n groups[rightIndex] = result.merged;\n } else {\n groups[leftIndex] = result.merged;\n }\n // A partial merge retains the not-fully-consumed group with ALL its occurrences (line\n // coverage must not shrink, and a reported group must keep >= 2 occurrences), so its\n // paired occurrences now also live inside the merged group's occurrences: mark them so\n // duplicateBlockCount counts each token span once.\n for (const occurrence of result.pairedRetained) {\n occurrence.sharedWithMergedGroup = true;\n }\n groups.sort(compareGroups);\n restart = true;\n break;\n }\n }\n }\n return groups;\n}\n\nfunction compareGroups(left: CountedOccurrence[], right: CountedOccurrence[]): number {\n const leftFirst = left[0];\n const rightFirst = right[0];\n return (\n (leftFirst?.startTokenIndex ?? 0) - (rightFirst?.startTokenIndex ?? 0) ||\n (leftFirst?.endTokenIndex ?? 0) - (rightFirst?.endTokenIndex ?? 0)\n );\n}\n\ninterface MergeResult<T> {\n merged: T[];\n /** Whether every occurrence of the respective input group was paired into the merge. */\n firstConsumed: boolean;\n secondConsumed: boolean;\n /** The retained (not fully consumed) group's occurrences that were paired into the merge. */\n pairedRetained: T[];\n}\n\n/**\n * Pairs `second` occurrences with gap-preceding `first` occurrences, greedily in source order:\n * each trailing occurrence takes the earliest unused leading occurrence within the gap, and a\n * pair's leading must start at or after the previous pair's trailing end so merged spans never\n * overlap. A merge needs at least two pairs (a merged group must still mean \"appears more than\n * once\") and must fully consume at least one group; for equal cardinalities this reduces to the\n * strict all-pairs merge, so pre-partial-merge behavior is unchanged there.\n */\nfunction mergeGroups<T extends CountedOccurrence>(\n first: T[],\n second: T[],\n maxGapTokens: number\n): MergeResult<T> | undefined {\n // Occurrences a previous partial merge already paired into a merged group must not pair again:\n // their spans already live inside that merged group, so re-pairing them would assemble a second,\n // competing merged group instead of letting the existing merged group extend (and would count\n // the same span twice). Consumption is still judged against the FULL group, so a group holding\n // shared occurrences is never subsumed away.\n const leadings = first.filter((occurrence) => !occurrence.sharedWithMergedGroup);\n const trailings = second.filter((occurrence) => !occurrence.sharedWithMergedGroup);\n const pairs: [T, T][] = [];\n let leadingIndex = 0;\n let previousTrailingEnd = -1;\n for (const trailing of trailings) {\n // Leadings ending too far before this trailing can never pair a later (even farther) one.\n while (leadingIndex < leadings.length) {\n const leading = leadings[leadingIndex];\n if (leading && leading.endTokenIndex + maxGapTokens < trailing.startTokenIndex) {\n leadingIndex += 1;\n } else {\n break;\n }\n }\n const leading = leadings[leadingIndex];\n if (\n leading &&\n leading.endTokenIndex <= trailing.startTokenIndex &&\n leading.startTokenIndex >= previousTrailingEnd\n ) {\n pairs.push([leading, trailing]);\n previousTrailingEnd = trailing.endTokenIndex;\n leadingIndex += 1;\n }\n }\n const firstConsumed = pairs.length === first.length;\n const secondConsumed = pairs.length === second.length;\n if (pairs.length < 2 || (!firstConsumed && !secondConsumed)) {\n return undefined;\n }\n const merged = pairs.map(([leading, trailing]) => ({\n ...leading,\n // A merged occurrence is a fresh span combination; it never inherits shared-span marks.\n sharedWithMergedGroup: undefined,\n segments: [...leading.segments, ...trailing.segments],\n tokenCount: leading.tokenCount + trailing.tokenCount,\n endTokenIndex: trailing.endTokenIndex,\n endIndex: trailing.endIndex,\n endLine: trailing.endLine,\n }));\n const pairedRetained =\n firstConsumed === secondConsumed ? [] : pairs.map(([leading, trailing]) => (firstConsumed ? trailing : leading));\n return { merged, firstConsumed, secondConsumed, pairedRetained };\n}\n\n/**\n * Redundant copies one group adds to duplicateBlockCount. Each redundant occurrence contributes\n * one count per matched fragment, so merging a gapped clone's fragments into one group does not\n * halve duplicateBlockCount: an edited two-fragment pair still counts 2, exactly as its unmerged\n * fragments did. Occurrence shapes can differ within one group (a\n * gap-merged exact pair plus an appended whole-block near-miss copy), so every occurrence's\n * fragments are summed and one representative — the largest — is deducted, keeping the count\n * independent of source order. Occurrences a partial gapped merge also paired into a merged group\n * are skipped: their spans are already counted there, and such a retained group deducts no\n * representative of its own — the merged group's representative already stands for the shared\n * content — so no token span contributes to the count twice.\n */\nexport function countRedundantFragments(group: CountedOccurrence[]): number {\n let fragmentCount = 0;\n let maxFragmentCount = 0;\n let hasSharedOccurrence = false;\n for (const occurrence of group) {\n if (occurrence.sharedWithMergedGroup) {\n hasSharedOccurrence = true;\n continue;\n }\n fragmentCount += occurrence.segments.length;\n maxFragmentCount = Math.max(maxFragmentCount, occurrence.segments.length);\n }\n return hasSharedOccurrence ? fragmentCount : fragmentCount - maxFragmentCount;\n}\n\n/** Adds the 1-based code lines the segment's matched tokens cover; shared with cross-file coverage. */\nexport function collectSegmentLines(\n segment: { startTokenIndex: number; endTokenIndex: number },\n tokens: Token[],\n codeLineNumbers: Set<number> | undefined,\n duplicatedLines: Set<number>\n): void {\n for (let index = segment.startTokenIndex; index < segment.endTokenIndex; index += 1) {\n const token = tokens[index];\n for (let row = token?.startRow ?? 0; row <= (token?.endRow ?? -1); row += 1) {\n if (!codeLineNumbers || codeLineNumbers.has(row + 1)) {\n duplicatedLines.add(row + 1);\n }\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"duplication.cjs","names":[],"sources":["../src/duplication.ts"],"sourcesContent":["import type { DuplicationOptions } from './types.js';\n\n/**\n * Project-level duplication machinery operating on normalized token streams. Tokenization itself\n * (parsing, identifier anonymization, literal normalization) happens in the Rust addon, which\n * serializes each file's Token stream and statement structure; the helpers here match statement\n * windows across files, merge gap-adjacent groups, and count duplicated lines over that data.\n */\n\nexport const defaultDuplicationOptions: Required<DuplicationOptions> = {\n minTokens: 40,\n maxGapTokens: 30,\n minSimilarityPercent: 70,\n};\n\n/**\n * Fills defaults for absent settings, applying the same normalization as the native boundary's\n * clampToU32 — NaN (e.g. `Number(unsetEnvVariable)`) counts as absent, and other values truncate\n * and clamp to [0, u32::MAX] — so the TypeScript half of cross-file matching cannot diverge from\n * the natively collected candidates on such input.\n */\nexport function resolveDuplicationOptions(options?: DuplicationOptions): Required<DuplicationOptions> {\n return {\n minTokens: resolveOption(options?.minTokens, defaultDuplicationOptions.minTokens),\n maxGapTokens: resolveOption(options?.maxGapTokens, defaultDuplicationOptions.maxGapTokens),\n minSimilarityPercent: resolveOption(options?.minSimilarityPercent, defaultDuplicationOptions.minSimilarityPercent),\n };\n}\n\nfunction resolveOption(value: number | undefined, fallback: number): number {\n return value === undefined || Number.isNaN(value)\n ? fallback\n : Math.min(Math.max(Math.trunc(value), 0), 0xFF_FF_FF_FF);\n}\n\n/** Minimum consecutive statements for a statement-sequence duplicate candidate. */\nconst minSequenceStatementCount = 2;\n/**\n * Caps the window length so statement-sequence enumeration stays linear in the statement count.\n * Heterogeneous clones longer than the cap are reported as capped windows (a deliberate\n * conservative undercount trading completeness for bounded discovery cost).\n */\nconst maxSequenceStatementCount = 100;\n\n/**\n * A region whose normalized tokens are at least 20% literal values is data-like (a lookup table, a\n * constant list, a value-mapping switch), not logic: literal values re-enter its fingerprint so\n * tables that merely share their shape stop counting as copy-paste. Compared in integer math\n * (5 * literals >= total) so the TypeScript and native sides cannot disagree on the boundary.\n */\nfunction isLiteralDense(literalCount: number, tokenCount: number): boolean {\n return literalCount * 5 >= tokenCount;\n}\n\nexport interface Token {\n /** Normalization target: identifiers to anonymize, literal kind tags, or the raw token text. */\n kind: 'id' | 'text';\n text: string;\n /**\n * Two INDEPENDENT hashes of `text` (djb2 and FNV-1a), precomputed so fingerprinting nested\n * regions never re-hashes a token. Feeding the same per-token hash to both fingerprint\n * accumulators would collapse the key to 32 effective bits: one djb2 collision between two\n * token texts would then equate whole regions.\n */\n textHash: number;\n textHash2: number;\n /** Hash pair of a value-carrying literal's value, folded into data-like region fingerprints. */\n literalHash?: number;\n literalHash2?: number;\n /**\n * True for verbatim-kept NAMES (member/callee/type names, named grammar leaves): together with\n * value-carrying literals these are the content-bearing tokens the near-miss content gate\n * counts. Keywords, operators, and punctuation come from unnamed nodes and stay false.\n */\n isName?: boolean;\n /** 0-based source rows the token occupies, so line coverage counts only matched-token lines. */\n startRow: number;\n endRow: number;\n}\n\n/** A token span with its source position; plain data so cross-file matching can retain it. */\nexport interface TokenRange {\n startTokenIndex: number;\n endTokenIndex: number;\n startIndex: number;\n endIndex: number;\n startLine: number;\n endLine: number;\n}\n\n/** A contiguous run of matched tokens; gapped (merged) duplicates carry several per occurrence. */\ninterface TokenSegment {\n startTokenIndex: number;\n endTokenIndex: number;\n}\n\ninterface DuplicateCandidate {\n fingerprint: string;\n tokenCount: number;\n startTokenIndex: number;\n endTokenIndex: number;\n startIndex: number;\n endIndex: number;\n startLine: number;\n endLine: number;\n}\n\nexport interface CountedOccurrence {\n /** Matched token runs; more than one once gapped groups are merged. */\n segments: TokenSegment[];\n /**\n * Set on occurrences whose span another reported group already counts: a retained group's\n * occurrences that a partial gapped merge also paired into a merged group, and cross-file copies\n * nested inside a larger group's region. Block counting must not count them again.\n */\n spanCountedElsewhere?: boolean;\n /**\n * Set on cross-file copies nested inside a larger group's region (they also set\n * `spanCountedElsewhere`). They never pair in gapped merging, and they do not keep their group's\n * standalone copies from merging, which they are not copies of. They do keep the merged group\n * from taking their group's place, since only the original group reports the nesting.\n */\n nestedInLargerGroup?: boolean;\n /** Sum of segment token counts (the gap tokens are not matched content). */\n tokenCount: number;\n startTokenIndex: number;\n endTokenIndex: number;\n startIndex: number;\n endIndex: number;\n startLine: number;\n endLine: number;\n}\n\n/** A duplicate region found in one file, exported for cross-file matching by fingerprint. */\nexport interface CrossFileDuplicateCandidate {\n /** Content key: equal fingerprints mean equal normalized token sequences (up to hash collision). */\n fingerprint: string;\n tokenCount: number;\n /** Token positions within the owning file, for cross-file gapped (Type-3) merging. */\n startTokenIndex: number;\n endTokenIndex: number;\n startIndex: number;\n endIndex: number;\n startLine: number;\n endLine: number;\n}\n\n/**\n * One file's contribution to cross-file clone detection: catalogued candidates plus the normalized\n * token stream and statement structure, so the project-level pass can match partial statement runs\n * (windows that a single file cannot know repeat elsewhere) and merge gap-adjacent groups.\n */\nexport interface CrossFileDuplicationFileData {\n candidates: CrossFileDuplicateCandidate[];\n tokens: Token[];\n containerStatements: TokenRange[][];\n /**\n * 1-based lines that are neither blank nor comment-only, so cross-file line coverage counts only\n * code lines (blank rows inside multi-row tokens such as template literals carry no content).\n * Optional for backward compatibility; without it, every matched-token row counts.\n */\n codeLineNumbers?: Set<number>;\n}\n\n/** literalCountPrefix[i] = value-carrying literal tokens in tokens[0..i), for O(1) density checks. */\nexport function buildLiteralCountPrefix(tokens: Token[]): Int32Array {\n const prefix = new Int32Array(tokens.length + 1);\n for (const [index, token] of tokens.entries()) {\n prefix[index + 1] = (prefix[index] ?? 0) + (token.literalHash === undefined ? 0 : 1);\n }\n return prefix;\n}\n\ninterface WindowOccurrences {\n count: number;\n /** -1 once occurrences span more than one container. */\n containerIndex: number;\n /** -1 once occurrences span more than one context (file). */\n contextIndex: number;\n minStart: number;\n maxStart: number;\n}\n\ninterface SequenceWindow {\n containerIndex: number;\n start: number;\n length: number;\n}\n\n/** One file's token stream and statement containers, as a window-matching context. */\nexport interface SequenceWindowContext {\n tokens: Token[];\n literalCountPrefix: Int32Array;\n containers: TokenRange[][];\n}\n\nexport interface ContextualSequenceCandidate {\n candidate: DuplicateCandidate;\n contextIndex: number;\n}\n\n/**\n * Enumerates runs of consecutive sibling statements over one or more contexts (files). Every\n * container statement participates; only the window length is capped, so enumeration stays linear\n * in the statement count. Windows are grouped by a cheap rolling hash of per-statement\n * fingerprints, and only locally maximal repeated windows — those whose one-statement extensions\n * stop repeating — become candidates with an exact (window-consistent) fingerprint. Without the\n * maximality filter a degenerate file of near-identical statements would fingerprint every\n * sub-window of every repeated region. With `requireMultipleContexts` a window only counts as\n * repeated when its occurrences span at least two contexts (CPD-style cross-file matching): a\n * repeat confined to one file is that file's own concern, and emitting it here would flood the\n * project-level selection with unusable single-file groups.\n */\nexport function collectSequenceWindowCandidates(\n contexts: SequenceWindowContext[],\n minTokens: number,\n requireMultipleContexts: boolean\n): ContextualSequenceCandidate[] {\n const candidates: ContextualSequenceCandidate[] = [];\n const contextIndexByContainer: number[] = [];\n const containers: TokenRange[][] = [];\n for (const [contextIndex, context] of contexts.entries()) {\n for (const statements of context.containers) {\n contextIndexByContainer.push(contextIndex);\n containers.push(statements);\n }\n }\n const contextAt = (containerIndex: number): SequenceWindowContext | undefined =>\n contexts[contextIndexByContainer[containerIndex] ?? 0];\n const occurrencesByWindowKey = new Map<number, WindowOccurrences>();\n const containerWindows = containers.map((statements, containerIndex) =>\n enumerateContainerWindows(contextAt(containerIndex)?.tokens ?? [], statements, minTokens)\n );\n for (const [containerIndex, windows] of containerWindows.entries()) {\n const contextIndex = contextIndexByContainer[containerIndex] ?? 0;\n for (const [start, row] of windows.windowKeysByStart.entries()) {\n for (const windowKey of row) {\n if (windowKey === undefined) {\n continue;\n }\n const occurrences = occurrencesByWindowKey.get(windowKey);\n if (occurrences) {\n occurrences.count += 1;\n if (occurrences.containerIndex !== containerIndex) {\n occurrences.containerIndex = -1;\n }\n if (occurrences.contextIndex !== contextIndex) {\n occurrences.contextIndex = -1;\n }\n occurrences.minStart = Math.min(occurrences.minStart, start);\n occurrences.maxStart = Math.max(occurrences.maxStart, start);\n } else {\n occurrencesByWindowKey.set(windowKey, {\n count: 1,\n containerIndex,\n contextIndex,\n minStart: start,\n maxStart: start,\n });\n }\n }\n }\n }\n\n // A window only \"repeats\" when two of its occurrences can coexist without overlapping: sliding\n // matches inside a homogeneous run (start spread smaller than the window length) can never both\n // be counted and must neither qualify a window nor dominate its sub-windows. Cross-context\n // matching instead requires occurrences in two contexts, which coexist by construction.\n const repeats = (windowKey: number | undefined, length: number): boolean => {\n if (windowKey === undefined) {\n return false;\n }\n const occurrences = occurrencesByWindowKey.get(windowKey);\n if (occurrences === undefined || occurrences.count < 2) {\n return false;\n }\n if (requireMultipleContexts) {\n return occurrences.contextIndex === -1;\n }\n return occurrences.containerIndex === -1 || occurrences.maxStart - occurrences.minStart >= length;\n };\n\n // A window whose statements all share one normalized shape (sixteen `let x = 0;` declarations,\n // a constant table) is a homogeneous preamble, not a copy-paste: requiring two distinct\n // per-statement shapes keeps such runs out of duplicate groups and the duplication ratio.\n const hasDistinctStatements = (window: SequenceWindow): boolean => {\n const hashes = containerWindows[window.containerIndex]?.statementHashes ?? [];\n const firstHash = hashes[window.start];\n for (let index = window.start + 1; index < window.start + window.length; index += 1) {\n if (hashes[index] !== firstHash) {\n return true;\n }\n }\n return false;\n };\n\n const maximalWindows: SequenceWindow[] = [];\n for (const [containerIndex, windows] of containerWindows.entries()) {\n for (const [start, row] of windows.windowKeysByStart.entries()) {\n for (const [length, windowKey] of row.entries()) {\n if (!repeats(windowKey, length) || !hasDistinctStatements({ containerIndex, start, length })) {\n continue;\n }\n // Dominated windows are skipped: the one-statement extension also repeats, so a larger\n // candidate covering this window exists.\n const extendedRight = windows.windowKeysByStart[start]?.[length + 1];\n const extendedLeft = windows.windowKeysByStart[start - 1]?.[length + 1];\n if (repeats(extendedRight, length + 1) || repeats(extendedLeft, length + 1)) {\n continue;\n }\n maximalWindows.push({ containerIndex, start, length });\n }\n }\n }\n\n // The rolling hash anonymizes identifiers per statement, so a window can look repeated coarsely\n // while its exact (window-consistent) fingerprints differ, and a longer window's match can\n // dominate sub-windows that other copies still need (three copies where only two extend one\n // statement further). Every emitted window therefore exposes its repeating, unvisited\n // sub-windows; `visited` bounds the worklist and lengths strictly decrease, so it terminates.\n const visited = new Set(maximalWindows.map(windowId));\n let frontier = maximalWindows;\n while (frontier.length > 0) {\n const emitted: SequenceWindow[] = [];\n for (const window of frontier) {\n const statements = containers[window.containerIndex];\n const first = statements?.[window.start];\n const last = statements?.[window.start + window.length - 1];\n const context = contextAt(window.containerIndex);\n if (!first || !last || !context) {\n continue;\n }\n const fingerprint = `s:${fingerprintKey(context.tokens, context.literalCountPrefix, first.startTokenIndex, last.endTokenIndex)}`;\n candidates.push({\n candidate: toCandidate(fingerprint, first.startTokenIndex, last.endTokenIndex, first, last),\n contextIndex: contextIndexByContainer[window.containerIndex] ?? 0,\n });\n emitted.push(window);\n }\n frontier = [];\n for (const window of emitted) {\n for (const start of [window.start, window.start + 1]) {\n const subWindow = { containerIndex: window.containerIndex, start, length: window.length - 1 };\n const subWindowKey = containerWindows[window.containerIndex]?.windowKeysByStart[start]?.[subWindow.length];\n if (\n visited.has(windowId(subWindow)) ||\n !repeats(subWindowKey, subWindow.length) ||\n !hasDistinctStatements(subWindow)\n ) {\n continue;\n }\n visited.add(windowId(subWindow));\n frontier.push(subWindow);\n }\n }\n }\n return candidates;\n}\n\nfunction windowId(window: SequenceWindow): string {\n return `${window.containerIndex}:${window.start}:${window.length}`;\n}\n\ninterface ContainerWindows {\n /** windowKeysByStart[start][length] is the rolling-hash key of the window, or undefined if it is below the size thresholds. */\n windowKeysByStart: (number | undefined)[][];\n /** Per-statement fingerprint hashes, for the distinct-shape requirement on windows. */\n statementHashes: number[];\n}\n\nfunction enumerateContainerWindows(tokens: Token[], statements: TokenRange[], minTokens: number): ContainerWindows {\n const statementHashes = statements.map((statement) =>\n fingerprintHash(tokens, statement.startTokenIndex, statement.endTokenIndex)\n );\n const windowKeysByStart: (number | undefined)[][] = [];\n for (let start = 0; start < statements.length; start += 1) {\n const row: (number | undefined)[] = [];\n let hash = 5381;\n let tokenCount = 0;\n const maxEnd = Math.min(statements.length, start + maxSequenceStatementCount);\n for (let end = start; end < maxEnd; end += 1) {\n const statement = statements[end];\n const statementHash = statementHashes[end];\n if (!statement || statementHash === undefined) {\n break;\n }\n hash = combineHashes(hash, statementHash);\n tokenCount += statement.endTokenIndex - statement.startTokenIndex;\n const statementCount = end - start + 1;\n row[statementCount] =\n statementCount >= minSequenceStatementCount && tokenCount >= minTokens\n ? combineHashes(hash, statementCount)\n : undefined;\n }\n windowKeysByStart.push(row);\n }\n return { windowKeysByStart, statementHashes };\n}\n\n/**\n * Longest-common-subsequence LENGTH of two symbol sequences via the Allison–Dix bit-parallel\n * recurrence (O(|a|/32 · |b|) words): per symbol of `b`, `x = match | v` and\n * `v = x & ~(x - ((v << 1) | 1))` over multi-word bit vectors; the set bits of `v` count the LCS.\n */\nexport function lcsLength(a: Int32Array, b: Int32Array): number {\n const wordCount = (a.length + 31) >>> 5;\n const positionMasks = new Map<number, Uint32Array>();\n for (const [index, symbol] of a.entries()) {\n let mask = positionMasks.get(symbol);\n if (!mask) {\n mask = new Uint32Array(wordCount);\n positionMasks.set(symbol, mask);\n }\n const word = index >>> 5;\n // The Uint32Array store wraps the signed int32 bit pattern to unsigned. The `?? 0` guards in\n // this function are required by noUncheckedIndexedAccess (typed-array reads type as\n // `number | undefined`), not redundancy: every index is in bounds.\n mask[word] = (mask[word] ?? 0) | (1 << (index & 31));\n }\n\n const v = new Uint32Array(wordCount);\n for (const symbol of b) {\n const matchMask = positionMasks.get(symbol);\n // `(v << 1) | 1` shifts a carry bit across words; subtraction borrows across words.\n let shiftCarry = 1;\n let borrow = 0;\n for (let word = 0; word < wordCount; word += 1) {\n const previous = v[word] ?? 0;\n // oxlint-disable-next-line unicorn/prefer-math-trunc -- `>>> 0` reinterprets the signed int32 bit pattern as unsigned so the borrow subtraction below compares magnitudes; Math.trunc would keep it negative.\n const x = ((matchMask?.[word] ?? 0) | previous) >>> 0;\n // oxlint-disable-next-line unicorn/prefer-math-trunc -- same unsigned reinterpretation as `x`.\n const shifted = ((previous << 1) | shiftCarry) >>> 0;\n shiftCarry = previous >>> 31;\n const difference = x - shifted - borrow;\n borrow = difference < 0 ? 1 : 0;\n // The Uint32Array store wraps the signed int32 bit pattern to unsigned.\n v[word] = x & ~difference;\n }\n }\n\n let length = 0;\n for (const word of v) {\n length += popCount(word);\n }\n return length;\n}\n\nfunction popCount(value: number): number {\n let count = value - ((value >>> 1) & 0x55_55_55_55);\n count = (count & 0x33_33_33_33) + ((count >>> 2) & 0x33_33_33_33);\n return (Math.imul((count + (count >>> 4)) & 0x0F_0F_0F_0F, 0x01_01_01_01) >>> 24) & 0xFF;\n}\n\nfunction toCandidate(\n fingerprint: string,\n startTokenIndex: number,\n endTokenIndex: number,\n first: TokenRange,\n last: TokenRange\n): DuplicateCandidate {\n return {\n fingerprint,\n tokenCount: endTokenIndex - startTokenIndex,\n startTokenIndex,\n endTokenIndex,\n startIndex: first.startIndex,\n endIndex: last.endIndex,\n startLine: first.startLine,\n endLine: last.endLine,\n };\n}\n\n/** Caches of hashText/hashText2 over '$0', '$1', ... so anonymized identifiers hash without allocating. */\nconst anonymizedIndexHashes: number[] = [];\nconst anonymizedIndexHashes2: number[] = [];\n\nfunction anonymizedIndexHash(index: number): number {\n let hash = anonymizedIndexHashes[index];\n if (hash === undefined) {\n hash = hashText(`$${index}`);\n anonymizedIndexHashes[index] = hash;\n }\n return hash;\n}\n\nfunction anonymizedIndexHash2(index: number): number {\n let hash = anonymizedIndexHashes2[index];\n if (hash === undefined) {\n hash = hashText2(`$${index}`);\n anonymizedIndexHashes2[index] = hash;\n }\n return hash;\n}\n\n/**\n * Content key of a token range: two independent 32-bit hashes over the normalized token sequence\n * (identifiers anonymized consistently by first-occurrence order) plus the token count. Regions\n * with equal keys are treated as equal content; a collision would need both 32-bit hashes and the\n * length to coincide, which is negligible for a metrics report. The format and arithmetic match\n * fingerprint_key in native/src/duplication.rs exactly, so window candidates fingerprinted here\n * group together with the per-file candidates the addon catalogues.\n */\nfunction fingerprintKey(\n tokens: Token[],\n literalCountPrefix: Int32Array,\n startTokenIndex: number,\n endTokenIndex: number\n): string {\n const literalCount = (literalCountPrefix[endTokenIndex] ?? 0) - (literalCountPrefix[startTokenIndex] ?? 0);\n const literalDense = isLiteralDense(literalCount, endTokenIndex - startTokenIndex);\n const [primary, secondary] = fingerprintHashPair(tokens, startTokenIndex, endTokenIndex, literalDense);\n return `${primary}:${secondary}:${endTokenIndex - startTokenIndex}`;\n}\n\n/**\n * A single 32-bit summary of a range for the coarse rolling-hash phase. Deliberately\n * density-agnostic: density is a property of the final candidate REGION, and folding literal\n * values into per-statement hashes would make a dense statement inside a logic-heavy window\n * (`const weights = [1, 2, 3];`) block the window from ever being enumerated. The coarse phase\n * over-approximates on shape alone; the exact region fingerprint still applies the density rule.\n */\nfunction fingerprintHash(tokens: Token[], startTokenIndex: number, endTokenIndex: number): number {\n const [primary, secondary] = fingerprintHashPair(tokens, startTokenIndex, endTokenIndex, false);\n // XOR already coerces to int32, matching the native side's i32 arithmetic.\n return primary ^ Math.imul(secondary, 31);\n}\n\nfunction fingerprintHashPair(\n tokens: Token[],\n startTokenIndex: number,\n endTokenIndex: number,\n foldLiteralValues: boolean\n): [number, number] {\n const indexByIdentifier = new Map<string, number>();\n let primary = 5381;\n let secondary = 52_711;\n for (let index = startTokenIndex; index < endTokenIndex; index += 1) {\n const token = tokens[index];\n if (!token) {\n continue;\n }\n // Each accumulator consumes its own independent per-token hash: sharing one would collapse\n // the key to 32 effective bits (a single djb2 collision would equate whole regions).\n let part: number;\n let part2: number;\n if (token.kind === 'id') {\n let identifierIndex = indexByIdentifier.get(token.text);\n if (identifierIndex === undefined) {\n identifierIndex = indexByIdentifier.size;\n indexByIdentifier.set(token.text, identifierIndex);\n }\n part = anonymizedIndexHash(identifierIndex);\n part2 = anonymizedIndexHash2(identifierIndex);\n } else {\n part = token.textHash;\n part2 = token.textHash2;\n }\n // oxlint-disable-next-line unicorn/prefer-math-trunc -- `| 0` wraps the sum to int32 (Math.trunc does not), which must match the native side's wrapping i32 arithmetic.\n primary = (Math.imul(primary, 31) + part) | 0;\n secondary = Math.imul(secondary, 37) ^ part2;\n if (foldLiteralValues && token.literalHash !== undefined && token.literalHash2 !== undefined) {\n // oxlint-disable-next-line unicorn/prefer-math-trunc -- `| 0` wraps the sum to int32 (Math.trunc does not), which must match the native side's wrapping i32 arithmetic.\n primary = (Math.imul(primary, 31) + token.literalHash) | 0;\n secondary = Math.imul(secondary, 37) ^ token.literalHash2;\n }\n }\n return [primary, secondary];\n}\n\n/** djb2-style hash; XOR keeps the value in signed 32-bit range, which is fine for a grouping key. */\nfunction hashText(text: string): number {\n let hash = 5381;\n for (let index = 0; index < text.length; index += 1) {\n // oxlint-disable-next-line unicorn/prefer-code-point -- djb2 hashes UTF-16 code units; codePointAt would hash surrogate pairs twice (full code point, then the lone low surrogate).\n hash = Math.imul(hash, 33) ^ text.charCodeAt(index);\n }\n return hash;\n}\n\n/** FNV-1a over UTF-16 code units: independent of hashText so the two accumulators never share input. */\nfunction hashText2(text: string): number {\n let hash = -2_128_831_035; // 2166136261 as int32 (the FNV-1a offset basis)\n for (let index = 0; index < text.length; index += 1) {\n // oxlint-disable-next-line unicorn/prefer-code-point -- hashes UTF-16 code units like hashText.\n hash = Math.imul(hash ^ text.charCodeAt(index), 16_777_619);\n }\n return hash;\n}\n\nfunction combineHashes(hash: number, value: number): number {\n return Math.imul(hash, 31) + value;\n}\n\n/**\n * Merges duplicate groups separated by a small token gap into one gapped (Type-3) clone group: a\n * copy edited in one spot splits into two exact groups whose occurrences sit side by side in the\n * same order. Occurrences are paired greedily in source order; a merge happens when the pairing\n * fully pairs at least one group with at least two pairs. Equal-cardinality groups whose\n * occurrences all pair merge into one group as before. When cardinalities differ (a fragment also\n * occurs standalone: prefix ×3, suffix ×2), the fully-paired group is subsumed into the merged\n * gapped group while the other group is RETAINED with ALL its occurrences: dropping the leftover\n * would lose duplicated-line coverage, and reporting it alone would make a single-occurrence group\n * (contradicting duplicateBlockGroupCount's \"appears more than once\" meaning). Line coverage\n * unions ranges, so the overlap between the retained exact group and the merged group is harmless.\n * Merging repeats to a fixpoint so a clone edited in several spots still reassembles; it\n * terminates because a merge either removes the input groups it replaces or marks their paired\n * occurrences as counted elsewhere, and only unmarked occurrences of remaining groups pair, so the\n * number of pairable occurrences strictly decreases with every merge. Gap tokens are not matched\n * content: line coverage and sizes count only the matched segments. Generic so cross-file merging\n * can thread file identity through occurrences.\n */\nexport function mergeAdjacentGroups<T extends CountedOccurrence>(\n groups: T[][],\n maxGapTokens: number,\n isReportableGroup: (group: T[]) => boolean = () => true\n): T[][] {\n if (maxGapTokens <= 0 || groups.length < 2) {\n return groups;\n }\n // Deterministic processing order (mirrored by the native side): by first occurrence position.\n groups.sort(compareGroups);\n for (let restart = true; restart;) {\n restart = false;\n for (let leftIndex = 0; leftIndex < groups.length && !restart; leftIndex += 1) {\n for (let rightIndex = leftIndex + 1; rightIndex < groups.length; rightIndex += 1) {\n const left = groups[leftIndex];\n const right = groups[rightIndex];\n if (!left || !right) {\n continue;\n }\n const forward = mergeGroups(left, right, maxGapTokens, isReportableGroup);\n const result = forward ?? mergeGroups(right, left, maxGapTokens, isReportableGroup);\n if (!result) {\n continue;\n }\n const leftReplaced = forward ? result.firstReplaced : result.secondReplaced;\n const rightReplaced = forward ? result.secondReplaced : result.firstReplaced;\n if (leftReplaced && rightReplaced) {\n groups[leftIndex] = result.merged;\n groups.splice(rightIndex, 1);\n } else if (rightReplaced) {\n groups[rightIndex] = result.merged;\n } else if (leftReplaced) {\n groups[leftIndex] = result.merged;\n } else {\n // Both groups stay (each is only partly paired, or reports nested copies of its own), so\n // the merged group joins them instead of taking a place.\n groups.push(result.merged);\n }\n // A group that stays keeps ALL its occurrences (line coverage must not shrink, and a\n // reported group must keep >= 2 occurrences), so its paired occurrences now also live\n // inside the merged group's occurrences: mark them so duplicateBlockCount counts each\n // token span once, and so the same pair cannot merge again.\n for (const occurrence of result.pairedRetained) {\n occurrence.spanCountedElsewhere = true;\n }\n groups.sort(compareGroups);\n restart = true;\n break;\n }\n }\n }\n return groups;\n}\n\nfunction compareGroups(left: CountedOccurrence[], right: CountedOccurrence[]): number {\n const leftFirst = left[0];\n const rightFirst = right[0];\n return (\n (leftFirst?.startTokenIndex ?? 0) - (rightFirst?.startTokenIndex ?? 0) ||\n (leftFirst?.endTokenIndex ?? 0) - (rightFirst?.endTokenIndex ?? 0)\n );\n}\n\ninterface MergeResult<T> {\n merged: T[];\n /**\n * Whether the merged group takes the respective input group's place: its standalone occurrences\n * were all paired and it holds no nested copies that only it can report.\n */\n firstReplaced: boolean;\n secondReplaced: boolean;\n /** The occurrences of groups that stay, which the merged group's spans now also cover. */\n pairedRetained: T[];\n}\n\n/**\n * Pairs `second` occurrences with gap-preceding `first` occurrences, greedily in source order:\n * each trailing occurrence takes the earliest unused leading occurrence within the gap, and a\n * pair's leading must start at or after the previous pair's trailing end so merged spans never\n * overlap. A merge needs at least two pairs (a merged group must still mean \"appears more than\n * once\") and must fully consume at least one group; for equal cardinalities this reduces to the\n * strict all-pairs merge, so pre-partial-merge behavior is unchanged there.\n */\nfunction mergeGroups<T extends CountedOccurrence>(\n first: T[],\n second: T[],\n maxGapTokens: number,\n isReportableGroup: (group: T[]) => boolean\n): MergeResult<T> | undefined {\n // Occurrences a previous partial merge already paired into a merged group must not pair again:\n // their spans already live inside that merged group, so re-pairing them would assemble a second,\n // competing merged group instead of letting the existing merged group extend (and would count\n // the same span twice). Consumption is still judged against the FULL group, so a group holding\n // shared occurrences is never subsumed away; nested copies, which the merged span would not\n // cover anyway, are left out of that judgment so they cannot veto a merge of the standalone\n // copies.\n const [leadings, firstLength] = pairableOccurrences(first);\n const [trailings, secondLength] = pairableOccurrences(second);\n const pairs: [T, T][] = [];\n let leadingIndex = 0;\n let previousTrailingEnd = -1;\n for (const trailing of trailings) {\n // Leadings ending too far before this trailing can never pair a later (even farther) one.\n while (leadingIndex < leadings.length) {\n const leading = leadings[leadingIndex];\n if (leading && leading.endTokenIndex + maxGapTokens < trailing.startTokenIndex) {\n leadingIndex += 1;\n } else {\n break;\n }\n }\n const leading = leadings[leadingIndex];\n if (\n leading &&\n leading.endTokenIndex <= trailing.startTokenIndex &&\n leading.startTokenIndex >= previousTrailingEnd\n ) {\n pairs.push([leading, trailing]);\n previousTrailingEnd = trailing.endTokenIndex;\n leadingIndex += 1;\n }\n }\n const firstFullyPaired = pairs.length === firstLength;\n const secondFullyPaired = pairs.length === secondLength;\n if (pairs.length < 2 || (!firstFullyPaired && !secondFullyPaired)) {\n return undefined;\n }\n // A group holding nested copies stays even when all its standalone copies pair: the merged\n // group's content is larger than what those copies matched, so only the original group can\n // report which files share the matched fragment.\n const firstReplaced = firstFullyPaired && !first.some((occurrence) => occurrence.nestedInLargerGroup);\n const secondReplaced = secondFullyPaired && !second.some((occurrence) => occurrence.nestedInLargerGroup);\n const merged = pairs.map(([leading, trailing]) => ({\n ...leading,\n // A merged occurrence is a fresh span combination; it never inherits shared-span marks.\n spanCountedElsewhere: undefined,\n nestedInLargerGroup: undefined,\n segments: [...leading.segments, ...trailing.segments],\n tokenCount: leading.tokenCount + trailing.tokenCount,\n endTokenIndex: trailing.endTokenIndex,\n endIndex: trailing.endIndex,\n endLine: trailing.endLine,\n }));\n // Only occurrences of one file pair (file offsets exceed the gap), so a merged group spans the\n // files its pairs sit in: with nested copies left out of pairing, that can be fewer files than\n // the input groups covered, and a merged group that is no longer reportable must not form.\n if (!isReportableGroup(merged)) {\n return undefined;\n }\n const pairedRetained = [\n ...(firstReplaced ? [] : pairs.map(([leading]) => leading)),\n ...(secondReplaced ? [] : pairs.map(([, trailing]) => trailing)),\n ];\n return { merged, firstReplaced, secondReplaced, pairedRetained };\n}\n\n/** One pass over a group: its pairable occurrences and its non-nested occurrence count. */\nfunction pairableOccurrences<T extends CountedOccurrence>(group: T[]): [T[], number] {\n const pairable: T[] = [];\n let length = 0;\n for (const occurrence of group) {\n if (!occurrence.nestedInLargerGroup) {\n length += 1;\n }\n // Either flag keeps an occurrence out of pairing: its span is already counted elsewhere, or it\n // is a nested copy of content the merged span would not cover.\n if (!occurrence.spanCountedElsewhere && !occurrence.nestedInLargerGroup) {\n pairable.push(occurrence);\n }\n }\n return [pairable, length];\n}\n\n/**\n * Redundant copies one group adds to duplicateBlockCount. Each redundant occurrence contributes\n * one count per matched fragment, so merging a gapped clone's fragments into one group does not\n * halve duplicateBlockCount: an edited two-fragment pair still counts 2, exactly as its unmerged\n * fragments did. Occurrence shapes can differ within one group (a\n * gap-merged exact pair plus an appended whole-block near-miss copy), so every occurrence's\n * fragments are summed and one representative — the largest — is deducted, keeping the count\n * independent of source order. Occurrences a partial gapped merge also paired into a merged group\n * are skipped: their spans are already counted there, and such a retained group deducts no\n * representative of its own — the merged group's representative already stands for the shared\n * content — so no token span contributes to the count twice.\n */\nexport function countRedundantFragments(group: CountedOccurrence[]): number {\n let fragmentCount = 0;\n let maxFragmentCount = 0;\n let hasSharedOccurrence = false;\n for (const occurrence of group) {\n if (occurrence.spanCountedElsewhere) {\n hasSharedOccurrence = true;\n continue;\n }\n fragmentCount += occurrence.segments.length;\n maxFragmentCount = Math.max(maxFragmentCount, occurrence.segments.length);\n }\n return hasSharedOccurrence ? fragmentCount : fragmentCount - maxFragmentCount;\n}\n\n/** Adds the 1-based code lines the segment's matched tokens cover; shared with cross-file coverage. */\nexport function collectSegmentLines(\n segment: { startTokenIndex: number; endTokenIndex: number },\n tokens: Token[],\n codeLineNumbers: Set<number> | undefined,\n duplicatedLines: Set<number>\n): void {\n for (let index = segment.startTokenIndex; index < segment.endTokenIndex; index += 1) {\n const token = tokens[index];\n for (let row = token?.startRow ?? 0; row <= (token?.endRow ?? -1); row += 1) {\n if (!codeLineNumbers || codeLineNumbers.has(row + 1)) {\n duplicatedLines.add(row + 1);\n }\n }\n }\n}\n"],"mappings":"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"}
|