code-gauge 4.2.1 → 4.2.2
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/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/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/native/src/complexity.rs +11 -8
- package/native/src/dep_degree.rs +102 -11
- package/native/src/functions.rs +828 -22
- package/package.json +8 -8
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";const e=require("./duplicateSelection.cjs"),t=require("./duplication.cjs");function n(n,
|
|
1
|
+
"use strict";const e=require("./duplicateSelection.cjs"),t=require("./duplication.cjs");function n(n,i){let{minTokens:l,maxGapTokens:u}=t.resolveDuplicationOptions(i),d=n.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let e of r(n,l))d.push(e);let f=e.selectMaximalGroups(d,a,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),p=o(n,u);return c(s([...f.values()],p,u),n,p)}function r(e,n){let r=[],i=[];for(let[n,{tokens:a,containerStatements:o}]of e.entries())a&&o&&(r.push(n),i.push({tokens:a,literalCountPrefix:t.buildLiteralCountPrefix(a),containers:o}));return i.length<2?[]:t.collectSequenceWindowCandidates(i,n,!0).flatMap(({candidate:t,contextIndex:n})=>{let i=r[n],a=i===void 0?void 0:e[i];return i===void 0||a===void 0?[]:[{...t,regionBucket:i,file:a.file}]})}function i(e){return new Set(e.map(e=>e.file)).size>=2}function a(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function o(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function s(e,n,r){let a=e.map(e=>e.map(e=>{let t=e.startTokenIndex+(n[e.regionBucket]??0),r=e.endTokenIndex+(n[e.regionBucket]??0);return{file:e.file,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:t,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:t,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return t.mergeAdjacentGroups(a,r,i)}function c(e,n,r){let i=[],a=new Map,o=new Map(n.map((e,t)=>[e.file,{tokens:e.tokens,codeLineNumbers:e.codeLineNumbers,offset:r[t]??0}])),s=new Map,c=0;for(let n of e){c+=t.countRedundantFragments(n);for(let e of n)l(e,o,s);let e=n.map(({file:e,startLine:t,endLine:n})=>({file:e,startLine:t,endLine:n})).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),r=[...new Set(e.map(({file:e})=>e))];for(let e of r)a.set(e,(a.get(e)??0)+1);i.push({files:r,occurrences:e,tokenCount:n[0]?.tokenCount??0})}return i.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:i}}function l(e,n,r){let i=n.get(e.file);if(!i?.tokens)return;let a=r.get(e.file);a||(a=new Set,r.set(e.file,a));for(let n of e.segments)t.collectSegmentLines({startTokenIndex:n.startTokenIndex-i.offset,endTokenIndex:n.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,a)}exports.measureCrossFileDuplication=n;
|
|
2
2
|
//# sourceMappingURL=crossFileDuplication.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crossFileDuplication.cjs","names":["resolveDuplicationOptions","selectMaximalGroups","buildLiteralCountPrefix","collectSequenceWindowCandidates","mergeAdjacentGroups","countRedundantFragments"],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { selectMaximalGroups } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n startLine: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /** Matched token count of one occurrence (all occurrences share it; gaps are not counted). */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by matched tokens of its cross-file occurrences,\n * sorted ascending. Exact like within-file duplicateLineNumbers: the unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the matched lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n return summarize(mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens), files, tokenOffsets);\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n for (const occurrence of group) {\n collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n }\n const occurrences = group\n .map(({ file, startLine, endLine }) => ({ file, startLine, endLine }))\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: group[0]?.tokenCount ?? 0 });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's matched tokens cover to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream. A file that supplied only\n * candidates (no token stream) is skipped rather than approximated from the bounding line range,\n * which would include gap and comment/blank lines and break the field's exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): void {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return;\n }\n let lines = lineNumbersByFile.get(occurrence.file);\n if (!lines) {\n lines = new Set();\n lineNumbersByFile.set(occurrence.file, lines);\n }\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"crossFileDuplication.cjs","names":["resolveDuplicationOptions","selectMaximalGroups","buildLiteralCountPrefix","collectSequenceWindowCandidates","mergeAdjacentGroups","countRedundantFragments"],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { selectMaximalGroups, type SelectableRegion } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n startLine: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /** Matched token count of one occurrence (all occurrences share it; gaps are not counted). */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by matched tokens of its cross-file occurrences,\n * sorted ascending. Exact like within-file duplicateLineNumbers: the unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the matched lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate, SelectableRegion {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. A copy nested inside a larger group's region (two files share\n * a whole function, a third file only a block of it) is reported with its group, so the third\n * file's copy still shows what it duplicates. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n return summarize(mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens), files, tokenOffsets);\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\n/** A merged group is reported only while it still covers more than one file. */\nfunction spansMultipleFilesAfterMerge(group: CrossFileOccurrence[]): boolean {\n return new Set(group.map((occurrence) => occurrence.file)).size >= 2;\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n spanCountedElsewhere: candidate.nestedInLargerGroup,\n nestedInLargerGroup: candidate.nestedInLargerGroup,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens, spansMultipleFilesAfterMerge);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n for (const occurrence of group) {\n collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n }\n const occurrences = group\n .map(({ file, startLine, endLine }) => ({ file, startLine, endLine }))\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: group[0]?.tokenCount ?? 0 });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's matched tokens cover to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream. A file that supplied only\n * candidates (no token stream) is skipped rather than approximated from the bounding line range,\n * which would include gap and comment/blank lines and break the field's exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): void {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return;\n }\n let lines = lineNumbersByFile.get(occurrence.file);\n if (!lines) {\n lines = new Set();\n lineNumbersByFile.set(occurrence.file, lines);\n }\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n}\n"],"mappings":"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"}
|
|
@@ -39,7 +39,9 @@ export interface CrossFileDuplicationMetrics {
|
|
|
39
39
|
* no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only
|
|
40
40
|
* maximal, non-overlapping regions whose group spans at least two files are counted. Groups that
|
|
41
41
|
* shrink to a single file during selection are shed — a within-file repeat is already reported by
|
|
42
|
-
* that file's own duplication metrics.
|
|
42
|
+
* that file's own duplication metrics. A copy nested inside a larger group's region (two files share
|
|
43
|
+
* a whole function, a third file only a block of it) is reported with its group, so the third
|
|
44
|
+
* file's copy still shows what it duplicates. Groups separated by a small token gap within each file then
|
|
43
45
|
* merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.
|
|
44
46
|
*/
|
|
45
47
|
export declare function measureCrossFileDuplication(files: CrossFileDuplicationSourceFile[], options?: DuplicationOptions): CrossFileDuplicationMetrics;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{selectMaximalGroups as e}from"./duplicateSelection.js";import{buildLiteralCountPrefix as t,collectSegmentLines as n,collectSequenceWindowCandidates as r,countRedundantFragments as i,mergeAdjacentGroups as a,resolveDuplicationOptions as o}from"./duplication.js";function s(t,n){let{minTokens:r,maxGapTokens:i}=o(n),a=t.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let e of c(t,r))a.push(e);let s=e(a,
|
|
1
|
+
import{selectMaximalGroups as e}from"./duplicateSelection.js";import{buildLiteralCountPrefix as t,collectSegmentLines as n,collectSequenceWindowCandidates as r,countRedundantFragments as i,mergeAdjacentGroups as a,resolveDuplicationOptions as o}from"./duplication.js";function s(t,n){let{minTokens:r,maxGapTokens:i}=o(n),a=t.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let e of c(t,r))a.push(e);let s=e(a,u,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),l=d(t,i);return p(f([...s.values()],l,i),t,l)}function c(e,n){let i=[],a=[];for(let[n,{tokens:r,containerStatements:o}]of e.entries())r&&o&&(i.push(n),a.push({tokens:r,literalCountPrefix:t(r),containers:o}));return a.length<2?[]:r(a,n,!0).flatMap(({candidate:t,contextIndex:n})=>{let r=i[n],a=r===void 0?void 0:e[r];return r===void 0||a===void 0?[]:[{...t,regionBucket:r,file:a.file}]})}function l(e){return new Set(e.map(e=>e.file)).size>=2}function u(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function d(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function f(e,t,n){let r=e.map(e=>e.map(e=>{let n=e.startTokenIndex+(t[e.regionBucket]??0),r=e.endTokenIndex+(t[e.regionBucket]??0);return{file:e.file,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:n,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:n,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return a(r,n,l)}function p(e,t,n){let r=[],a=new Map,o=new Map(t.map((e,t)=>[e.file,{tokens:e.tokens,codeLineNumbers:e.codeLineNumbers,offset:n[t]??0}])),s=new Map,c=0;for(let t of e){c+=i(t);for(let e of t)m(e,o,s);let e=t.map(({file:e,startLine:t,endLine:n})=>({file:e,startLine:t,endLine:n})).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),n=[...new Set(e.map(({file:e})=>e))];for(let e of n)a.set(e,(a.get(e)??0)+1);r.push({files:n,occurrences:e,tokenCount:t[0]?.tokenCount??0})}return r.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:r}}function m(e,t,r){let i=t.get(e.file);if(!i?.tokens)return;let a=r.get(e.file);a||(a=new Set,r.set(e.file,a));for(let t of e.segments)n({startTokenIndex:t.startTokenIndex-i.offset,endTokenIndex:t.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,a)}export{s as measureCrossFileDuplication};
|
|
2
2
|
//# sourceMappingURL=crossFileDuplication.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"crossFileDuplication.js","names":[],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { selectMaximalGroups } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n startLine: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /** Matched token count of one occurrence (all occurrences share it; gaps are not counted). */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by matched tokens of its cross-file occurrences,\n * sorted ascending. Exact like within-file duplicateLineNumbers: the unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the matched lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n return summarize(mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens), files, tokenOffsets);\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n for (const occurrence of group) {\n collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n }\n const occurrences = group\n .map(({ file, startLine, endLine }) => ({ file, startLine, endLine }))\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: group[0]?.tokenCount ?? 0 });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's matched tokens cover to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream. A file that supplied only\n * candidates (no token stream) is skipped rather than approximated from the bounding line range,\n * which would include gap and comment/blank lines and break the field's exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): void {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return;\n }\n let lines = lineNumbersByFile.get(occurrence.file);\n if (!lines) {\n lines = new Set();\n lineNumbersByFile.set(occurrence.file, lines);\n }\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"crossFileDuplication.js","names":[],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { selectMaximalGroups, type SelectableRegion } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n startLine: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /** Matched token count of one occurrence (all occurrences share it; gaps are not counted). */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by matched tokens of its cross-file occurrences,\n * sorted ascending. Exact like within-file duplicateLineNumbers: the unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the matched lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate, SelectableRegion {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. A copy nested inside a larger group's region (two files share\n * a whole function, a third file only a block of it) is reported with its group, so the third\n * file's copy still shows what it duplicates. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n return summarize(mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens), files, tokenOffsets);\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\n/** A merged group is reported only while it still covers more than one file. */\nfunction spansMultipleFilesAfterMerge(group: CrossFileOccurrence[]): boolean {\n return new Set(group.map((occurrence) => occurrence.file)).size >= 2;\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n spanCountedElsewhere: candidate.nestedInLargerGroup,\n nestedInLargerGroup: candidate.nestedInLargerGroup,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens, spansMultipleFilesAfterMerge);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n for (const occurrence of group) {\n collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n }\n const occurrences = group\n .map(({ file, startLine, endLine }) => ({ file, startLine, endLine }))\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: group[0]?.tokenCount ?? 0 });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's matched tokens cover to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream. A file that supplied only\n * candidates (no token stream) is skipped rather than approximated from the bounding line range,\n * which would include gap and comment/blank lines and break the field's exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): void {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return;\n }\n let lines = lineNumbersByFile.get(occurrence.file);\n if (!lines) {\n lines = new Set();\n lineNumbersByFile.set(occurrence.file, lines);\n }\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,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":"aAoCA,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,IACd,EAAsB,IAAI,IAChC,IAAK,IAAM,KAAa,EAAY,CAClC,IAAM,EAAc,EAAoB,IAAI,EAAU,cAAgB,CAAC,GAAK,CAAC,EAIzE,EAAkB,GAClB,EAAoB,GACpB,EACJ,IAAK,IAAM,KAAU,EACf,OAAO,YAAc,EAAU,UAAY,EAAU,YAAc,EAAO,UAG9E,IAAI,EAAO,YAAc,EAAU,YAAc,EAAU,UAAY,EAAO,SAAU,CACtF,EAAkB,GAClB,KACF,CACA,GAAI,EAAU,YAAc,EAAO,YAAc,EAAO,UAAY,EAAU,UAC3E,IAAiB,CAAC,EAAA,CAAG,KAAK,CAAM,MAC5B,CACL,EAAoB,GACpB,KACF,CANA,CAQF,GAAI,EAAiB,CACnB,IAAM,EAAS,EAAoB,IAAI,EAAU,WAAW,GAAK,CAAC,EAClE,EAAO,KAAK,CAAE,GAAG,EAAW,oBAAqB,EAAK,CAAC,EACvD,EAAoB,IAAI,EAAU,YAAa,CAAM,EACrD,QACF,CACA,GAAI,EACF,SAIF,IAAM,EAAW,EACjB,IAAK,IAAM,KAAS,GAAY,CAAC,EAAG,CAClC,IAAM,EAAQ,EAAQ,IAAI,EAAM,WAAW,GAAK,CAAC,EAC3C,EAAQ,EAAM,QAAQ,CAAK,EAC7B,IAAU,KACZ,EAAM,GAAS,CAAE,GAAG,EAAO,oBAAqB,EAAK,EAEzD,CAIA,IAAM,EAAW,EAAW,EAAY,OAAQ,GAAW,CAAC,EAAS,SAAS,CAAM,CAAC,EAAI,EACzF,EAAS,KAAK,CAAS,EACvB,EAAoB,IAAI,EAAU,cAAgB,EAAG,CAAQ,EAC7D,IAAM,EAAQ,EAAQ,IAAI,EAAU,WAAW,GAAK,CAAC,EACrD,EAAM,KAAK,CAAS,EACpB,EAAQ,IAAI,EAAU,YAAa,CAAK,CAC1C,CAGA,IAAK,GAAM,CAAC,EAAa,KAAW,EAClC,EAAQ,IAAI,CAAW,CAAC,EAAE,KAAK,GAAG,CAAM,EAE1C,IAAK,GAAM,CAAC,EAAa,KAAU,EAC7B,EAAM,MAAO,GAAc,EAAU,mBAAmB,GAC1D,EAAQ,OAAO,CAAW,EAI9B,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,GAEX,OADA,EAAiB,EAAS,CAAgB,EACnC,EAGT,EAAa,EAAW,OAAQ,GAAc,EAAU,cAAgB,CAAiB,CAC3F,CACF,CAQA,SAAS,EACP,EACA,EACM,CACN,IAAK,IAAI,EAAU,GAAM,GAAU,CACjC,EAAU,GACV,IAAK,GAAM,CAAC,EAAa,KAAU,GAC7B,CAAC,EAAiB,CAAK,GAAK,EAAM,MAAO,GAAc,EAAU,mBAAmB,KACtF,EAAQ,OAAO,CAAW,EAC1B,EAAU,IAGd,IAAM,EAAa,CAAC,GAAG,EAAQ,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,OAAQ,GAAc,CAAC,EAAU,mBAAmB,EACpG,IAAK,GAAM,CAAC,EAAa,KAAU,EAAS,CAC1C,IAAM,EAAO,EAAM,OAChB,GACC,CAAC,EAAU,qBACX,EAAW,KACR,IACE,EAAO,cAAgB,MAAQ,EAAU,cAAgB,IAC1D,EAAO,YAAc,EAAU,YAC/B,EAAU,UAAY,EAAO,QACjC,CACJ,EACI,EAAK,SAAW,EAAM,SACxB,EAAQ,IAAI,EAAa,CAAI,EAC7B,EAAU,GAEd,CACF,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,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":"AAoCA,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,IACd,EAAsB,IAAI,IAChC,IAAK,IAAM,KAAa,EAAY,CAClC,IAAM,EAAc,EAAoB,IAAI,EAAU,cAAgB,CAAC,GAAK,CAAC,EAIzE,EAAkB,GAClB,EAAoB,GACpB,EACJ,IAAK,IAAM,KAAU,EACf,OAAO,YAAc,EAAU,UAAY,EAAU,YAAc,EAAO,UAG9E,IAAI,EAAO,YAAc,EAAU,YAAc,EAAU,UAAY,EAAO,SAAU,CACtF,EAAkB,GAClB,KACF,CACA,GAAI,EAAU,YAAc,EAAO,YAAc,EAAO,UAAY,EAAU,UAC3E,IAAiB,CAAC,EAAA,CAAG,KAAK,CAAM,MAC5B,CACL,EAAoB,GACpB,KACF,CANA,CAQF,GAAI,EAAiB,CACnB,IAAM,EAAS,EAAoB,IAAI,EAAU,WAAW,GAAK,CAAC,EAClE,EAAO,KAAK,CAAE,GAAG,EAAW,oBAAqB,EAAK,CAAC,EACvD,EAAoB,IAAI,EAAU,YAAa,CAAM,EACrD,QACF,CACA,GAAI,EACF,SAIF,IAAM,EAAW,EACjB,IAAK,IAAM,KAAS,GAAY,CAAC,EAAG,CAClC,IAAM,EAAQ,EAAQ,IAAI,EAAM,WAAW,GAAK,CAAC,EAC3C,EAAQ,EAAM,QAAQ,CAAK,EAC7B,IAAU,KACZ,EAAM,GAAS,CAAE,GAAG,EAAO,oBAAqB,EAAK,EAEzD,CAIA,IAAM,EAAW,EAAW,EAAY,OAAQ,GAAW,CAAC,EAAS,SAAS,CAAM,CAAC,EAAI,EACzF,EAAS,KAAK,CAAS,EACvB,EAAoB,IAAI,EAAU,cAAgB,EAAG,CAAQ,EAC7D,IAAM,EAAQ,EAAQ,IAAI,EAAU,WAAW,GAAK,CAAC,EACrD,EAAM,KAAK,CAAS,EACpB,EAAQ,IAAI,EAAU,YAAa,CAAK,CAC1C,CAGA,IAAK,GAAM,CAAC,EAAa,KAAW,EAClC,EAAQ,IAAI,CAAW,CAAC,EAAE,KAAK,GAAG,CAAM,EAE1C,IAAK,GAAM,CAAC,EAAa,KAAU,EAC7B,EAAM,MAAO,GAAc,EAAU,mBAAmB,GAC1D,EAAQ,OAAO,CAAW,EAI9B,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,GAEX,OADA,EAAiB,EAAS,CAAgB,EACnC,EAGT,EAAa,EAAW,OAAQ,GAAc,EAAU,cAAgB,CAAiB,CAC3F,CACF,CAQA,SAAS,EACP,EACA,EACM,CACN,IAAK,IAAI,EAAU,GAAM,GAAU,CACjC,EAAU,GACV,IAAK,GAAM,CAAC,EAAa,KAAU,GAC7B,CAAC,EAAiB,CAAK,GAAK,EAAM,MAAO,GAAc,EAAU,mBAAmB,KACtF,EAAQ,OAAO,CAAW,EAC1B,EAAU,IAGd,IAAM,EAAa,CAAC,GAAG,EAAQ,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,OAAQ,GAAc,CAAC,EAAU,mBAAmB,EACpG,IAAK,GAAM,CAAC,EAAa,KAAU,EAAS,CAC1C,IAAM,EAAO,EAAM,OAChB,GACC,CAAC,EAAU,qBACX,EAAW,KACR,IACE,EAAO,cAAgB,MAAQ,EAAU,cAAgB,IAC1D,EAAO,YAAc,EAAU,YAC/B,EAAU,UAAY,EAAO,QACjC,CACJ,EACI,EAAK,SAAW,EAAM,SACxB,EAAQ,IAAI,EAAa,CAAI,EAC7B,EAAU,GAEd,CACF,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"}
|
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];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){if(t<=0||e.length<2)return e;e.sort(S);for(let
|
|
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
|