code-gauge 4.4.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/dist/crossFileDuplication.cjs +1 -1
- package/dist/crossFileDuplication.cjs.map +1 -1
- package/dist/crossFileDuplication.d.ts +11 -4
- package/dist/crossFileDuplication.js +1 -1
- package/dist/crossFileDuplication.js.map +1 -1
- package/dist/crossFileNearMiss.cjs +2 -0
- package/dist/crossFileNearMiss.cjs.map +1 -0
- package/dist/crossFileNearMiss.d.ts +33 -0
- package/dist/crossFileNearMiss.js +2 -0
- package/dist/crossFileNearMiss.js.map +1 -0
- package/dist/diffCommand.cjs +1 -1
- package/dist/diffCommand.cjs.map +1 -1
- package/dist/diffCommand.js +3 -3
- package/dist/diffCommand.js.map +1 -1
- package/dist/duplication.cjs +1 -1
- package/dist/duplication.cjs.map +1 -1
- package/dist/duplication.d.ts +11 -0
- package/dist/duplication.js +1 -1
- package/dist/duplication.js.map +1 -1
- package/dist/metrics.cjs +1 -1
- package/dist/metrics.cjs.map +1 -1
- package/dist/metrics.d.ts +10 -0
- package/dist/metrics.js +1 -1
- package/dist/metrics.js.map +1 -1
- package/dist/nativeMetrics.cjs +2 -2
- package/dist/nativeMetrics.cjs.map +1 -1
- package/dist/nativeMetrics.d.ts +7 -2
- package/dist/nativeMetrics.js +2 -2
- package/dist/nativeMetrics.js.map +1 -1
- package/dist/scan.cjs +1 -1
- package/dist/scan.cjs.map +1 -1
- package/dist/scan.d.ts +12 -1
- package/dist/scan.js +1 -1
- package/dist/scan.js.map +1 -1
- package/dist/types.d.ts +4 -2
- package/native/src/dep_degree.rs +2 -3
- package/native/src/duplication.rs +490 -337
- package/native/src/functions.rs +1 -1
- package/native/src/lib.rs +7 -2
- package/native/src/measure.rs +35 -11
- package/native/src/near_miss.rs +455 -0
- package/native/src/types.rs +5 -0
- package/package.json +10 -10
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
[](https://github.com/WillBooster/code-gauge/actions/workflows/test.yml)
|
|
6
6
|
[](https://github.com/WillBooster/code-gauge/actions/workflows/test-rust.yml)
|
|
7
7
|
[](https://github.com/semantic-release/semantic-release)
|
|
8
|
-
[](https://github.com/WillBooster/shared/tree/main/packages/wbfy)
|
|
9
9
|
|
|
10
10
|
A command-line tool that ranks the files of a project by refactoring priority and gates changes
|
|
11
11
|
against metric regressions, built for AI-agent workflows: an agent asked to "refactor this
|
|
@@ -157,8 +157,17 @@ The `duplication` section tunes how clones are detected:
|
|
|
157
157
|
- `minSimilarityPercent` (default 70): blocks the exact pipeline misses are additionally compared by
|
|
158
158
|
similarity (n-gram filtration, then token-level longest-common-subsequence verification, following
|
|
159
159
|
NIL and NiCad), so a near-miss (Type-3) clone with scattered small edits is still reported when
|
|
160
|
-
both blocks are at least this similar and share more than half of their content-bearing tokens
|
|
161
|
-
|
|
160
|
+
both blocks are at least this similar and share more than half of their content-bearing tokens
|
|
161
|
+
(names and literal values, weighted by rarity so ubiquitous names count less, after ECScan). Two
|
|
162
|
+
refinements apply the same threshold: blocks whose top-level statements were reordered are also
|
|
163
|
+
compared in a canonical statement order, and a copy embedded in added code (on one side or both)
|
|
164
|
+
is matched on its cores, provided the two blocks are within 3 times each other's length (a
|
|
165
|
+
threshold below 34% widens this to whatever the threshold allows): the chain of n-grams unique to
|
|
166
|
+
both blocks (only those continuing a diagonal run) is split at gaps of more than 30 tokens, each
|
|
167
|
+
segment must pass the same threshold on its own, and the verified cores, not the whole blocks,
|
|
168
|
+
are reported. `100` disables near-miss detection. Applies to within-file detection and to cross-file matching alike; across files,
|
|
169
|
+
n-grams shared by more than 1000 blocks (syntax boilerplate) are left out of the filtration index
|
|
170
|
+
so boilerplate cannot make candidate counting quadratic in the block count.
|
|
162
171
|
|
|
163
172
|
## Metrics
|
|
164
173
|
|
|
@@ -182,9 +191,11 @@ The `duplication` section tunes how clones are detected:
|
|
|
182
191
|
- Within-file duplication: copy-pasted blocks matched on normalized tokens (identifiers anonymized
|
|
183
192
|
consistently, literals by kind, and literal-dense data tables excluded unless their values also
|
|
184
193
|
match), with adjacent matches around a small edit merged into gapped (Type-3) clone groups and
|
|
185
|
-
near-miss (Type-3) clones matched by token-LCS similarity
|
|
194
|
+
near-miss (Type-3) clones matched by token-LCS similarity (tolerating reordered statements and
|
|
195
|
+
copies embedded in added code), plus duplicated line count and ratio
|
|
186
196
|
- Cross-file duplication (via `measureCrossFileDuplication`): copy-pasted blocks shared between
|
|
187
|
-
files, matched with the same normalization
|
|
197
|
+
files, matched with the same normalization (exact, gapped, and near-miss clones) and reported as
|
|
198
|
+
groups with their file locations
|
|
188
199
|
- Halstead base counts, vocabulary, length, volume, and effort, per function and per file — the
|
|
189
200
|
strongest correlates of measured cognitive load in the EEG/fMRI validation literature
|
|
190
201
|
- Per-function DepDegree (Beyer & Fararooy 2010), approximated as the number of variable reads
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";const e=require("./
|
|
1
|
+
"use strict";const e=require("./duplication.cjs"),t=require("./crossFileNearMiss.cjs"),n=require("./duplicateSelection.cjs");function r(t,r){let{minTokens:o,maxGapTokens:d,minSimilarityPercent:f}=e.resolveDuplicationOptions(r),p=t.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let e of a(t,o))p.push(e);let m=n.selectMaximalGroups(p,s,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),h=c(t,d),g=l([...m.values()],h,d);for(let e of i(t,g,h,o,f))g.push(e);return u(g,t,h)}function i(e,n,r,i,a){let o=e.map(()=>[]);for(let{fileIndex:e,startTokenIndex:t,endTokenIndex:i}of n.flat()){let n=r[e]??0;o[e]?.push({startTokenIndex:t-n,endTokenIndex:i-n})}return t.collectCrossFileNearMissGroups(e,o,i,a).map(t=>t.map(t=>{let n=r[t.fileIndex]??0;return{...t,file:e[t.fileIndex]?.file??``,segments:t.segments.map(e=>({startTokenIndex:e.startTokenIndex+n,endTokenIndex:e.endTokenIndex+n})),startTokenIndex:t.startTokenIndex+n,endTokenIndex:t.endTokenIndex+n}}))}function a(t,n){let r=[],i=[];for(let[n,{tokens:a,containerStatements:o}]of t.entries())a&&o&&(r.push(n),i.push({tokens:a,literalCountPrefix:e.buildLiteralCountPrefix(a),containers:o}));return i.length<2?[]:e.collectSequenceWindowCandidates(i,n,!0).flatMap(({candidate:e,contextIndex:n})=>{let i=r[n],a=i===void 0?void 0:t[i];return i===void 0||a===void 0?[]:[{...e,regionBucket:i,file:a.file}]})}function o(e){return new Set(e.map(e=>e.file)).size>=2}function s(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function c(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function l(t,n,r){let i=t.map(e=>e.map(e=>{let t=e.startTokenIndex+(n[e.regionBucket]??0),r=e.endTokenIndex+(n[e.regionBucket]??0);return{file:e.file,fileIndex:e.regionBucket,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:t,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:t,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return e.mergeAdjacentGroups(i,r,o)}function u(t,n,r){let i=[],a=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 t){c+=e.countRedundantFragments(n);for(let e of n)d(e,o,s);let t=n.map(({file:e,startLine:t,endLine:n})=>({file:e,startLine:t,endLine:n})).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),r=[...new Set(t.map(({file:e})=>e))];for(let e of r)a.set(e,(a.get(e)??0)+1);i.push({files:r,occurrences:t,tokenCount:Math.min(...n.map(({tokenCount:e})=>e))})}return i.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:i}}function d(t,n,r){let i=n.get(t.file);if(!i?.tokens)return;let a=r.get(t.file);a||(a=new Set,r.set(t.file,a));for(let n of t.segments)e.collectSegmentLines({startTokenIndex:n.startTokenIndex-i.offset,endTokenIndex:n.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,a)}exports.measureCrossFileDuplication=r;
|
|
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, 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
|
+
{"version":3,"file":"crossFileDuplication.cjs","names":["resolveDuplicationOptions","selectMaximalGroups","collectCrossFileNearMissGroups","buildLiteralCountPrefix","collectSequenceWindowCandidates","mergeAdjacentGroups","countRedundantFragments"],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { collectCrossFileNearMissGroups } from './crossFileNearMiss.js';\nimport { selectMaximalGroups, type SelectableRegion } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n startLine: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /**\n * Token count of the smallest occurrence. For exact and gapped groups it is the matched token\n * count every occurrence shares (gaps are not counted); for near-miss (Type-3) groups it is the\n * token count of the smallest whole block or set of matched cores, edited tokens included.\n */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by the tokens of its cross-file occurrences, sorted\n * ascending: the matched tokens of exact and gapped occurrences, and every token of a near-miss\n * block or its matched cores, edited ones included (like within-file near-miss coverage). The unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the covered lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate, SelectableRegion {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n fileIndex: number;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. A copy nested inside a larger group's region (two files share\n * a whole function, a third file only a block of it) is reported with its group, so the third\n * file's copy still shows what it duplicates. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n * Finally, blocks of files that supplied `nearMissBlocks` are compared across files for near-miss\n * (Type-3) clones under `minSimilarityPercent` (see crossFileNearMiss.ts).\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens, minSimilarityPercent } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n const groups = mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens);\n for (const group of collectNearMissGroups(files, groups, tokenOffsets, minTokens, minSimilarityPercent)) {\n groups.push(group);\n }\n return summarize(groups, files, tokenOffsets);\n}\n\n/** Near-miss groups among the blocks the exact groups leave unreported, in the project token space. */\nfunction collectNearMissGroups(\n files: CrossFileDuplicationSourceFile[],\n exactGroups: CrossFileOccurrence[][],\n tokenOffsets: number[],\n minTokens: number,\n minSimilarityPercent: number\n): CrossFileOccurrence[][] {\n const reportedSpansByFile: { startTokenIndex: number; endTokenIndex: number }[][] = files.map(() => []);\n for (const { fileIndex, startTokenIndex, endTokenIndex } of exactGroups.flat()) {\n const offset = tokenOffsets[fileIndex] ?? 0;\n reportedSpansByFile[fileIndex]?.push({\n startTokenIndex: startTokenIndex - offset,\n endTokenIndex: endTokenIndex - offset,\n });\n }\n return collectCrossFileNearMissGroups(files, reportedSpansByFile, minTokens, minSimilarityPercent).map((group) =>\n group.map((occurrence) => {\n const offset = tokenOffsets[occurrence.fileIndex] ?? 0;\n return {\n ...occurrence,\n file: files[occurrence.fileIndex]?.file ?? '',\n segments: occurrence.segments.map((segment) => ({\n startTokenIndex: segment.startTokenIndex + offset,\n endTokenIndex: segment.endTokenIndex + offset,\n })),\n startTokenIndex: occurrence.startTokenIndex + offset,\n endTokenIndex: occurrence.endTokenIndex + offset,\n };\n })\n );\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\n/** A merged group is reported only while it still covers more than one file. */\nfunction spansMultipleFilesAfterMerge(group: CrossFileOccurrence[]): boolean {\n return new Set(group.map((occurrence) => occurrence.file)).size >= 2;\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n fileIndex: candidate.regionBucket,\n spanCountedElsewhere: candidate.nestedInLargerGroup,\n nestedInLargerGroup: candidate.nestedInLargerGroup,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens, spansMultipleFilesAfterMerge);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n for (const occurrence of group) {\n collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n }\n const occurrences = group\n .map(({ file, startLine, endLine }) => ({ file, startLine, endLine }))\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: Math.min(...group.map(({ tokenCount }) => tokenCount)) });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's segment tokens cover (matched tokens of an exact or gapped\n * occurrence, the whole block or matched cores of a near-miss one) to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream. A file that supplied only\n * candidates (no token stream) is skipped rather than approximated from the bounding line range,\n * which would include gap and comment/blank lines and break the field's exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): void {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return;\n }\n let lines = lineNumbersByFile.get(occurrence.file);\n if (!lines) {\n lines = new Set();\n lineNumbersByFile.set(occurrence.file, lines);\n }\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n}\n"],"mappings":"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"}
|
|
@@ -12,7 +12,11 @@ export interface CrossFileDuplicateOccurrence {
|
|
|
12
12
|
export interface CrossFileDuplicateBlockGroup {
|
|
13
13
|
files: string[];
|
|
14
14
|
occurrences: CrossFileDuplicateOccurrence[];
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Token count of the smallest occurrence. For exact and gapped groups it is the matched token
|
|
17
|
+
* count every occurrence shares (gaps are not counted); for near-miss (Type-3) groups it is the
|
|
18
|
+
* token count of the smallest whole block or set of matched cores, edited tokens included.
|
|
19
|
+
*/
|
|
16
20
|
tokenCount: number;
|
|
17
21
|
}
|
|
18
22
|
export interface CrossFileDuplicationMetrics {
|
|
@@ -21,11 +25,12 @@ export interface CrossFileDuplicationMetrics {
|
|
|
21
25
|
/** Groups the file participates in, keyed by the file name passed in. */
|
|
22
26
|
duplicateBlockGroupCountByFile: Record<string, number>;
|
|
23
27
|
/**
|
|
24
|
-
* Per file, the 1-based code lines covered by
|
|
25
|
-
*
|
|
28
|
+
* Per file, the 1-based code lines covered by the tokens of its cross-file occurrences, sorted
|
|
29
|
+
* ascending: the matched tokens of exact and gapped occurrences, and every token of a near-miss
|
|
30
|
+
* block or its matched cores, edited ones included (like within-file near-miss coverage). The unmatched gap of a merged
|
|
26
31
|
* clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows
|
|
27
32
|
* inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied
|
|
28
|
-
* only candidates (no `tokens`) has no entry — without its token stream the
|
|
33
|
+
* only candidates (no `tokens`) has no entry — without its token stream the covered lines are
|
|
29
34
|
* unknowable, and an approximate bounding range would break this field's exactness.
|
|
30
35
|
*/
|
|
31
36
|
duplicateLineNumbersByFile: Record<string, number[]>;
|
|
@@ -43,5 +48,7 @@ export interface CrossFileDuplicationMetrics {
|
|
|
43
48
|
* a whole function, a third file only a block of it) is reported with its group, so the third
|
|
44
49
|
* file's copy still shows what it duplicates. Groups separated by a small token gap within each file then
|
|
45
50
|
* merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.
|
|
51
|
+
* Finally, blocks of files that supplied `nearMissBlocks` are compared across files for near-miss
|
|
52
|
+
* (Type-3) clones under `minSimilarityPercent` (see crossFileNearMiss.ts).
|
|
46
53
|
*/
|
|
47
54
|
export declare function measureCrossFileDuplication(files: CrossFileDuplicationSourceFile[], options?: DuplicationOptions): CrossFileDuplicationMetrics;
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{buildLiteralCountPrefix as e,collectSegmentLines as t,collectSequenceWindowCandidates as n,countRedundantFragments as r,mergeAdjacentGroups as i,resolveDuplicationOptions as a}from"./duplication.js";import{collectCrossFileNearMissGroups as o}from"./crossFileNearMiss.js";import{selectMaximalGroups as s}from"./duplicateSelection.js";function c(e,t){let{minTokens:n,maxGapTokens:r,minSimilarityPercent:i}=a(t),o=e.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let t of u(e,n))o.push(t);let c=s(o,f,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),d=p(e,r),g=m([...c.values()],d,r);for(let t of l(e,g,d,n,i))g.push(t);return h(g,e,d)}function l(e,t,n,r,i){let a=e.map(()=>[]);for(let{fileIndex:e,startTokenIndex:r,endTokenIndex:i}of t.flat()){let t=n[e]??0;a[e]?.push({startTokenIndex:r-t,endTokenIndex:i-t})}return o(e,a,r,i).map(t=>t.map(t=>{let r=n[t.fileIndex]??0;return{...t,file:e[t.fileIndex]?.file??``,segments:t.segments.map(e=>({startTokenIndex:e.startTokenIndex+r,endTokenIndex:e.endTokenIndex+r})),startTokenIndex:t.startTokenIndex+r,endTokenIndex:t.endTokenIndex+r}}))}function u(t,r){let i=[],a=[];for(let[n,{tokens:r,containerStatements:o}]of t.entries())r&&o&&(i.push(n),a.push({tokens:r,literalCountPrefix:e(r),containers:o}));return a.length<2?[]:n(a,r,!0).flatMap(({candidate:e,contextIndex:n})=>{let r=i[n],a=r===void 0?void 0:t[r];return r===void 0||a===void 0?[]:[{...e,regionBucket:r,file:a.file}]})}function d(e){return new Set(e.map(e=>e.file)).size>=2}function f(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function p(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function m(e,t,n){let r=e.map(e=>e.map(e=>{let n=e.startTokenIndex+(t[e.regionBucket]??0),r=e.endTokenIndex+(t[e.regionBucket]??0);return{file:e.file,fileIndex:e.regionBucket,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:n,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:n,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return i(r,n,d)}function h(e,t,n){let i=[],a=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+=r(t);for(let e of t)g(e,o,s);let e=t.map(({file:e,startLine:t,endLine:n})=>({file:e,startLine:t,endLine:n})).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),n=[...new Set(e.map(({file:e})=>e))];for(let e of n)a.set(e,(a.get(e)??0)+1);i.push({files:n,occurrences:e,tokenCount:Math.min(...t.map(({tokenCount:e})=>e))})}return i.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:i}}function g(e,n,r){let i=n.get(e.file);if(!i?.tokens)return;let a=r.get(e.file);a||(a=new Set,r.set(e.file,a));for(let n of e.segments)t({startTokenIndex:n.startTokenIndex-i.offset,endTokenIndex:n.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,a)}export{c as measureCrossFileDuplication};
|
|
2
2
|
//# sourceMappingURL=crossFileDuplication.js.map
|
|
@@ -1 +1 @@
|
|
|
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
|
+
{"version":3,"file":"crossFileDuplication.js","names":[],"sources":["../src/crossFileDuplication.ts"],"sourcesContent":["import { collectCrossFileNearMissGroups } from './crossFileNearMiss.js';\nimport { selectMaximalGroups, type SelectableRegion } from './duplicateSelection.js';\nimport {\n buildLiteralCountPrefix,\n collectSegmentLines,\n collectSequenceWindowCandidates,\n countRedundantFragments,\n mergeAdjacentGroups,\n resolveDuplicationOptions,\n type CountedOccurrence,\n type CrossFileDuplicateCandidate,\n type CrossFileDuplicationFileData,\n type SequenceWindowContext,\n} from './duplication.js';\nimport type { DuplicationOptions } from './types.js';\n\nexport interface CrossFileDuplicationSourceFile extends Partial<CrossFileDuplicationFileData> {\n file: string;\n candidates: CrossFileDuplicateCandidate[];\n}\n\nexport interface CrossFileDuplicateOccurrence {\n endLine: number;\n file: string;\n startLine: number;\n}\n\nexport interface CrossFileDuplicateBlockGroup {\n files: string[];\n occurrences: CrossFileDuplicateOccurrence[];\n /**\n * Token count of the smallest occurrence. For exact and gapped groups it is the matched token\n * count every occurrence shares (gaps are not counted); for near-miss (Type-3) groups it is the\n * token count of the smallest whole block or set of matched cores, edited tokens included.\n */\n tokenCount: number;\n}\n\nexport interface CrossFileDuplicationMetrics {\n /** Number of redundant copies across all groups, counted per matched fragment like within-file. */\n duplicateBlockCount: number;\n /** Groups the file participates in, keyed by the file name passed in. */\n duplicateBlockGroupCountByFile: Record<string, number>;\n /**\n * Per file, the 1-based code lines covered by the tokens of its cross-file occurrences, sorted\n * ascending: the matched tokens of exact and gapped occurrences, and every token of a near-miss\n * block or its matched cores, edited ones included (like within-file near-miss coverage). The unmatched gap of a merged\n * clone and comment/blank lines inside an occurrence's bounding range are excluded (blank rows\n * inside multi-row tokens only when the file supplied codeLineNumbers). A file that supplied\n * only candidates (no `tokens`) has no entry — without its token stream the covered lines are\n * unknowable, and an approximate bounding range would break this field's exactness.\n */\n duplicateLineNumbersByFile: Record<string, number[]>;\n groups: CrossFileDuplicateBlockGroup[];\n}\n\ninterface SelectableCandidate extends CrossFileDuplicateCandidate, SelectableRegion {\n regionBucket: number;\n file: string;\n}\n\n/** A cross-file occurrence: a within-file occurrence in the project-wide token index space. */\ninterface CrossFileOccurrence extends CountedOccurrence {\n file: string;\n fileIndex: number;\n}\n\n/**\n * Detects code regions duplicated across files. Per-file candidates (whole block subtrees and full\n * container runs, fingerprinted with the same normalization as within-file duplication) are joined\n * by a project-level window index over per-statement fingerprint sequences (CPD-style), so a\n * copy-pasted partial statement run embedded in different surrounding code is matched even though\n * no single file can know it repeats elsewhere. Candidates are grouped by fingerprint, and only\n * maximal, non-overlapping regions whose group spans at least two files are counted. Groups that\n * shrink to a single file during selection are shed — a within-file repeat is already reported by\n * that file's own duplication metrics. A copy nested inside a larger group's region (two files share\n * a whole function, a third file only a block of it) is reported with its group, so the third\n * file's copy still shows what it duplicates. Groups separated by a small token gap within each file then\n * merge into gapped (Type-3) clone groups under `maxGapTokens`, exactly like within-file merging.\n * Finally, blocks of files that supplied `nearMissBlocks` are compared across files for near-miss\n * (Type-3) clones under `minSimilarityPercent` (see crossFileNearMiss.ts).\n */\nexport function measureCrossFileDuplication(\n files: CrossFileDuplicationSourceFile[],\n options?: DuplicationOptions\n): CrossFileDuplicationMetrics {\n const { minTokens, maxGapTokens, minSimilarityPercent } = resolveDuplicationOptions(options);\n const candidates: SelectableCandidate[] = files.flatMap(({ file, candidates }, fileIndex) =>\n candidates.map((candidate) => ({ ...candidate, regionBucket: fileIndex, file }))\n );\n // Pushed one by one: spreading the project-scale window-candidate array as call arguments\n // overflows V8's argument limit (~124k) and crashes on Node, though Bun/JSC tolerates it.\n for (const candidate of collectWindowCandidates(files, minTokens)) {\n candidates.push(candidate);\n }\n const counted = selectMaximalGroups(\n candidates,\n spansMultipleFiles,\n // File index and position break coverage ties deterministically.\n (left, right) => left.regionBucket - right.regionBucket || left.startIndex - right.startIndex\n );\n const tokenOffsets = computeTokenOffsets(files, maxGapTokens);\n const groups = mergeGapAdjacentGroups([...counted.values()], tokenOffsets, maxGapTokens);\n for (const group of collectNearMissGroups(files, groups, tokenOffsets, minTokens, minSimilarityPercent)) {\n groups.push(group);\n }\n return summarize(groups, files, tokenOffsets);\n}\n\n/** Near-miss groups among the blocks the exact groups leave unreported, in the project token space. */\nfunction collectNearMissGroups(\n files: CrossFileDuplicationSourceFile[],\n exactGroups: CrossFileOccurrence[][],\n tokenOffsets: number[],\n minTokens: number,\n minSimilarityPercent: number\n): CrossFileOccurrence[][] {\n const reportedSpansByFile: { startTokenIndex: number; endTokenIndex: number }[][] = files.map(() => []);\n for (const { fileIndex, startTokenIndex, endTokenIndex } of exactGroups.flat()) {\n const offset = tokenOffsets[fileIndex] ?? 0;\n reportedSpansByFile[fileIndex]?.push({\n startTokenIndex: startTokenIndex - offset,\n endTokenIndex: endTokenIndex - offset,\n });\n }\n return collectCrossFileNearMissGroups(files, reportedSpansByFile, minTokens, minSimilarityPercent).map((group) =>\n group.map((occurrence) => {\n const offset = tokenOffsets[occurrence.fileIndex] ?? 0;\n return {\n ...occurrence,\n file: files[occurrence.fileIndex]?.file ?? '',\n segments: occurrence.segments.map((segment) => ({\n startTokenIndex: segment.startTokenIndex + offset,\n endTokenIndex: segment.endTokenIndex + offset,\n })),\n startTokenIndex: occurrence.startTokenIndex + offset,\n endTokenIndex: occurrence.endTokenIndex + offset,\n };\n })\n );\n}\n\n/** Repeated sub-windows of sibling statements matched across the whole project's files. */\nfunction collectWindowCandidates(files: CrossFileDuplicationSourceFile[], minTokens: number): SelectableCandidate[] {\n const fileIndexByContext: number[] = [];\n const contexts: SequenceWindowContext[] = [];\n for (const [fileIndex, { tokens, containerStatements }] of files.entries()) {\n if (tokens && containerStatements) {\n fileIndexByContext.push(fileIndex);\n contexts.push({ tokens, literalCountPrefix: buildLiteralCountPrefix(tokens), containers: containerStatements });\n }\n }\n if (contexts.length < 2) {\n return [];\n }\n return collectSequenceWindowCandidates(contexts, minTokens, true).flatMap(({ candidate, contextIndex }) => {\n const fileIndex = fileIndexByContext[contextIndex];\n const file = fileIndex === undefined ? undefined : files[fileIndex];\n return fileIndex === undefined || file === undefined\n ? []\n : [{ ...candidate, regionBucket: fileIndex, file: file.file }];\n });\n}\n\n/** A merged group is reported only while it still covers more than one file. */\nfunction spansMultipleFilesAfterMerge(group: CrossFileOccurrence[]): boolean {\n return new Set(group.map((occurrence) => occurrence.file)).size >= 2;\n}\n\nfunction spansMultipleFiles(group: SelectableCandidate[]): boolean {\n return group.length >= 2 && new Set(group.map((candidate) => candidate.regionBucket)).size >= 2;\n}\n\n/**\n * Per-file token offsets that map every file into one project-wide token index space: each file's\n * tokens are offset by more than `maxGapTokens` past the previous file's, so occurrences in\n * different files are never gap-adjacent and merged pairs always stay within one file.\n */\nfunction computeTokenOffsets(files: CrossFileDuplicationSourceFile[], maxGapTokens: number): number[] {\n const tokenOffsets: number[] = [];\n let offset = 0;\n for (const { tokens, candidates } of files) {\n tokenOffsets.push(offset);\n // Accumulated in a loop: spreading a project-scale candidate array as call arguments would\n // overflow V8's argument limit (~124k) and crash on Node.\n let tokenCount = tokens?.length ?? 0;\n if (!tokens) {\n for (const candidate of candidates) {\n tokenCount = Math.max(tokenCount, candidate.endTokenIndex);\n }\n }\n offset += tokenCount + maxGapTokens + 1;\n }\n return tokenOffsets;\n}\n\n/** Reuses the within-file gapped (Type-3) merging in the project-wide token index space. */\nfunction mergeGapAdjacentGroups(\n groups: SelectableCandidate[][],\n tokenOffsets: number[],\n maxGapTokens: number\n): CrossFileOccurrence[][] {\n const occurrenceGroups = groups.map((group) =>\n group\n .map((candidate): CrossFileOccurrence => {\n const start = candidate.startTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n const end = candidate.endTokenIndex + (tokenOffsets[candidate.regionBucket] ?? 0);\n return {\n file: candidate.file,\n fileIndex: candidate.regionBucket,\n spanCountedElsewhere: candidate.nestedInLargerGroup,\n nestedInLargerGroup: candidate.nestedInLargerGroup,\n segments: [{ startTokenIndex: start, endTokenIndex: end }],\n tokenCount: candidate.tokenCount,\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: candidate.startIndex,\n endIndex: candidate.endIndex,\n startLine: candidate.startLine,\n endLine: candidate.endLine,\n };\n })\n .toSorted((left, right) => left.startTokenIndex - right.startTokenIndex)\n );\n return mergeAdjacentGroups(occurrenceGroups, maxGapTokens, spansMultipleFilesAfterMerge);\n}\n\nfunction summarize(\n groups: CrossFileOccurrence[][],\n files: CrossFileDuplicationSourceFile[],\n tokenOffsets: number[]\n): CrossFileDuplicationMetrics {\n const reported: CrossFileDuplicateBlockGroup[] = [];\n // Accumulated in Maps: file names are arbitrary strings, and a plain object would read\n // inherited properties for names like \"constructor\".\n const groupCountByFile = new Map<string, number>();\n const fileDataByName = new Map(\n files.map((file, index) => [\n file.file,\n { tokens: file.tokens, codeLineNumbers: file.codeLineNumbers, offset: tokenOffsets[index] ?? 0 },\n ])\n );\n const lineNumbersByFile = new Map<string, Set<number>>();\n let duplicateBlockCount = 0;\n for (const group of groups) {\n // Mirrors within-file counting: each redundant occurrence contributes one count per matched\n // fragment, gapped merging consolidates the grouping without halving the count, and spans a\n // partial merge shares between a retained group and the merged group count once.\n duplicateBlockCount += countRedundantFragments(group);\n for (const occurrence of group) {\n collectOccurrenceLines(occurrence, fileDataByName, lineNumbersByFile);\n }\n const occurrences = group\n .map(({ file, startLine, endLine }) => ({ file, startLine, endLine }))\n .toSorted((left, right) => left.file.localeCompare(right.file) || left.startLine - right.startLine);\n const files = [...new Set(occurrences.map(({ file }) => file))];\n for (const file of files) {\n groupCountByFile.set(file, (groupCountByFile.get(file) ?? 0) + 1);\n }\n reported.push({ files, occurrences, tokenCount: Math.min(...group.map(({ tokenCount }) => tokenCount)) });\n }\n reported.sort(\n (left, right) =>\n right.tokenCount - left.tokenCount ||\n (left.occurrences[0]?.file ?? '').localeCompare(right.occurrences[0]?.file ?? '') ||\n (left.occurrences[0]?.startLine ?? 0) - (right.occurrences[0]?.startLine ?? 0)\n );\n return {\n duplicateBlockCount,\n duplicateBlockGroupCountByFile: Object.fromEntries(groupCountByFile),\n duplicateLineNumbersByFile: Object.fromEntries(\n [...lineNumbersByFile].map(([file, lines]) => [file, [...lines].toSorted((left, right) => left - right)])\n ),\n groups: reported,\n };\n}\n\n/**\n * Adds the code lines an occurrence's segment tokens cover (matched tokens of an exact or gapped\n * occurrence, the whole block or matched cores of a near-miss one) to its file's line set, mapping the\n * project-wide token segments back into the file's own token stream. A file that supplied only\n * candidates (no token stream) is skipped rather than approximated from the bounding line range,\n * which would include gap and comment/blank lines and break the field's exactness contract.\n */\nfunction collectOccurrenceLines(\n occurrence: CrossFileOccurrence,\n fileDataByName: Map<\n string,\n { tokens?: CrossFileDuplicationSourceFile['tokens']; codeLineNumbers?: Set<number>; offset: number }\n >,\n lineNumbersByFile: Map<string, Set<number>>\n): void {\n const fileData = fileDataByName.get(occurrence.file);\n if (!fileData?.tokens) {\n return;\n }\n let lines = lineNumbersByFile.get(occurrence.file);\n if (!lines) {\n lines = new Set();\n lineNumbersByFile.set(occurrence.file, lines);\n }\n for (const segment of occurrence.segments) {\n collectSegmentLines(\n {\n startTokenIndex: segment.startTokenIndex - fileData.offset,\n endTokenIndex: segment.endTokenIndex - fileData.offset,\n },\n fileData.tokens,\n fileData.codeLineNumbers,\n lines\n );\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
"use strict";const e=require("./duplication.cjs");function t(e,t,c,l){if(l>=100)return[];let u=g(e),d=s(u,c,l),f=t.map(i),p=t.map(r),m=u.map(({fileIndex:e,range:t})=>p[e]?.(t)??!1),h=u.map(({fileIndex:e,range:t})=>f[e]?.(t)??!1),_=[];o(u,m,l,(e,t)=>{let n=u[e],r=u[t],i=n&&r&&d(n,r,t);if(i){if(i.kind===`whole`)h[e]&&h[t]||_.push([e,void 0,t,void 0]);else for(let[n,r]of i.cores)_.push([e,n,t,r])}});let v=u.map(()=>!1),y=u.map(()=>[]);for(let[e,t,n,r]of _)for(let[i,a]of[[e,t],[n,r]])a?y[i]?.push(a):v[i]=!0;let b=[],x=[];for(let e of u.keys()){x.push(b.length);let t=v[e]?[]:n(y[e]??[]);t.length===0&&b.push({blockIndex:e,core:void 0});for(let n of t)b.push({blockIndex:e,core:n})}let S=(e,t)=>{let n=x[e]??0;if(!t||v[e])return n;for(let r=n;b[r]?.blockIndex===e;r+=1){let e=b[r]?.core;if(e&&e[0]<=t[0]&&t[1]<=e[1])return r}throw Error(`every local core lies in one of its block's merged cores`)},C=b.map((e,t)=>t),w=e=>{let t=e;for(;C[t]!==t;)t=C[t]??t;for(let n=e;C[n]!==t;){let e=C[n]??t;C[n]=t,n=e}return t},T=b.map(({blockIndex:e,core:t})=>{let n=u[e],[r,i]=t??[n?.range.startTokenIndex??0,n?.range.endTokenIndex??0];return f[n?.fileIndex??0]?.({startTokenIndex:r,endTokenIndex:i})??!1});for(let[e,t,n,r]of _){let i=S(e,t),a=S(n,r);if(T[i]&&T[a])continue;let o=w(i),s=w(a);C[Math.max(o,s)]=Math.min(o,s)}let E=new Map;for(let e of b.keys()){let t=w(e),n=E.get(t)??[];n.push(e),E.set(t,n)}let D=[];for(let t of E.values()){if(t.length<2||t.every(e=>T[e]))continue;let n=new Map;for(let e of t){let{blockIndex:t=0,core:r}=b[e]??{},i=n.get(t)??{cores:[],anchor:!1};i.cores.push(r),i.anchor||=T[e]??!1,n.set(t,i)}D.push([...n].flatMap(([t,{cores:n,anchor:r}])=>{let i=u[t];return i?[a(i,e,n,r)]:[]}))}return D}function n(e){let t=[];for(let[n,r]of e.toSorted((e,t)=>e[0]-t[0])){let e=t.at(-1);e&&n<e[1]?e[1]=Math.max(e[1],r):t.push([n,r])}return t}function r(e){let t=[];for(let{startTokenIndex:n,endTokenIndex:r}of e.toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex)){let e=t.at(-1);e&&n<=e[1]?e[1]=Math.max(e[1],r):t.push([n,r])}return e=>{let n=0,r=t.length;for(;n<r;){let i=n+r>>>1;(t[i]?.[0]??0)<=e.startTokenIndex?n=i+1:r=i}return(t[n-1]?.[1]??-1)>=e.endTokenIndex}}function i(e){let t=e.toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex),n=new Int32Array(t.length),r=-1;for(let[e,i]of t.entries())r=Math.max(r,i.endTokenIndex),n[e]=r;return e=>{let r=0,i=t.length;for(;r<i;){let n=r+i>>>1;(t[n]?.startTokenIndex??0)<e.endTokenIndex?r=n+1:i=n}return r>0&&(n[r-1]??-1)>e.startTokenIndex}}function a({fileIndex:e,range:t},n,r,i){let a=r.includes(void 0),o=r.map(e=>e??[t.startTokenIndex,t.endTokenIndex]).toSorted((e,t)=>e[0]-t[0]).map(([e,t])=>({startTokenIndex:e,endTokenIndex:t})),s=o[0]?.startTokenIndex??t.startTokenIndex,c=o.at(-1)?.endTokenIndex??t.endTokenIndex,l=n[e]?.tokens;return{fileIndex:e,spanCountedElsewhere:i||void 0,segments:o,tokenCount:o.reduce((e,t)=>e+t.endTokenIndex-t.startTokenIndex,0),startTokenIndex:s,endTokenIndex:c,startIndex:t.startIndex,endIndex:t.endIndex,startLine:a?t.startLine:(l?.[s]?.startRow??0)+1,endLine:a?t.endLine:(l?.[c-1]?.endRow??0)+1}}function o(e,t,n,r){let i=new Map;for(let t of e)for(let e of t.ngrams)i.set(e,(i.get(e)??0)+1);for(let t of e)t.ngrams=t.ngrams.filter(e=>(i.get(e)??0)<=1e3);let a=Int32Array.from(e,e=>e.fileIndex),o=Uint8Array.from(t,Number),s=Int32Array.from(e,e=>e.sequence.length),c=Int32Array.from(e,e=>e.ngrams.length),l=[...e.keys()].toSorted((e,t)=>(s[e]??0)-(s[t]??0)),u=new Map,d=new Int32Array(e.length),f=[];for(let t of l){let i=a[t],l=o[t]===1,p=Math.min(Math.ceil((s[t]??0)/3),Math.ceil(n*(s[t]??0)/100)),m=e[t]?.ngrams??[];for(let e of m){let n=u.get(e);if(!n){u.set(e,[t]);continue}for(let e=n.length-1;e>=0;--e){let t=n[e]??0;if((s[t]??0)<p)break;a[t]===i||l&&o[t]===1||(d[t]===0&&f.push(t),d[t]=(d[t]??0)+1)}n.push(t)}for(let e of f){let n=d[e]??0;d[e]=0,n*100>=10*Math.min(c[e]??0,m.length)&&r(e,t)}f.length=0}}function s(t,n,r){let i=new Map;for(let e of t)for(let t of e.contentCounts.keys())i.set(t,(i.get(t)??0)+1);let a=e=>Math.min(31-Math.clz32(Math.floor((t.length+1)/e))+1,3),o=new Map;for(let[e,t]of i)o.set(e,a(t));let s=e=>{let t=Int32Array.from(e.keys()).toSorted(),n=Int32Array.from(t,t=>(e.get(t)??0)*(o.get(t)??0)),r=0;for(let e of n)r+=e;return{symbols:t,weightedCounts:n,total:r}},u=new Map(t.map(e=>[e,s(e.contentCounts)])),d=-1,g,_=(t,n,r)=>((d!==n||!g)&&(d=n,g=e.createLcsLengthCounter(t.sequence)),g(r)),b=(t,i)=>{let a=[];for(let e=0,n=0;e<t.uniqueNgrams.length&&n<i.uniqueNgrams.length;){let r=t.uniqueNgrams[e]??0,o=i.uniqueNgrams[n]??0;r===o&&a.push([t.uniqueNgramOffsets[e]??0,i.uniqueNgramOffsets[n]??0]),r<=o&&(e+=1),o<=r&&(n+=1)}a.sort((e,t)=>e[0]-t[0]);let o=a.filter(([e,t],n)=>{let r=a[n-1],i=a[n+1];return r?.[0]===e-1&&r[1]===t-1||i?.[0]===e+1&&i[1]===t+1}),l=t.range.startTokenIndex,u=i.range.startTokenIndex,d=[];for(let a of p(f(o))){let[o,f]=a[0]??[0,0],[p,h]=a.at(-1)??[0,0],g=p+5,_=h+5,b=g-o,x=_-f,S=Math.min(b,x),C=r*Math.max(b,x);S>=n&&S*100>=C&&m(a)*100>=50*S&&c(s(v(t.symbols,t.isContent,o,g)),s(v(i.symbols,i.isContent,f,_)))&&e.lcsLength(y(t.symbols.subarray(o,g)),y(i.symbols.subarray(f,_)))*100>=C&&d.push([[l+o,l+g],[u+f,u+_]])}return d.length>0?{kind:`local`,cores:d}:void 0};return(e,t,n)=>{let i=r*Math.max(e.sequence.length,t.sequence.length);return Math.min(e.sequence.length,t.sequence.length)*100>=i&&c(u.get(e),u.get(t))&&(h(e.sortedSequence,t.sortedSequence)*100>=i&&_(t,n,e.sequence)*100>=i||l(e,t,i))?{kind:`whole`}:b(e,t)}}function c(e,t){if(!e||!t)return!1;let n=0;for(let r=0,i=0;r<e.symbols.length&&i<t.symbols.length;){let a=e.symbols[r]??0,o=t.symbols[i]??0;a===o&&(n+=Math.min(e.weightedCounts[r]??0,t.weightedCounts[i]??0)),a<=o&&(r+=1),o<=a&&(i+=1)}return n*100>50*Math.max(e.total,t.total)}function l(t,n,r){return t.canonicalSequence!==void 0&&n.canonicalSequence!==void 0&&e.lcsLength(t.canonicalSequence,n.canonicalSequence)*100>=r}function u(e,t,n){if(t.length<2)return;let r=[],i=0;for(let[a,o]of t){let t=a-n;i<t&&r.push(y(e.subarray(i,t))),r.push(y(e.subarray(t,o-n))),i=o-n}i<e.length&&r.push(y(e.subarray(i))),r.sort(d);let a=new Int32Array(e.length),o=0;for(let e of r)a.set(e,o),o+=e.length;return a}function d(e,t){for(let n=0;n<Math.min(e.length,t.length);n+=1){let r=(e[n]??0)-(t[n]??0);if(r!==0)return r}return e.length-t.length}function f(e){let t=[],n=[];for(let[r,[,i]]of e.entries()){let a=0,o=t.length;for(;a<o;){let n=a+o>>>1;(e[t[n]??0]?.[1]??0)<i?a=n+1:o=n}n.push(a>0?t[a-1]??-1:-1),t[a]=r}let r=[];for(let i=t.at(-1)??-1;i>=0;i=n[i]??-1){let t=e[i];t&&r.push(t)}return r.toReversed()}function p(e){let t=[];for(let[n,r]of e.entries()){let i=e[n-1];i!==void 0&&r[0]-(i[0]+5)<=30&&r[1]-(i[1]+5)<=30?t.at(-1)?.push(r):t.push([r])}return t}function m(e){let t=5;for(let n=1;n<e.length;n+=1)t+=Math.min((e[n]?.[0]??0)-(e[n-1]?.[0]??0),5);return t}function h(e,t){let n=0,r=0,i=0;for(;r<e.length&&i<t.length;){let a=e[r]??0,o=t[i]??0;a===o?(n+=1,r+=1,i+=1):a<o?r+=1:i+=1}return n}function g(e){let t=new Map,n=[];for(let[r,{tokens:i,containerStatements:a,nearMissBlocks:o}]of e.entries()){if(!i||!o?.length)continue;let e=new Int32Array(i.length),s=new Uint8Array(i.length),c=new Map;for(let[n,r]of i.entries()){if(r.kind===`id`){let t=c.get(r.text);t===void 0&&(t=c.size,c.set(r.text,t)),e[n]=-(t+1);continue}let i=b(r),a=t.get(i);a===void 0&&(a=t.size,t.set(i,a)),e[n]=a,s[n]=r.isName||r.literalHash!==void 0?1:0}let l=_(a??[]);for(let t of o){let{startTokenIndex:i,endTokenIndex:a}=t,o=e.subarray(i,a),c=s.subarray(i,a),d=y(o),f=x(o),p=new Map;for(let e of f)p.set(e,(p.get(e)??0)+1);let m=f.keys().filter(e=>p.get(f[e]??0)===1).toArray().toSorted((e,t)=>(f[e]??0)-(f[t]??0));n.push({fileIndex:r,range:t,symbols:o,isContent:c,sequence:d,sortedSequence:d.toSorted(),ngrams:Int32Array.from(p.keys()),uniqueNgrams:Int32Array.from(m,e=>f[e]??0),uniqueNgramOffsets:Int32Array.from(m),contentCounts:v(o,c,0,o.length),canonicalSequence:u(o,l(i,a),i)})}}return n}function _(e){let t=e.flat().filter(e=>e.startTokenIndex<e.endTokenIndex).map(e=>[e.startTokenIndex,e.endTokenIndex]).toSorted((e,t)=>e[0]-t[0]||t[1]-e[1]);return(e,n)=>{let r=0,i=t.length;for(;r<i;){let n=r+i>>>1;(t[n]?.[0]??0)<e?r=n+1:i=n}let a=[];for(let i=r;i<t.length;i+=1){let r=t[i];if(!r||r[0]>=n)break;let o=a.at(-1),s=o!==void 0&&r[0]<o[1];r[1]<=n&&(r[0]!==e||r[1]!==n)&&!s&&a.push(r)}return a}}function v(e,t,n,r){let i=new Map;for(let a=n;a<r;a+=1)if(t[a]===1){let t=e[a]??0;i.set(t,(i.get(t)??0)+1)}return i}function y(e){let t=new Map;return e.map(e=>{if(e>=0)return e;let n=t.get(e);return n===void 0&&(n=t.size,t.set(e,n)),-(n+1)})}function b(e){let t=e.textHash^Math.imul(e.literalHash??0,2654435761),n=e.textHash2^Math.imul(e.literalHash2??0,2246822507);return(t>>>0)*2097152+(n>>>11)}function x(e){let t=new Int32Array(Math.max(e.length-5+1,0));for(let n=0;n<t.length;n+=1){let r=5381;for(let t=0;t<5;t+=1){let i=e[n+t]??0;r=Math.imul(r,31)+(i<0?-1:i)|0}t[n]=r}return t}exports.collectCrossFileNearMissGroups=t;
|
|
2
|
+
//# sourceMappingURL=crossFileNearMiss.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crossFileNearMiss.cjs","names":["createLcsLengthCounter","lcsLength"],"sources":["../src/crossFileNearMiss.ts"],"sourcesContent":["import {\n createLcsLengthCounter,\n lcsLength,\n type CountedOccurrence,\n type Token,\n type TokenRange,\n} from './duplication.js';\n\n/**\n * Cross-file near-miss (Type-3) clone detection, following the within-file detector's model\n * (native/src/near_miss.rs): candidate block pairs are filtered through an n-gram inverted index\n * (NIL, Nakagawa et al. 2021), then verified by token-level longest common subsequence against the\n * larger block (NiCad's per-fragment similarity), backed by an information-weighted content gate,\n * with a statement-order-insensitive fallback and a local match over the anchored cores of two\n * blocks. Only pairs of blocks in different files are compared: a same-file pair is the\n * within-file detector's concern.\n */\n\nexport interface NearMissSourceFile {\n tokens?: Token[];\n containerStatements?: TokenRange[][];\n nearMissBlocks?: TokenRange[];\n}\n\n/** One copy (a block or its matched cores) in a near-miss group; anchors carry `spanCountedElsewhere` (see collectCrossFileNearMissGroups). */\nexport interface NearMissOccurrence extends CountedOccurrence {\n fileIndex: number;\n}\n\n/** N-gram size of the candidate index and local-match anchors (NIL's default). */\nconst ngramSize = 5;\n/** Filtration threshold: shared distinct n-grams over the smaller block's (NIL's default). */\nconst filtrationPercent = 10;\n/**\n * Pairs whose longer block exceeds this multiple of the shorter are compared only when whole-block\n * similarity still allows their ratio (below a minSimilarityPercent of 34). The candidate scan\n * stops at this floor while walking length-ordered postings, so pairs of very different lengths are\n * neither counted nor verified; `maxNgramBlockFrequency` is what bounds the scan's total cost.\n */\nconst maxLengthRatio = 3;\n/**\n * A structural match must also share content: more than this percent of the larger side's\n * information-weighted content-bearing tokens (names and literal values), so blocks of the same\n * shape that call different APIs on different data are not clones.\n */\nconst minContentSimilarityPercent = 50;\n/**\n * Caps content weights so only names and values spread over more than a quarter of the blocks are\n * discounted: a family of copies shares its content across several blocks, and uncapped rarity\n * weighting would let each copy's few unique edits outweigh everything the family shares.\n */\nconst maxContentWeight = 3;\n/**\n * Anchors must cover at least this percent of the shorter core: sparser chains are coincidental\n * runs of common n-grams in merely similar-looking code, and the cheap bound spares their content\n * and LCS checks. Not the similarity threshold itself, since n-grams repeated within a block\n * (repetitive statements) never anchor.\n */\nconst minAnchorCoveragePercent = 50;\n/** Anchors farther apart than this (in either block) split a local match into separate chains. */\nconst maxAnchorGapTokens = 30;\n/** Statement-order-insensitive comparison needs this many top-level statements per block. */\nconst minReorderStatementCount = 2;\n/**\n * N-grams occurring in more blocks than this are stop n-grams (syntax boilerplate such as a chain\n * of closing braces), left out of the index and of each block's n-gram count. Counting shared\n * n-grams costs the square of an n-gram's block frequency, so without the cap a project's most\n * common n-grams make filtration quadratic in the block count, while they discriminate nothing.\n */\nconst maxNgramBlockFrequency = 1000;\n\ninterface NormalizedBlock {\n fileIndex: number;\n range: TokenRange;\n /** Interned non-identifier symbols (>= 0) and identifiers as -(file-level id + 1). */\n symbols: Int32Array;\n isContent: Uint8Array;\n /** Identifiers anonymized by first occurrence within the block. */\n sequence: Int32Array;\n /** The sequence sorted, for the token-bag upper bound on the LCS. */\n sortedSequence: Int32Array;\n /** Distinct non-stop n-gram hashes. */\n ngrams: Int32Array;\n /**\n * The n-grams occurring exactly once in the block, sorted, with their offsets in the parallel\n * array: two blocks' local-match anchors intersect by merging.\n */\n uniqueNgrams: Int32Array;\n uniqueNgramOffsets: Int32Array;\n contentCounts: Map<number, number>;\n /**\n * The sequence with its top-level statements in canonical order, when it has enough of them for\n * statement-order-insensitive comparison.\n */\n canonicalSequence: Int32Array | undefined;\n}\n\n/** A verified core in each block of a pair, as file token ranges. */\ntype CorePair = [[number, number], [number, number]];\n\n/** How a verified pair matched: whole blocks, or every anchored core pair (one per gap-split chain segment). */\ntype PairMatch = { kind: 'whole' } | { kind: 'local'; cores: CorePair[] };\n\n/**\n * Clusters verified cross-file near-miss pairs into groups. A node (a whole block or a matched\n * core) overlapping an occurrence of `reportedSpansByFile` (the exact cross-file groups) is an\n * anchor: it links near-miss copies to the content an exact group already reports, and appears in\n * the near-miss group marked `spanCountedElsewhere` so block counting does not count its span\n * twice. Pairs of two anchors are skipped (blocks wholly covered by reported spans are not even\n * compared), and a group needs at least one non-anchor node. A block that matched only locally is\n * reported as its matched cores (overlapping cores merged), each clustered with its own partners,\n * so code no verified pair matched never counts as duplicated.\n */\nexport function collectCrossFileNearMissGroups(\n files: NearMissSourceFile[],\n reportedSpansByFile: { startTokenIndex: number; endTokenIndex: number }[][],\n minTokens: number,\n minSimilarityPercent: number\n): NearMissOccurrence[][] {\n if (minSimilarityPercent >= 100) {\n return [];\n }\n const blocks = normalizeBlocks(files);\n const matcher = createMatcher(blocks, minTokens, minSimilarityPercent);\n const overlapsReportedSpan = reportedSpansByFile.map(createOverlapTest);\n const coveredByReportedSpans = reportedSpansByFile.map(createCoverageTest);\n const fullyReported = blocks.map(({ fileIndex, range }) => coveredByReportedSpans[fileIndex]?.(range) ?? false);\n const touchesReported = blocks.map(({ fileIndex, range }) => overlapsReportedSpan[fileIndex]?.(range) ?? false);\n const edges: [number, [number, number] | undefined, number, [number, number] | undefined][] = [];\n forEachCandidatePair(blocks, fullyReported, minSimilarityPercent, (left, right) => {\n const leftBlock = blocks[left];\n const rightBlock = blocks[right];\n const match = leftBlock && rightBlock && matcher(leftBlock, rightBlock, right);\n if (match) {\n if (match.kind === 'whole') {\n // A whole match between two blocks that both overlap reported spans could never join a\n // group, and recording it would collapse the blocks' core nodes.\n if (!(touchesReported[left] && touchesReported[right])) {\n edges.push([left, undefined, right, undefined]);\n }\n } else {\n for (const [leftCore, rightCore] of match.cores) {\n edges.push([left, leftCore, right, rightCore]);\n }\n }\n }\n });\n\n // Clustering runs over (block, core) nodes: a block with a recorded whole match is one node,\n // and otherwise each union of its overlapping local cores is its own node, so disjoint cores\n // matched with different partners fall into separate groups.\n const matchedWhole = blocks.map(() => false);\n const localCores = blocks.map((): [number, number][] => []);\n for (const [left, leftCore, right, rightCore] of edges) {\n for (const [index, core] of [\n [left, leftCore],\n [right, rightCore],\n ] as const) {\n if (core) {\n localCores[index]?.push(core);\n } else {\n matchedWhole[index] = true;\n }\n }\n }\n const nodes: { blockIndex: number; core: [number, number] | undefined }[] = [];\n const firstNodeByBlock: number[] = [];\n for (const blockIndex of blocks.keys()) {\n firstNodeByBlock.push(nodes.length);\n const cores = matchedWhole[blockIndex] ? [] : mergeOverlappingCores(localCores[blockIndex] ?? []);\n if (cores.length === 0) {\n nodes.push({ blockIndex, core: undefined });\n }\n for (const core of cores) {\n nodes.push({ blockIndex, core });\n }\n }\n const nodeOf = (blockIndex: number, core: [number, number] | undefined): number => {\n const first = firstNodeByBlock[blockIndex] ?? 0;\n if (!core || matchedWhole[blockIndex]) {\n return first;\n }\n for (let node = first; nodes[node]?.blockIndex === blockIndex; node += 1) {\n const span = nodes[node]?.core;\n if (span && span[0] <= core[0] && core[1] <= span[1]) {\n return node;\n }\n }\n throw new Error(\"every local core lies in one of its block's merged cores\");\n };\n\n const parent = nodes.map((_, index) => index);\n const find = (index: number): number => {\n let root = index;\n while (parent[root] !== root) {\n root = parent[root] ?? root;\n }\n for (let current = index; parent[current] !== root;) {\n const next = parent[current] ?? root;\n parent[current] = root;\n current = next;\n }\n return root;\n };\n // Anchoring is judged per node: a core is an anchor only when a reported span overlaps the core\n // itself, not merely elsewhere in its block.\n const anchored = nodes.map(({ blockIndex, core }) => {\n const block = blocks[blockIndex];\n const [startTokenIndex, endTokenIndex] = core ?? [\n block?.range.startTokenIndex ?? 0,\n block?.range.endTokenIndex ?? 0,\n ];\n return overlapsReportedSpan[block?.fileIndex ?? 0]?.({ startTokenIndex, endTokenIndex }) ?? false;\n });\n for (const [left, leftCore, right, rightCore] of edges) {\n const leftNode = nodeOf(left, leftCore);\n const rightNode = nodeOf(right, rightCore);\n if (anchored[leftNode] && anchored[rightNode]) {\n continue;\n }\n const leftRoot = find(leftNode);\n const rightRoot = find(rightNode);\n parent[Math.max(leftRoot, rightRoot)] = Math.min(leftRoot, rightRoot);\n }\n\n const membersByRoot = new Map<number, number[]>();\n for (const node of nodes.keys()) {\n const root = find(node);\n const members = membersByRoot.get(root) ?? [];\n members.push(node);\n membersByRoot.set(root, members);\n }\n const groups: NearMissOccurrence[][] = [];\n for (const members of membersByRoot.values()) {\n // Components form only through cross-file pairs, so two members always span two files.\n if (members.length < 2 || members.every((node) => anchored[node])) {\n continue;\n }\n // A group's nodes from one block become ONE occurrence whose segments are its cores, so the\n // fragment-weighted count charges the block as one copy (as for gapped clones), not once per core.\n const coresByBlock = new Map<number, { cores: ([number, number] | undefined)[]; anchor: boolean }>();\n for (const node of members) {\n const { blockIndex = 0, core } = nodes[node] ?? {};\n const entry = coresByBlock.get(blockIndex) ?? { cores: [], anchor: false };\n entry.cores.push(core);\n entry.anchor ||= anchored[node] ?? false;\n coresByBlock.set(blockIndex, entry);\n }\n groups.push(\n [...coresByBlock].flatMap(([blockIndex, { cores, anchor }]) => {\n const block = blocks[blockIndex];\n return block ? [toOccurrence(block, files, cores, anchor)] : [];\n })\n );\n }\n return groups;\n}\n\n/** The unions of overlapping cores, in position order. */\nfunction mergeOverlappingCores(cores: [number, number][]): [number, number][] {\n const merged: [number, number][] = [];\n for (const [start, end] of cores.toSorted((left, right) => left[0] - right[0])) {\n const last = merged.at(-1);\n if (last && start < last[1]) {\n last[1] = Math.max(last[1], end);\n } else {\n merged.push([start, end]);\n }\n }\n return merged;\n}\n\n/** Whether the spans, merged, cover every token of a range. */\nfunction createCoverageTest(\n spans: { startTokenIndex: number; endTokenIndex: number }[]\n): (range: { startTokenIndex: number; endTokenIndex: number }) => boolean {\n // Touching spans merge too: together they cover a range across their boundary.\n const merged: [number, number][] = [];\n for (const { startTokenIndex, endTokenIndex } of spans.toSorted(\n (left, right) => left.startTokenIndex - right.startTokenIndex\n )) {\n const last = merged.at(-1);\n if (last && startTokenIndex <= last[1]) {\n last[1] = Math.max(last[1], endTokenIndex);\n } else {\n merged.push([startTokenIndex, endTokenIndex]);\n }\n }\n return (range) => {\n let low = 0;\n let high = merged.length;\n while (low < high) {\n const middle = (low + high) >>> 1;\n if ((merged[middle]?.[0] ?? 0) <= range.startTokenIndex) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return (merged[low - 1]?.[1] ?? -1) >= range.endTokenIndex;\n };\n}\n\n/**\n * Whether a range overlaps any of the spans: among the spans starting before the range ends\n * (binary search over sorted starts), the furthest end reaches past the range's start.\n */\nfunction createOverlapTest(\n spans: { startTokenIndex: number; endTokenIndex: number }[]\n): (range: { startTokenIndex: number; endTokenIndex: number }) => boolean {\n const sorted = spans.toSorted((left, right) => left.startTokenIndex - right.startTokenIndex);\n const maxEndPrefix = new Int32Array(sorted.length);\n let maxEnd = -1;\n for (const [index, span] of sorted.entries()) {\n maxEnd = Math.max(maxEnd, span.endTokenIndex);\n maxEndPrefix[index] = maxEnd;\n }\n return (range) => {\n let low = 0;\n let high = sorted.length;\n while (low < high) {\n const middle = (low + high) >>> 1;\n if ((sorted[middle]?.startTokenIndex ?? 0) < range.endTokenIndex) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return low > 0 && (maxEndPrefix[low - 1] ?? -1) > range.startTokenIndex;\n };\n}\n\n/**\n * The block's occurrence with one segment per entry of `cores` (the whole block for `undefined`,\n * a whole match). Source offsets stay the block's: tokens carry none, and near-miss occurrences\n * report lines only.\n */\nfunction toOccurrence(\n { fileIndex, range }: NormalizedBlock,\n files: NearMissSourceFile[],\n cores: ([number, number] | undefined)[],\n anchor: boolean\n): NearMissOccurrence {\n const whole = cores.includes(undefined);\n const segments = cores\n .map((core): [number, number] => core ?? [range.startTokenIndex, range.endTokenIndex])\n .toSorted((left, right) => left[0] - right[0])\n .map(([startTokenIndex, endTokenIndex]) => ({ startTokenIndex, endTokenIndex }));\n const start = segments[0]?.startTokenIndex ?? range.startTokenIndex;\n const end = segments.at(-1)?.endTokenIndex ?? range.endTokenIndex;\n const tokens = files[fileIndex]?.tokens;\n return {\n fileIndex,\n spanCountedElsewhere: anchor || undefined,\n segments,\n tokenCount: segments.reduce((sum, segment) => sum + segment.endTokenIndex - segment.startTokenIndex, 0),\n startTokenIndex: start,\n endTokenIndex: end,\n startIndex: range.startIndex,\n endIndex: range.endIndex,\n startLine: whole ? range.startLine : (tokens?.[start]?.startRow ?? 0) + 1,\n endLine: whole ? range.endLine : (tokens?.[end - 1]?.endRow ?? 0) + 1,\n };\n}\n\n/**\n * Visits every cross-file block pair sharing at least `filtrationPercent` of the smaller block's\n * non-stop n-grams, except pairs of two blocks wholly covered by reported spans and pairs whose\n * length ratio rules out both\n * whole-block similarity and `maxLengthRatio`. Blocks are\n * indexed in ascending length, so each posting list is scanned backwards only while its blocks\n * are long enough; shared counts accumulate in a dense counter, so no pair map is materialized.\n */\nfunction forEachCandidatePair(\n blocks: NormalizedBlock[],\n fullyReported: boolean[],\n minSimilarityPercent: number,\n visit: (left: number, right: number) => void\n): void {\n const blockFrequency = new Map<number, number>();\n for (const block of blocks) {\n for (const ngram of block.ngrams) {\n blockFrequency.set(ngram, (blockFrequency.get(ngram) ?? 0) + 1);\n }\n }\n for (const block of blocks) {\n block.ngrams = block.ngrams.filter((ngram) => (blockFrequency.get(ngram) ?? 0) <= maxNgramBlockFrequency);\n }\n\n // Typed copies keep the posting loop, which dominates this phase, free of object dereferences.\n const fileIndexes = Int32Array.from(blocks, (block) => block.fileIndex);\n const reportedFlags = Uint8Array.from(fullyReported, Number);\n const lengths = Int32Array.from(blocks, (block) => block.sequence.length);\n const ngramCounts = Int32Array.from(blocks, (block) => block.ngrams.length);\n const order = [...blocks.keys()].toSorted((left, right) => (lengths[left] ?? 0) - (lengths[right] ?? 0));\n const postings = new Map<number, number[]>();\n const sharedCounts = new Int32Array(blocks.length);\n const touched: number[] = [];\n for (const right of order) {\n const fileIndex = fileIndexes[right];\n const rightReported = reportedFlags[right] === 1;\n const minLeftLength = Math.min(\n Math.ceil((lengths[right] ?? 0) / maxLengthRatio),\n Math.ceil((minSimilarityPercent * (lengths[right] ?? 0)) / 100)\n );\n const ngrams = blocks[right]?.ngrams ?? [];\n for (const ngram of ngrams) {\n const posting = postings.get(ngram);\n if (!posting) {\n postings.set(ngram, [right]);\n continue;\n }\n for (let position = posting.length - 1; position >= 0; position -= 1) {\n const left = posting[position] ?? 0;\n if ((lengths[left] ?? 0) < minLeftLength) {\n break;\n }\n if (fileIndexes[left] === fileIndex || (rightReported && reportedFlags[left] === 1)) {\n continue;\n }\n if (sharedCounts[left] === 0) {\n touched.push(left);\n }\n sharedCounts[left] = (sharedCounts[left] ?? 0) + 1;\n }\n posting.push(right);\n }\n for (const left of touched) {\n const shared = sharedCounts[left] ?? 0;\n sharedCounts[left] = 0;\n if (shared * 100 >= filtrationPercent * Math.min(ngramCounts[left] ?? 0, ngrams.length)) {\n visit(left, right);\n }\n }\n touched.length = 0;\n }\n}\n\n/**\n * Returns the pair verifier. Content symbols are weighted by integer self-information,\n * 1 + floor(log2((N + 1) / df)) over N blocks capped at `maxContentWeight`, so rare names and\n * values (the logic a copy preserves) outweigh ubiquitous ones, following the\n * information-theoretic weighting of ECScan's essence-clone detection (2025).\n */\nfunction createMatcher(\n blocks: NormalizedBlock[],\n minTokens: number,\n minSimilarityPercent: number\n): (left: NormalizedBlock, right: NormalizedBlock, rightIndex: number) => PairMatch | undefined {\n const documentFrequencies = new Map<number, number>();\n for (const block of blocks) {\n for (const symbol of block.contentCounts.keys()) {\n documentFrequencies.set(symbol, (documentFrequencies.get(symbol) ?? 0) + 1);\n }\n }\n const selfInformation = (documentFrequency: number): number =>\n Math.min(31 - Math.clz32(Math.floor((blocks.length + 1) / documentFrequency)) + 1, maxContentWeight);\n const weights = new Map<number, number>();\n for (const [symbol, frequency] of documentFrequencies) {\n weights.set(symbol, selfInformation(frequency));\n }\n const weigh = (counts: Map<number, number>): WeightedContent => {\n const symbols = Int32Array.from(counts.keys()).toSorted();\n // Span content comes from blocks, so every symbol has a weight.\n const weightedCounts = Int32Array.from(symbols, (symbol) => (counts.get(symbol) ?? 0) * (weights.get(symbol) ?? 0));\n let total = 0;\n for (const count of weightedCounts) {\n total += count;\n }\n return { symbols, weightedCounts, total };\n };\n const blockContents = new Map(blocks.map((block) => [block, weigh(block.contentCounts)]));\n\n // Every candidate pair of one `right` block is visited consecutively, so one LCS counter (its\n // position masks built once) serves them all.\n let counterBlock = -1;\n let counter: ((sequence: Int32Array) => number) | undefined;\n const lcsLengthWithRight = (right: NormalizedBlock, rightIndex: number, sequence: Int32Array): number => {\n if (counterBlock !== rightIndex || !counter) {\n counterBlock = rightIndex;\n counter = createLcsLengthCounter(right.sequence);\n }\n return counter(sequence);\n };\n\n /**\n * Matches the cores two blocks share inside different surroundings (a copy wrapped in added\n * code, or two copies embedded in different code), which whole-block similarity misses\n * (CCAligner's large-gap and LVMapper's large-variance clones). N-grams unique to each block\n * anchor the alignment; their longest chain increasing in both blocks (a run filter keeps only\n * anchors continuing a diagonal, but the chain may shift diagonals at small insertions), split at\n * gaps, delimits the cores, and every core pair that is a near-miss clone in its own right is\n * returned.\n */\n const matchLocally = (left: NormalizedBlock, right: NormalizedBlock): PairMatch | undefined => {\n const anchors: [number, number][] = [];\n for (\n let leftIndex = 0, rightIndex = 0;\n leftIndex < left.uniqueNgrams.length && rightIndex < right.uniqueNgrams.length;\n ) {\n const leftHash = left.uniqueNgrams[leftIndex] ?? 0;\n const rightHash = right.uniqueNgrams[rightIndex] ?? 0;\n if (leftHash === rightHash) {\n anchors.push([left.uniqueNgramOffsets[leftIndex] ?? 0, right.uniqueNgramOffsets[rightIndex] ?? 0]);\n }\n if (leftHash <= rightHash) {\n leftIndex += 1;\n }\n if (rightHash <= leftHash) {\n rightIndex += 1;\n }\n }\n anchors.sort((first, second) => first[0] - second[0]);\n // An isolated 5-gram match is often coincidental (n-grams are identifier-blind); a copied core\n // yields runs of consecutive anchors, so only anchors continuing a diagonal run are chained.\n const runAnchors = anchors.filter(([leftOffset, rightOffset], index) => {\n const previous = anchors[index - 1];\n const next = anchors[index + 1];\n return (\n (previous?.[0] === leftOffset - 1 && previous[1] === rightOffset - 1) ||\n (next?.[0] === leftOffset + 1 && next[1] === rightOffset + 1)\n );\n });\n const leftOffset = left.range.startTokenIndex;\n const rightOffset = right.range.startTokenIndex;\n const cores: CorePair[] = [];\n for (const segment of chainSegments(longestIncreasingChain(runAnchors))) {\n const [leftStart, rightStart] = segment[0] ?? [0, 0];\n const [leftLast, rightLast] = segment.at(-1) ?? [0, 0];\n const leftEnd = leftLast + ngramSize;\n const rightEnd = rightLast + ngramSize;\n const leftLength = leftEnd - leftStart;\n const rightLength = rightEnd - rightStart;\n const shorter = Math.min(leftLength, rightLength);\n const required = minSimilarityPercent * Math.max(leftLength, rightLength);\n if (\n shorter >= minTokens &&\n shorter * 100 >= required &&\n anchoredTokenCount(segment) * 100 >= minAnchorCoveragePercent * shorter &&\n sharesContent(\n weigh(countContent(left.symbols, left.isContent, leftStart, leftEnd)),\n weigh(countContent(right.symbols, right.isContent, rightStart, rightEnd))\n ) &&\n lcsLength(\n anonymize(left.symbols.subarray(leftStart, leftEnd)),\n anonymize(right.symbols.subarray(rightStart, rightEnd))\n ) *\n 100 >=\n required\n ) {\n cores.push([\n [leftOffset + leftStart, leftOffset + leftEnd],\n [rightOffset + rightStart, rightOffset + rightEnd],\n ]);\n }\n }\n return cores.length > 0 ? { kind: 'local', cores } : undefined;\n };\n\n /** Cheapest bounds first: the LCS cannot exceed the shorter block's length nor the bag overlap. */\n return (left, right, rightIndex) => {\n const required = minSimilarityPercent * Math.max(left.sequence.length, right.sequence.length);\n if (\n Math.min(left.sequence.length, right.sequence.length) * 100 >= required &&\n sharesContent(blockContents.get(left), blockContents.get(right)) &&\n ((sortedOverlap(left.sortedSequence, right.sortedSequence) * 100 >= required &&\n lcsLengthWithRight(right, rightIndex, left.sequence) * 100 >= required) ||\n matchesReordered(left, right, required))\n ) {\n return { kind: 'whole' };\n }\n return matchLocally(left, right);\n };\n}\n\n/** Content-bearing symbols, sorted, with their information-weighted counts. */\ninterface WeightedContent {\n symbols: Int32Array;\n weightedCounts: Int32Array;\n total: number;\n}\n\n/**\n * A structural match must be backed by shared content: more than `minContentSimilarityPercent` of\n * the larger side's information-weighted names and literal values. Two sides without content never\n * pass.\n */\nfunction sharesContent(left: WeightedContent | undefined, right: WeightedContent | undefined): boolean {\n if (!left || !right) {\n return false;\n }\n let overlap = 0;\n for (let leftIndex = 0, rightIndex = 0; leftIndex < left.symbols.length && rightIndex < right.symbols.length;) {\n const leftSymbol = left.symbols[leftIndex] ?? 0;\n const rightSymbol = right.symbols[rightIndex] ?? 0;\n if (leftSymbol === rightSymbol) {\n overlap += Math.min(left.weightedCounts[leftIndex] ?? 0, right.weightedCounts[rightIndex] ?? 0);\n }\n if (leftSymbol <= rightSymbol) {\n leftIndex += 1;\n }\n if (rightSymbol <= leftSymbol) {\n rightIndex += 1;\n }\n }\n return overlap * 100 > minContentSimilarityPercent * Math.max(left.total, right.total);\n}\n\n/**\n * Compares the blocks with their top-level statements (each anonymized on its own) in a canonical\n * order, so a copy whose independent statements were swapped still matches.\n */\nfunction matchesReordered(left: NormalizedBlock, right: NormalizedBlock, required: number): boolean {\n return (\n left.canonicalSequence !== undefined &&\n right.canonicalSequence !== undefined &&\n lcsLength(left.canonicalSequence, right.canonicalSequence) * 100 >= required\n );\n}\n\n/**\n * The block's units (its top-level statements, given in file token indexes, and the token runs\n * between them), each anonymized on its own and sorted, concatenated; undefined with too few\n * statements.\n */\nfunction canonicalSequenceOf(\n symbols: Int32Array,\n statements: [number, number][],\n blockStart: number\n): Int32Array | undefined {\n if (statements.length < minReorderStatementCount) {\n return undefined;\n }\n const units: Int32Array[] = [];\n let cursor = 0;\n for (const [statementStart, statementEnd] of statements) {\n const start = statementStart - blockStart;\n if (cursor < start) {\n units.push(anonymize(symbols.subarray(cursor, start)));\n }\n units.push(anonymize(symbols.subarray(start, statementEnd - blockStart)));\n cursor = statementEnd - blockStart;\n }\n if (cursor < symbols.length) {\n units.push(anonymize(symbols.subarray(cursor)));\n }\n units.sort(compareSequences);\n const canonical = new Int32Array(symbols.length);\n let offset = 0;\n for (const unit of units) {\n canonical.set(unit, offset);\n offset += unit.length;\n }\n return canonical;\n}\n\n/** Lexicographic order, matching Rust's Vec<i32> ordering. */\nfunction compareSequences(left: Int32Array, right: Int32Array): number {\n for (let index = 0; index < Math.min(left.length, right.length); index += 1) {\n const difference = (left[index] ?? 0) - (right[index] ?? 0);\n if (difference !== 0) {\n return difference;\n }\n }\n return left.length - right.length;\n}\n\n/**\n * The longest chain of anchors increasing in both blocks (anchors arrive sorted by left offset),\n * via patience sorting over right offsets.\n */\nfunction longestIncreasingChain(anchors: [number, number][]): [number, number][] {\n const tailIndexes: number[] = [];\n const predecessors: number[] = [];\n for (const [index, [, rightOffset]] of anchors.entries()) {\n let low = 0;\n let high = tailIndexes.length;\n while (low < high) {\n const middle = (low + high) >>> 1;\n if ((anchors[tailIndexes[middle] ?? 0]?.[1] ?? 0) < rightOffset) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n predecessors.push(low > 0 ? (tailIndexes[low - 1] ?? -1) : -1);\n tailIndexes[low] = index;\n }\n const chain: [number, number][] = [];\n for (let cursor = tailIndexes.at(-1) ?? -1; cursor >= 0; cursor = predecessors[cursor] ?? -1) {\n const anchor = anchors[cursor];\n if (anchor) {\n chain.push(anchor);\n }\n }\n return chain.toReversed();\n}\n\n/** The chain's segments, split where consecutive anchors lie more than `maxAnchorGapTokens` apart in either block. */\nfunction chainSegments(chain: [number, number][]): [number, number][][] {\n const segments: [number, number][][] = [];\n for (const [index, anchor] of chain.entries()) {\n const previous = chain[index - 1];\n const continues =\n previous !== undefined &&\n anchor[0] - (previous[0] + ngramSize) <= maxAnchorGapTokens &&\n anchor[1] - (previous[1] + ngramSize) <= maxAnchorGapTokens;\n if (continues) {\n segments.at(-1)?.push(anchor);\n } else {\n segments.push([anchor]);\n }\n }\n return segments;\n}\n\n/** Left-block tokens the segment's anchors cover (overlapping anchors count once). */\nfunction anchoredTokenCount(segment: [number, number][]): number {\n let count = ngramSize;\n for (let index = 1; index < segment.length; index += 1) {\n count += Math.min((segment[index]?.[0] ?? 0) - (segment[index - 1]?.[0] ?? 0), ngramSize);\n }\n return count;\n}\n\n/** Multiset intersection size of two ascending arrays. */\nfunction sortedOverlap(left: Int32Array, right: Int32Array): number {\n let overlap = 0;\n let leftIndex = 0;\n let rightIndex = 0;\n while (leftIndex < left.length && rightIndex < right.length) {\n const leftValue = left[leftIndex] ?? 0;\n const rightValue = right[rightIndex] ?? 0;\n if (leftValue === rightValue) {\n overlap += 1;\n leftIndex += 1;\n rightIndex += 1;\n } else if (leftValue < rightValue) {\n leftIndex += 1;\n } else {\n rightIndex += 1;\n }\n }\n return overlap;\n}\n\n/**\n * Normalizes every block like the within-file detector. Non-identifier symbols are interned\n * project-wide from the tokens' hash pairs, so equal tokens compare equal across files, while\n * identifiers are interned per file and re-anonymized per compared range.\n */\nfunction normalizeBlocks(files: NearMissSourceFile[]): NormalizedBlock[] {\n const symbolByTokenKey = new Map<number, number>();\n const blocks: NormalizedBlock[] = [];\n for (const [fileIndex, { tokens, containerStatements, nearMissBlocks }] of files.entries()) {\n if (!tokens || !nearMissBlocks?.length) {\n continue;\n }\n const symbols = new Int32Array(tokens.length);\n const isContent = new Uint8Array(tokens.length);\n const idByIdentifier = new Map<string, number>();\n for (const [index, token] of tokens.entries()) {\n if (token.kind === 'id') {\n let id = idByIdentifier.get(token.text);\n if (id === undefined) {\n id = idByIdentifier.size;\n idByIdentifier.set(token.text, id);\n }\n symbols[index] = -(id + 1);\n continue;\n }\n const key = tokenKey(token);\n let symbol = symbolByTokenKey.get(key);\n if (symbol === undefined) {\n symbol = symbolByTokenKey.size;\n symbolByTokenKey.set(key, symbol);\n }\n symbols[index] = symbol;\n isContent[index] = token.isName || token.literalHash !== undefined ? 1 : 0;\n }\n const findStatements = createTopLevelStatementFinder(containerStatements ?? []);\n for (const range of nearMissBlocks) {\n const { startTokenIndex: start, endTokenIndex: end } = range;\n const blockSymbols = symbols.subarray(start, end);\n const blockIsContent = isContent.subarray(start, end);\n const sequence = anonymize(blockSymbols);\n const ngramHashes = collectNgramHashes(blockSymbols);\n const occurrenceCounts = new Map<number, number>();\n for (const hash of ngramHashes) {\n occurrenceCounts.set(hash, (occurrenceCounts.get(hash) ?? 0) + 1);\n }\n const uniqueOffsets = ngramHashes\n .keys()\n .filter((offset) => occurrenceCounts.get(ngramHashes[offset] ?? 0) === 1)\n .toArray()\n .toSorted((first, second) => (ngramHashes[first] ?? 0) - (ngramHashes[second] ?? 0));\n blocks.push({\n fileIndex,\n range,\n symbols: blockSymbols,\n isContent: blockIsContent,\n sequence,\n sortedSequence: sequence.toSorted(),\n ngrams: Int32Array.from(occurrenceCounts.keys()),\n uniqueNgrams: Int32Array.from(uniqueOffsets, (offset) => ngramHashes[offset] ?? 0),\n uniqueNgramOffsets: Int32Array.from(uniqueOffsets),\n contentCounts: countContent(blockSymbols, blockIsContent, 0, blockSymbols.length),\n canonicalSequence: canonicalSequenceOf(blockSymbols, findStatements(start, end), start),\n });\n }\n }\n return blocks;\n}\n\n/**\n * Returns a lookup of the outermost container statements inside a token range, excluding a\n * statement spanning the whole range (the block itself).\n */\nfunction createTopLevelStatementFinder(\n containerStatements: TokenRange[][]\n): (start: number, end: number) => [number, number][] {\n const statements = containerStatements\n .flat()\n .filter((statement) => statement.startTokenIndex < statement.endTokenIndex)\n .map((statement): [number, number] => [statement.startTokenIndex, statement.endTokenIndex])\n .toSorted((left, right) => left[0] - right[0] || right[1] - left[1]);\n return (start, end) => {\n let low = 0;\n let high = statements.length;\n while (low < high) {\n const middle = (low + high) >>> 1;\n if ((statements[middle]?.[0] ?? 0) < start) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n const topLevel: [number, number][] = [];\n for (let index = low; index < statements.length; index += 1) {\n const statement = statements[index];\n if (!statement || statement[0] >= end) {\n break;\n }\n const last = topLevel.at(-1);\n const nested = last !== undefined && statement[0] < last[1];\n if (statement[1] <= end && !(statement[0] === start && statement[1] === end) && !nested) {\n topLevel.push(statement);\n }\n }\n return topLevel;\n };\n}\n\nfunction countContent(symbols: Int32Array, isContent: Uint8Array, start: number, end: number): Map<number, number> {\n const counts = new Map<number, number>();\n for (let offset = start; offset < end; offset += 1) {\n if (isContent[offset] === 1) {\n const symbol = symbols[offset] ?? 0;\n counts.set(symbol, (counts.get(symbol) ?? 0) + 1);\n }\n }\n return counts;\n}\n\n/** Identifiers renumbered by first occurrence within `symbols`, so a range compares the same wherever it sits in its file. */\nfunction anonymize(symbols: Int32Array): Int32Array {\n const indexByIdentifier = new Map<number, number>();\n return symbols.map((symbol) => {\n if (symbol >= 0) {\n return symbol;\n }\n let index = indexByIdentifier.get(symbol);\n if (index === undefined) {\n index = indexByIdentifier.size;\n indexByIdentifier.set(symbol, index);\n }\n return -(index + 1);\n });\n}\n\n/**\n * A 53-bit key from the token's two independent text hashes, each mixed with the matching literal\n * value hash: exact in a JavaScript number, so interning never merges distinct tokens unless 53\n * hash bits collide.\n */\nfunction tokenKey(token: Token): number {\n const primary = token.textHash ^ Math.imul(token.literalHash ?? 0, 0x9E_37_79_B1);\n const secondary = token.textHash2 ^ Math.imul(token.literalHash2 ?? 0, 0x85_EB_CA_6B);\n return (primary >>> 0) * 0x20_00_00 + (secondary >>> 11);\n}\n\n/**\n * N-gram hash per start offset, identifier-blind (every identifier hashes as -1) so a block copied\n * into different surroundings (renumbering its identifiers) or with reordered statements still\n * shares its n-grams.\n */\nfunction collectNgramHashes(symbols: Int32Array): Int32Array {\n const hashes = new Int32Array(Math.max(symbols.length - ngramSize + 1, 0));\n for (let start = 0; start < hashes.length; start += 1) {\n let hash = 5381;\n for (let offset = 0; offset < ngramSize; offset += 1) {\n const symbol = symbols[start + offset] ?? 0;\n // oxlint-disable-next-line unicorn/prefer-math-trunc -- `| 0` wraps the sum to int32 like the native n-gram hash.\n hash = (Math.imul(hash, 31) + (symbol < 0 ? -1 : symbol)) | 0;\n }\n hashes[start] = hash;\n }\n return hashes;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type CountedOccurrence, type Token, type TokenRange } from './duplication.js';
|
|
2
|
+
/**
|
|
3
|
+
* Cross-file near-miss (Type-3) clone detection, following the within-file detector's model
|
|
4
|
+
* (native/src/near_miss.rs): candidate block pairs are filtered through an n-gram inverted index
|
|
5
|
+
* (NIL, Nakagawa et al. 2021), then verified by token-level longest common subsequence against the
|
|
6
|
+
* larger block (NiCad's per-fragment similarity), backed by an information-weighted content gate,
|
|
7
|
+
* with a statement-order-insensitive fallback and a local match over the anchored cores of two
|
|
8
|
+
* blocks. Only pairs of blocks in different files are compared: a same-file pair is the
|
|
9
|
+
* within-file detector's concern.
|
|
10
|
+
*/
|
|
11
|
+
export interface NearMissSourceFile {
|
|
12
|
+
tokens?: Token[];
|
|
13
|
+
containerStatements?: TokenRange[][];
|
|
14
|
+
nearMissBlocks?: TokenRange[];
|
|
15
|
+
}
|
|
16
|
+
/** One copy (a block or its matched cores) in a near-miss group; anchors carry `spanCountedElsewhere` (see collectCrossFileNearMissGroups). */
|
|
17
|
+
export interface NearMissOccurrence extends CountedOccurrence {
|
|
18
|
+
fileIndex: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Clusters verified cross-file near-miss pairs into groups. A node (a whole block or a matched
|
|
22
|
+
* core) overlapping an occurrence of `reportedSpansByFile` (the exact cross-file groups) is an
|
|
23
|
+
* anchor: it links near-miss copies to the content an exact group already reports, and appears in
|
|
24
|
+
* the near-miss group marked `spanCountedElsewhere` so block counting does not count its span
|
|
25
|
+
* twice. Pairs of two anchors are skipped (blocks wholly covered by reported spans are not even
|
|
26
|
+
* compared), and a group needs at least one non-anchor node. A block that matched only locally is
|
|
27
|
+
* reported as its matched cores (overlapping cores merged), each clustered with its own partners,
|
|
28
|
+
* so code no verified pair matched never counts as duplicated.
|
|
29
|
+
*/
|
|
30
|
+
export declare function collectCrossFileNearMissGroups(files: NearMissSourceFile[], reportedSpansByFile: {
|
|
31
|
+
startTokenIndex: number;
|
|
32
|
+
endTokenIndex: number;
|
|
33
|
+
}[][], minTokens: number, minSimilarityPercent: number): NearMissOccurrence[][];
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{createLcsLengthCounter as e,lcsLength as t}from"./duplication.js";function n(e,t,n,l){if(l>=100)return[];let u=_(e),d=c(u,n,l),f=t.map(a),p=t.map(i),m=u.map(({fileIndex:e,range:t})=>p[e]?.(t)??!1),h=u.map(({fileIndex:e,range:t})=>f[e]?.(t)??!1),g=[];s(u,m,l,(e,t)=>{let n=u[e],r=u[t],i=n&&r&&d(n,r,t);if(i){if(i.kind===`whole`)h[e]&&h[t]||g.push([e,void 0,t,void 0]);else for(let[n,r]of i.cores)g.push([e,n,t,r])}});let v=u.map(()=>!1),y=u.map(()=>[]);for(let[e,t,n,r]of g)for(let[i,a]of[[e,t],[n,r]])a?y[i]?.push(a):v[i]=!0;let b=[],x=[];for(let e of u.keys()){x.push(b.length);let t=v[e]?[]:r(y[e]??[]);t.length===0&&b.push({blockIndex:e,core:void 0});for(let n of t)b.push({blockIndex:e,core:n})}let S=(e,t)=>{let n=x[e]??0;if(!t||v[e])return n;for(let r=n;b[r]?.blockIndex===e;r+=1){let e=b[r]?.core;if(e&&e[0]<=t[0]&&t[1]<=e[1])return r}throw Error(`every local core lies in one of its block's merged cores`)},C=b.map((e,t)=>t),w=e=>{let t=e;for(;C[t]!==t;)t=C[t]??t;for(let n=e;C[n]!==t;){let e=C[n]??t;C[n]=t,n=e}return t},T=b.map(({blockIndex:e,core:t})=>{let n=u[e],[r,i]=t??[n?.range.startTokenIndex??0,n?.range.endTokenIndex??0];return f[n?.fileIndex??0]?.({startTokenIndex:r,endTokenIndex:i})??!1});for(let[e,t,n,r]of g){let i=S(e,t),a=S(n,r);if(T[i]&&T[a])continue;let o=w(i),s=w(a);C[Math.max(o,s)]=Math.min(o,s)}let E=new Map;for(let e of b.keys()){let t=w(e),n=E.get(t)??[];n.push(e),E.set(t,n)}let D=[];for(let t of E.values()){if(t.length<2||t.every(e=>T[e]))continue;let n=new Map;for(let e of t){let{blockIndex:t=0,core:r}=b[e]??{},i=n.get(t)??{cores:[],anchor:!1};i.cores.push(r),i.anchor||=T[e]??!1,n.set(t,i)}D.push([...n].flatMap(([t,{cores:n,anchor:r}])=>{let i=u[t];return i?[o(i,e,n,r)]:[]}))}return D}function r(e){let t=[];for(let[n,r]of e.toSorted((e,t)=>e[0]-t[0])){let e=t.at(-1);e&&n<e[1]?e[1]=Math.max(e[1],r):t.push([n,r])}return t}function i(e){let t=[];for(let{startTokenIndex:n,endTokenIndex:r}of e.toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex)){let e=t.at(-1);e&&n<=e[1]?e[1]=Math.max(e[1],r):t.push([n,r])}return e=>{let n=0,r=t.length;for(;n<r;){let i=n+r>>>1;(t[i]?.[0]??0)<=e.startTokenIndex?n=i+1:r=i}return(t[n-1]?.[1]??-1)>=e.endTokenIndex}}function a(e){let t=e.toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex),n=new Int32Array(t.length),r=-1;for(let[e,i]of t.entries())r=Math.max(r,i.endTokenIndex),n[e]=r;return e=>{let r=0,i=t.length;for(;r<i;){let n=r+i>>>1;(t[n]?.startTokenIndex??0)<e.endTokenIndex?r=n+1:i=n}return r>0&&(n[r-1]??-1)>e.startTokenIndex}}function o({fileIndex:e,range:t},n,r,i){let a=r.includes(void 0),o=r.map(e=>e??[t.startTokenIndex,t.endTokenIndex]).toSorted((e,t)=>e[0]-t[0]).map(([e,t])=>({startTokenIndex:e,endTokenIndex:t})),s=o[0]?.startTokenIndex??t.startTokenIndex,c=o.at(-1)?.endTokenIndex??t.endTokenIndex,l=n[e]?.tokens;return{fileIndex:e,spanCountedElsewhere:i||void 0,segments:o,tokenCount:o.reduce((e,t)=>e+t.endTokenIndex-t.startTokenIndex,0),startTokenIndex:s,endTokenIndex:c,startIndex:t.startIndex,endIndex:t.endIndex,startLine:a?t.startLine:(l?.[s]?.startRow??0)+1,endLine:a?t.endLine:(l?.[c-1]?.endRow??0)+1}}function s(e,t,n,r){let i=new Map;for(let t of e)for(let e of t.ngrams)i.set(e,(i.get(e)??0)+1);for(let t of e)t.ngrams=t.ngrams.filter(e=>(i.get(e)??0)<=1e3);let a=Int32Array.from(e,e=>e.fileIndex),o=Uint8Array.from(t,Number),s=Int32Array.from(e,e=>e.sequence.length),c=Int32Array.from(e,e=>e.ngrams.length),l=[...e.keys()].toSorted((e,t)=>(s[e]??0)-(s[t]??0)),u=new Map,d=new Int32Array(e.length),f=[];for(let t of l){let i=a[t],l=o[t]===1,p=Math.min(Math.ceil((s[t]??0)/3),Math.ceil(n*(s[t]??0)/100)),m=e[t]?.ngrams??[];for(let e of m){let n=u.get(e);if(!n){u.set(e,[t]);continue}for(let e=n.length-1;e>=0;--e){let t=n[e]??0;if((s[t]??0)<p)break;a[t]===i||l&&o[t]===1||(d[t]===0&&f.push(t),d[t]=(d[t]??0)+1)}n.push(t)}for(let e of f){let n=d[e]??0;d[e]=0,n*100>=10*Math.min(c[e]??0,m.length)&&r(e,t)}f.length=0}}function c(n,r,i){let a=new Map;for(let e of n)for(let t of e.contentCounts.keys())a.set(t,(a.get(t)??0)+1);let o=e=>Math.min(31-Math.clz32(Math.floor((n.length+1)/e))+1,3),s=new Map;for(let[e,t]of a)s.set(e,o(t));let c=e=>{let t=Int32Array.from(e.keys()).toSorted(),n=Int32Array.from(t,t=>(e.get(t)??0)*(s.get(t)??0)),r=0;for(let e of n)r+=e;return{symbols:t,weightedCounts:n,total:r}},d=new Map(n.map(e=>[e,c(e.contentCounts)])),f=-1,_,v=(t,n,r)=>((f!==n||!_)&&(f=n,_=e(t.sequence)),_(r)),x=(e,n)=>{let a=[];for(let t=0,r=0;t<e.uniqueNgrams.length&&r<n.uniqueNgrams.length;){let i=e.uniqueNgrams[t]??0,o=n.uniqueNgrams[r]??0;i===o&&a.push([e.uniqueNgramOffsets[t]??0,n.uniqueNgramOffsets[r]??0]),i<=o&&(t+=1),o<=i&&(r+=1)}a.sort((e,t)=>e[0]-t[0]);let o=a.filter(([e,t],n)=>{let r=a[n-1],i=a[n+1];return r?.[0]===e-1&&r[1]===t-1||i?.[0]===e+1&&i[1]===t+1}),s=e.range.startTokenIndex,u=n.range.startTokenIndex,d=[];for(let a of m(p(o))){let[o,f]=a[0]??[0,0],[p,m]=a.at(-1)??[0,0],g=p+5,_=m+5,v=g-o,x=_-f,S=Math.min(v,x),C=i*Math.max(v,x);S>=r&&S*100>=C&&h(a)*100>=50*S&&l(c(y(e.symbols,e.isContent,o,g)),c(y(n.symbols,n.isContent,f,_)))&&t(b(e.symbols.subarray(o,g)),b(n.symbols.subarray(f,_)))*100>=C&&d.push([[s+o,s+g],[u+f,u+_]])}return d.length>0?{kind:`local`,cores:d}:void 0};return(e,t,n)=>{let r=i*Math.max(e.sequence.length,t.sequence.length);return Math.min(e.sequence.length,t.sequence.length)*100>=r&&l(d.get(e),d.get(t))&&(g(e.sortedSequence,t.sortedSequence)*100>=r&&v(t,n,e.sequence)*100>=r||u(e,t,r))?{kind:`whole`}:x(e,t)}}function l(e,t){if(!e||!t)return!1;let n=0;for(let r=0,i=0;r<e.symbols.length&&i<t.symbols.length;){let a=e.symbols[r]??0,o=t.symbols[i]??0;a===o&&(n+=Math.min(e.weightedCounts[r]??0,t.weightedCounts[i]??0)),a<=o&&(r+=1),o<=a&&(i+=1)}return n*100>50*Math.max(e.total,t.total)}function u(e,n,r){return e.canonicalSequence!==void 0&&n.canonicalSequence!==void 0&&t(e.canonicalSequence,n.canonicalSequence)*100>=r}function d(e,t,n){if(t.length<2)return;let r=[],i=0;for(let[a,o]of t){let t=a-n;i<t&&r.push(b(e.subarray(i,t))),r.push(b(e.subarray(t,o-n))),i=o-n}i<e.length&&r.push(b(e.subarray(i))),r.sort(f);let a=new Int32Array(e.length),o=0;for(let e of r)a.set(e,o),o+=e.length;return a}function f(e,t){for(let n=0;n<Math.min(e.length,t.length);n+=1){let r=(e[n]??0)-(t[n]??0);if(r!==0)return r}return e.length-t.length}function p(e){let t=[],n=[];for(let[r,[,i]]of e.entries()){let a=0,o=t.length;for(;a<o;){let n=a+o>>>1;(e[t[n]??0]?.[1]??0)<i?a=n+1:o=n}n.push(a>0?t[a-1]??-1:-1),t[a]=r}let r=[];for(let i=t.at(-1)??-1;i>=0;i=n[i]??-1){let t=e[i];t&&r.push(t)}return r.toReversed()}function m(e){let t=[];for(let[n,r]of e.entries()){let i=e[n-1];i!==void 0&&r[0]-(i[0]+5)<=30&&r[1]-(i[1]+5)<=30?t.at(-1)?.push(r):t.push([r])}return t}function h(e){let t=5;for(let n=1;n<e.length;n+=1)t+=Math.min((e[n]?.[0]??0)-(e[n-1]?.[0]??0),5);return t}function g(e,t){let n=0,r=0,i=0;for(;r<e.length&&i<t.length;){let a=e[r]??0,o=t[i]??0;a===o?(n+=1,r+=1,i+=1):a<o?r+=1:i+=1}return n}function _(e){let t=new Map,n=[];for(let[r,{tokens:i,containerStatements:a,nearMissBlocks:o}]of e.entries()){if(!i||!o?.length)continue;let e=new Int32Array(i.length),s=new Uint8Array(i.length),c=new Map;for(let[n,r]of i.entries()){if(r.kind===`id`){let t=c.get(r.text);t===void 0&&(t=c.size,c.set(r.text,t)),e[n]=-(t+1);continue}let i=x(r),a=t.get(i);a===void 0&&(a=t.size,t.set(i,a)),e[n]=a,s[n]=r.isName||r.literalHash!==void 0?1:0}let l=v(a??[]);for(let t of o){let{startTokenIndex:i,endTokenIndex:a}=t,o=e.subarray(i,a),c=s.subarray(i,a),u=b(o),f=S(o),p=new Map;for(let e of f)p.set(e,(p.get(e)??0)+1);let m=f.keys().filter(e=>p.get(f[e]??0)===1).toArray().toSorted((e,t)=>(f[e]??0)-(f[t]??0));n.push({fileIndex:r,range:t,symbols:o,isContent:c,sequence:u,sortedSequence:u.toSorted(),ngrams:Int32Array.from(p.keys()),uniqueNgrams:Int32Array.from(m,e=>f[e]??0),uniqueNgramOffsets:Int32Array.from(m),contentCounts:y(o,c,0,o.length),canonicalSequence:d(o,l(i,a),i)})}}return n}function v(e){let t=e.flat().filter(e=>e.startTokenIndex<e.endTokenIndex).map(e=>[e.startTokenIndex,e.endTokenIndex]).toSorted((e,t)=>e[0]-t[0]||t[1]-e[1]);return(e,n)=>{let r=0,i=t.length;for(;r<i;){let n=r+i>>>1;(t[n]?.[0]??0)<e?r=n+1:i=n}let a=[];for(let i=r;i<t.length;i+=1){let r=t[i];if(!r||r[0]>=n)break;let o=a.at(-1),s=o!==void 0&&r[0]<o[1];r[1]<=n&&(r[0]!==e||r[1]!==n)&&!s&&a.push(r)}return a}}function y(e,t,n,r){let i=new Map;for(let a=n;a<r;a+=1)if(t[a]===1){let t=e[a]??0;i.set(t,(i.get(t)??0)+1)}return i}function b(e){let t=new Map;return e.map(e=>{if(e>=0)return e;let n=t.get(e);return n===void 0&&(n=t.size,t.set(e,n)),-(n+1)})}function x(e){let t=e.textHash^Math.imul(e.literalHash??0,2654435761),n=e.textHash2^Math.imul(e.literalHash2??0,2246822507);return(t>>>0)*2097152+(n>>>11)}function S(e){let t=new Int32Array(Math.max(e.length-5+1,0));for(let n=0;n<t.length;n+=1){let r=5381;for(let t=0;t<5;t+=1){let i=e[n+t]??0;r=Math.imul(r,31)+(i<0?-1:i)|0}t[n]=r}return t}export{n as collectCrossFileNearMissGroups};
|
|
2
|
+
//# sourceMappingURL=crossFileNearMiss.js.map
|