code-gauge 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +5 -2
  2. package/dist/crossFileDuplication.cjs +1 -1
  3. package/dist/crossFileDuplication.cjs.map +1 -1
  4. package/dist/crossFileDuplication.d.ts +11 -4
  5. package/dist/crossFileDuplication.js +1 -1
  6. package/dist/crossFileDuplication.js.map +1 -1
  7. package/dist/crossFileNearMiss.cjs +2 -0
  8. package/dist/crossFileNearMiss.cjs.map +1 -0
  9. package/dist/crossFileNearMiss.d.ts +27 -0
  10. package/dist/crossFileNearMiss.js +2 -0
  11. package/dist/crossFileNearMiss.js.map +1 -0
  12. package/dist/diffCommand.cjs +1 -1
  13. package/dist/diffCommand.cjs.map +1 -1
  14. package/dist/diffCommand.js +3 -3
  15. package/dist/diffCommand.js.map +1 -1
  16. package/dist/duplication.cjs +1 -1
  17. package/dist/duplication.cjs.map +1 -1
  18. package/dist/duplication.d.ts +11 -0
  19. package/dist/duplication.js +1 -1
  20. package/dist/duplication.js.map +1 -1
  21. package/dist/metrics.cjs +1 -1
  22. package/dist/metrics.cjs.map +1 -1
  23. package/dist/metrics.d.ts +10 -0
  24. package/dist/metrics.js +1 -1
  25. package/dist/metrics.js.map +1 -1
  26. package/dist/nativeMetrics.cjs +2 -2
  27. package/dist/nativeMetrics.cjs.map +1 -1
  28. package/dist/nativeMetrics.d.ts +7 -2
  29. package/dist/nativeMetrics.js +2 -2
  30. package/dist/nativeMetrics.js.map +1 -1
  31. package/dist/scan.cjs +1 -1
  32. package/dist/scan.cjs.map +1 -1
  33. package/dist/scan.d.ts +12 -1
  34. package/dist/scan.js +1 -1
  35. package/dist/scan.js.map +1 -1
  36. package/dist/types.d.ts +1 -1
  37. package/native/src/dep_degree.rs +2 -3
  38. package/native/src/duplication.rs +170 -115
  39. package/native/src/functions.rs +1 -1
  40. package/native/src/lib.rs +6 -2
  41. package/native/src/measure.rs +35 -11
  42. package/native/src/types.rs +5 -0
  43. package/package.json +8 -8
package/README.md CHANGED
@@ -158,7 +158,9 @@ The `duplication` section tunes how clones are detected:
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
160
  both blocks are at least this similar and share more than half of their content-bearing tokens.
161
- `100` disables near-miss detection. Applies to within-file detection only.
161
+ `100` disables near-miss detection. Applies to within-file detection and to cross-file matching
162
+ alike; across files, n-grams shared by more than 1000 blocks (syntax boilerplate) are left out of
163
+ the filtration index so boilerplate cannot make candidate counting quadratic in the block count.
162
164
 
163
165
  ## Metrics
164
166
 
@@ -184,7 +186,8 @@ The `duplication` section tunes how clones are detected:
184
186
  match), with adjacent matches around a small edit merged into gapped (Type-3) clone groups and
185
187
  near-miss (Type-3) clones matched by token-LCS similarity, plus duplicated line count and ratio
186
188
  - Cross-file duplication (via `measureCrossFileDuplication`): copy-pasted blocks shared between
187
- files, matched with the same normalization and reported as groups with their file locations
189
+ files, matched with the same normalization (exact, gapped, and near-miss clones) and reported as
190
+ groups with their file locations
188
191
  - Halstead base counts, vocabulary, length, volume, and effort, per function and per file — the
189
192
  strongest correlates of measured cognitive load in the EEG/fMRI validation literature
190
193
  - Per-function DepDegree (Beyer & Fararooy 2010), approximated as the number of variable reads
@@ -1,2 +1,2 @@
1
- "use strict";const e=require("./duplicateSelection.cjs"),t=require("./duplication.cjs");function n(n,i){let{minTokens:l,maxGapTokens:u}=t.resolveDuplicationOptions(i),d=n.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let e of r(n,l))d.push(e);let f=e.selectMaximalGroups(d,a,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),p=o(n,u);return c(s([...f.values()],p,u),n,p)}function r(e,n){let r=[],i=[];for(let[n,{tokens:a,containerStatements:o}]of e.entries())a&&o&&(r.push(n),i.push({tokens:a,literalCountPrefix:t.buildLiteralCountPrefix(a),containers:o}));return i.length<2?[]:t.collectSequenceWindowCandidates(i,n,!0).flatMap(({candidate:t,contextIndex:n})=>{let i=r[n],a=i===void 0?void 0:e[i];return i===void 0||a===void 0?[]:[{...t,regionBucket:i,file:a.file}]})}function i(e){return new Set(e.map(e=>e.file)).size>=2}function a(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function o(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function s(e,n,r){let a=e.map(e=>e.map(e=>{let t=e.startTokenIndex+(n[e.regionBucket]??0),r=e.endTokenIndex+(n[e.regionBucket]??0);return{file:e.file,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:t,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:t,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return t.mergeAdjacentGroups(a,r,i)}function c(e,n,r){let i=[],a=new Map,o=new Map(n.map((e,t)=>[e.file,{tokens:e.tokens,codeLineNumbers:e.codeLineNumbers,offset:r[t]??0}])),s=new Map,c=0;for(let n of e){c+=t.countRedundantFragments(n);for(let e of n)l(e,o,s);let e=n.map(({file:e,startLine:t,endLine:n})=>({file:e,startLine:t,endLine:n})).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),r=[...new Set(e.map(({file:e})=>e))];for(let e of r)a.set(e,(a.get(e)??0)+1);i.push({files:r,occurrences:e,tokenCount:n[0]?.tokenCount??0})}return i.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:i}}function l(e,n,r){let i=n.get(e.file);if(!i?.tokens)return;let a=r.get(e.file);a||(a=new Set,r.set(e.file,a));for(let n of e.segments)t.collectSegmentLines({startTokenIndex:n.startTokenIndex-i.offset,endTokenIndex:n.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,a)}exports.measureCrossFileDuplication=n;
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,f))g.push(e);return u(g,t,h)}function i(e,n,r,i){let a=e.map(()=>[]);for(let{fileIndex:e,startTokenIndex:t,endTokenIndex:i}of n.flat()){let n=r[e]??0;a[e]?.push({startTokenIndex:t-n,endTokenIndex:i-n})}return t.collectCrossFileNearMissGroups(e,a,i).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 * smallest whole block's length, 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, 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, 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 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, 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 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
- /** Matched token count of one occurrence (all occurrences share it; gaps are not counted). */
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
+ * smallest whole block's length, 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 matched tokens of its cross-file occurrences,
25
- * sorted ascending. Exact like within-file duplicateLineNumbers: the unmatched gap of a merged
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, 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 matched lines are
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{selectMaximalGroups as e}from"./duplicateSelection.js";import{buildLiteralCountPrefix as t,collectSegmentLines as n,collectSequenceWindowCandidates as r,countRedundantFragments as i,mergeAdjacentGroups as a,resolveDuplicationOptions as o}from"./duplication.js";function s(t,n){let{minTokens:r,maxGapTokens:i}=o(n),a=t.flatMap(({file:e,candidates:t},n)=>t.map(t=>({...t,regionBucket:n,file:e})));for(let e of c(t,r))a.push(e);let s=e(a,u,(e,t)=>e.regionBucket-t.regionBucket||e.startIndex-t.startIndex),l=d(t,i);return p(f([...s.values()],l,i),t,l)}function c(e,n){let i=[],a=[];for(let[n,{tokens:r,containerStatements:o}]of e.entries())r&&o&&(i.push(n),a.push({tokens:r,literalCountPrefix:t(r),containers:o}));return a.length<2?[]:r(a,n,!0).flatMap(({candidate:t,contextIndex:n})=>{let r=i[n],a=r===void 0?void 0:e[r];return r===void 0||a===void 0?[]:[{...t,regionBucket:r,file:a.file}]})}function l(e){return new Set(e.map(e=>e.file)).size>=2}function u(e){return e.length>=2&&new Set(e.map(e=>e.regionBucket)).size>=2}function d(e,t){let n=[],r=0;for(let{tokens:i,candidates:a}of e){n.push(r);let e=i?.length??0;if(!i)for(let t of a)e=Math.max(e,t.endTokenIndex);r+=e+t+1}return n}function f(e,t,n){let r=e.map(e=>e.map(e=>{let n=e.startTokenIndex+(t[e.regionBucket]??0),r=e.endTokenIndex+(t[e.regionBucket]??0);return{file:e.file,spanCountedElsewhere:e.nestedInLargerGroup,nestedInLargerGroup:e.nestedInLargerGroup,segments:[{startTokenIndex:n,endTokenIndex:r}],tokenCount:e.tokenCount,startTokenIndex:n,endTokenIndex:r,startIndex:e.startIndex,endIndex:e.endIndex,startLine:e.startLine,endLine:e.endLine}}).toSorted((e,t)=>e.startTokenIndex-t.startTokenIndex));return a(r,n,l)}function p(e,t,n){let r=[],a=new Map,o=new Map(t.map((e,t)=>[e.file,{tokens:e.tokens,codeLineNumbers:e.codeLineNumbers,offset:n[t]??0}])),s=new Map,c=0;for(let t of e){c+=i(t);for(let e of t)m(e,o,s);let e=t.map(({file:e,startLine:t,endLine:n})=>({file:e,startLine:t,endLine:n})).toSorted((e,t)=>e.file.localeCompare(t.file)||e.startLine-t.startLine),n=[...new Set(e.map(({file:e})=>e))];for(let e of n)a.set(e,(a.get(e)??0)+1);r.push({files:n,occurrences:e,tokenCount:t[0]?.tokenCount??0})}return r.sort((e,t)=>t.tokenCount-e.tokenCount||(e.occurrences[0]?.file??``).localeCompare(t.occurrences[0]?.file??``)||(e.occurrences[0]?.startLine??0)-(t.occurrences[0]?.startLine??0)),{duplicateBlockCount:c,duplicateBlockGroupCountByFile:Object.fromEntries(a),duplicateLineNumbersByFile:Object.fromEntries([...s].map(([e,t])=>[e,[...t].toSorted((e,t)=>e-t)])),groups:r}}function m(e,t,r){let i=t.get(e.file);if(!i?.tokens)return;let a=r.get(e.file);a||(a=new Set,r.set(e.file,a));for(let t of e.segments)n({startTokenIndex:t.startTokenIndex-i.offset,endTokenIndex:t.endTokenIndex-i.offset},i.tokens,i.codeLineNumbers,a)}export{s as measureCrossFileDuplication};
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,i))g.push(t);return h(g,e,d)}function l(e,t,n,r){let i=e.map(()=>[]);for(let{fileIndex:e,startTokenIndex:r,endTokenIndex:a}of t.flat()){let t=n[e]??0;i[e]?.push({startTokenIndex:r-t,endTokenIndex:a-t})}return o(e,i,r).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 * smallest whole block's length, 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, 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, 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 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, 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 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(t,o,c){if(c>=100)return[];let l=s(t),u=o.map(n),d=l.map(({fileIndex:e,range:t})=>u[e]?.(t)??!1),f=l.map((e,t)=>t),p=e=>{let t=e;for(;f[t]!==t;)t=f[t]??t;for(let n=e;f[n]!==t;){let e=f[n]??t;f[n]=t,n=e}return t},m=-1,h,g=(t,n)=>((m!==t||!h)&&(m=t,h=e.createLcsLengthCounter(l[t]?.sequence??new Int32Array)),h(n));i(l,d,c,(e,t)=>{if(a(l[e],l[t],c,e=>g(t,e))){let n=p(e),r=p(t);f[Math.max(n,r)]=Math.min(n,r)}});let _=new Map;for(let e of l.keys()){let t=p(e),n=_.get(t)??[];n.push(e),_.set(t,n)}let v=[];for(let e of _.values())e.length<2||e.every(e=>d[e])||v.push(e.flatMap(e=>{let t=l[e];return t?[r(t,d[e]??!1)]:[]}));return v}function n(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 r({fileIndex:e,range:t},n){return{fileIndex:e,spanCountedElsewhere:n||void 0,segments:[{startTokenIndex:t.startTokenIndex,endTokenIndex:t.endTokenIndex}],tokenCount:t.endTokenIndex-t.startTokenIndex,startTokenIndex:t.startTokenIndex,endTokenIndex:t.endTokenIndex,startIndex:t.startIndex,endIndex:t.endIndex,startLine:t.startLine,endLine:t.endLine}}function i(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.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 a(e,t,n,r){if(!e||!t)return!1;let i=n*Math.max(e.sequence.length,t.sequence.length);return Math.min(e.sequence.length,t.sequence.length)*100<i||o(e.sortedContent,t.sortedContent)*100<=50*Math.max(e.sortedContent.length,t.sortedContent.length)||o(e.sortedSequence,t.sortedSequence)*100<i?!1:r(e.sequence)*100>=i}function o(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 s(e){let t=new Map,n=[];for(let[r,{tokens:i,nearMissBlocks:a}]of e.entries())if(i)for(let e of a??[]){let a=new Int32Array(e.endTokenIndex-e.startTokenIndex),o=[],s=new Map;for(let n=e.startTokenIndex;n<e.endTokenIndex;n+=1){let r=i[n];if(!r)continue;if(r.kind===`id`){let t=s.get(r.text);t===void 0&&(t=s.size,s.set(r.text,t)),a[n-e.startTokenIndex]=-(t+1);continue}let l=c(r),u=t.get(l);u===void 0&&(u=t.size,t.set(l,u)),a[n-e.startTokenIndex]=u,(r.isName||r.literalHash!==void 0)&&o.push(u)}n.push({fileIndex:r,range:e,sequence:a,sortedSequence:a.toSorted(),sortedContent:Int32Array.from(o).toSorted(),ngrams:l(a)})}return n}function c(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 l(e){let t=new Set;for(let n=0;n+5<=e.length;n+=1){let r=5381;for(let t=0;t<5;t+=1)r=Math.imul(r,31)+(e[n+t]??0)|0;t.add(r)}return Int32Array.from(t)}exports.collectCrossFileNearMissGroups=t;
2
+ //# sourceMappingURL=crossFileNearMiss.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crossFileNearMiss.cjs","names":["createLcsLengthCounter"],"sources":["../src/crossFileNearMiss.ts"],"sourcesContent":["import { createLcsLengthCounter, type CountedOccurrence, type Token, type TokenRange } from './duplication.js';\n\n/**\n * Cross-file near-miss (Type-3) clone detection, following the within-file detector's model (the\n * native collect_near_miss_groups): candidate block pairs are filtered through an n-gram inverted\n * index (NIL, Nakagawa et al. 2021), then verified by token-level longest common subsequence against\n * the larger block (NiCad's per-fragment similarity). Only pairs of blocks in different files are\n * compared: a same-file pair is the within-file detector's concern.\n */\n\nexport interface NearMissSourceFile {\n tokens?: Token[];\n nearMissBlocks?: TokenRange[];\n}\n\n/** A block the exact cross-file pipeline did not report, or one it did (an anchor). */\nexport interface NearMissOccurrence extends CountedOccurrence {\n fileIndex: number;\n}\n\n/** N-gram size of the candidate index (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 * A structural match must also share content: more than this percent of the larger block's\n * content-bearing tokens (names and literal values), so blocks of the same shape that call\n * different APIs on different data are not clones.\n */\nconst minContentSimilarityPercent = 50;\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 /** Identifiers as -(first-occurrence index + 1); other tokens as interned symbols (>= 0). */\n sequence: Int32Array;\n /** The sequence sorted, for the token-bag upper bound on the LCS. */\n sortedSequence: Int32Array;\n /** Content-bearing symbols (names and literal values) sorted, for the content gate. */\n sortedContent: Int32Array;\n /** Distinct non-stop n-gram hashes. */\n ngrams: Int32Array;\n}\n\n/**\n * Clusters verified cross-file near-miss pairs into groups. A block overlapping an occurrence of\n * `reportedSpansByFile` (the exact cross-file groups) is an anchor: it links near-miss copies to\n * the content an exact group already reports, and appears in the near-miss group marked\n * `spanCountedElsewhere` so block counting does not count its span twice. Pairs of two anchors are\n * skipped, and a group needs at least one non-anchor block.\n */\nexport function collectCrossFileNearMissGroups(\n files: NearMissSourceFile[],\n reportedSpansByFile: { startTokenIndex: number; endTokenIndex: number }[][],\n minSimilarityPercent: number\n): NearMissOccurrence[][] {\n if (minSimilarityPercent >= 100) {\n return [];\n }\n const blocks = normalizeBlocks(files);\n const overlapsReportedSpan = reportedSpansByFile.map(createOverlapTest);\n const anchored = blocks.map(({ fileIndex, range }) => overlapsReportedSpan[fileIndex]?.(range) ?? false);\n const parent = blocks.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 // 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: number, sequence: Int32Array): number => {\n if (counterBlock !== right || !counter) {\n counterBlock = right;\n counter = createLcsLengthCounter(blocks[right]?.sequence ?? new Int32Array());\n }\n return counter(sequence);\n };\n forEachCandidatePair(blocks, anchored, minSimilarityPercent, (left, right) => {\n if (\n isNearMissPair(blocks[left], blocks[right], minSimilarityPercent, (sequence) =>\n lcsLengthWithRight(right, sequence)\n )\n ) {\n const leftRoot = find(left);\n const rightRoot = find(right);\n parent[Math.max(leftRoot, rightRoot)] = Math.min(leftRoot, rightRoot);\n }\n });\n\n const membersByRoot = new Map<number, number[]>();\n for (const index of blocks.keys()) {\n const root = find(index);\n const members = membersByRoot.get(root) ?? [];\n members.push(index);\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((index) => anchored[index])) {\n continue;\n }\n groups.push(\n members.flatMap((index) => {\n const block = blocks[index];\n return block ? [toOccurrence(block, anchored[index] ?? false)] : [];\n })\n );\n }\n return groups;\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\nfunction toOccurrence({ fileIndex, range }: NormalizedBlock, anchor: boolean): NearMissOccurrence {\n return {\n fileIndex,\n spanCountedElsewhere: anchor || undefined,\n segments: [{ startTokenIndex: range.startTokenIndex, endTokenIndex: range.endTokenIndex }],\n tokenCount: range.endTokenIndex - range.startTokenIndex,\n startTokenIndex: range.startTokenIndex,\n endTokenIndex: range.endTokenIndex,\n startIndex: range.startIndex,\n endIndex: range.endIndex,\n startLine: range.startLine,\n endLine: range.endLine,\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 anchors and pairs whose length ratio alone rules out the\n * similarity requirement (the LCS cannot exceed the shorter block). Blocks are indexed in ascending\n * length, so each posting list is scanned backwards only while its blocks are long enough; shared\n * counts accumulate in a dense counter, so no pair map is materialized.\n */\nfunction forEachCandidatePair(\n blocks: NormalizedBlock[],\n anchored: 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 anchorFlags = Uint8Array.from(anchored, 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 rightAnchored = anchorFlags[right] === 1;\n const minLeftLength = Math.ceil((minSimilarityPercent * (lengths[right] ?? 0)) / 100);\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 || (rightAnchored && anchorFlags[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 * Verifies a filtered pair, cheapest bounds first. The LCS cannot exceed the shorter block's\n * length nor the token-bag overlap, so either bound falling below the similarity requirement\n * rejects the pair exactly without running the LCS.\n */\nfunction isNearMissPair(\n left: NormalizedBlock | undefined,\n right: NormalizedBlock | undefined,\n minSimilarityPercent: number,\n lcsLengthWithRight: (sequence: Int32Array) => number\n): boolean {\n if (!left || !right) {\n return false;\n }\n const required = minSimilarityPercent * Math.max(left.sequence.length, right.sequence.length);\n if (Math.min(left.sequence.length, right.sequence.length) * 100 < required) {\n return false;\n }\n if (\n sortedOverlap(left.sortedContent, right.sortedContent) * 100 <=\n minContentSimilarityPercent * Math.max(left.sortedContent.length, right.sortedContent.length)\n ) {\n return false;\n }\n if (sortedOverlap(left.sortedSequence, right.sortedSequence) * 100 < required) {\n return false;\n }\n return lcsLengthWithRight(left.sequence) * 100 >= required;\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: identifiers are anonymized by first\n * occurrence within the block, and every other token keeps its text and literal value. Symbols are\n * interned project-wide from the tokens' hash pairs, so equal tokens compare equal across files.\n */\nfunction normalizeBlocks(files: NearMissSourceFile[]): NormalizedBlock[] {\n const symbolByTokenKey = new Map<number, number>();\n const blocks: NormalizedBlock[] = [];\n for (const [fileIndex, { tokens, nearMissBlocks }] of files.entries()) {\n if (!tokens) {\n continue;\n }\n for (const range of nearMissBlocks ?? []) {\n const sequence = new Int32Array(range.endTokenIndex - range.startTokenIndex);\n const content: number[] = [];\n const indexByIdentifier = new Map<string, number>();\n for (let index = range.startTokenIndex; index < range.endTokenIndex; index += 1) {\n const token = tokens[index];\n if (!token) {\n continue;\n }\n if (token.kind === 'id') {\n let identifierIndex = indexByIdentifier.get(token.text);\n if (identifierIndex === undefined) {\n identifierIndex = indexByIdentifier.size;\n indexByIdentifier.set(token.text, identifierIndex);\n }\n sequence[index - range.startTokenIndex] = -(identifierIndex + 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 sequence[index - range.startTokenIndex] = symbol;\n if (token.isName || token.literalHash !== undefined) {\n content.push(symbol);\n }\n }\n blocks.push({\n fileIndex,\n range,\n sequence,\n sortedSequence: sequence.toSorted(),\n sortedContent: Int32Array.from(content).toSorted(),\n ngrams: collectNgrams(sequence),\n });\n }\n }\n return blocks;\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\nfunction collectNgrams(sequence: Int32Array): Int32Array {\n const ngrams = new Set<number>();\n for (let start = 0; start + ngramSize <= sequence.length; start += 1) {\n let hash = 5381;\n for (let offset = 0; offset < ngramSize; offset += 1) {\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) + (sequence[start + offset] ?? 0)) | 0;\n }\n ngrams.add(hash);\n }\n return Int32Array.from(ngrams);\n}\n"],"mappings":"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"}
@@ -0,0 +1,27 @@
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 (the
4
+ * native collect_near_miss_groups): candidate block pairs are filtered through an n-gram inverted
5
+ * index (NIL, Nakagawa et al. 2021), then verified by token-level longest common subsequence against
6
+ * the larger block (NiCad's per-fragment similarity). Only pairs of blocks in different files are
7
+ * compared: a same-file pair is the within-file detector's concern.
8
+ */
9
+ export interface NearMissSourceFile {
10
+ tokens?: Token[];
11
+ nearMissBlocks?: TokenRange[];
12
+ }
13
+ /** A block the exact cross-file pipeline did not report, or one it did (an anchor). */
14
+ export interface NearMissOccurrence extends CountedOccurrence {
15
+ fileIndex: number;
16
+ }
17
+ /**
18
+ * Clusters verified cross-file near-miss pairs into groups. A block overlapping an occurrence of
19
+ * `reportedSpansByFile` (the exact cross-file groups) is an anchor: it links near-miss copies to
20
+ * the content an exact group already reports, and appears in the near-miss group marked
21
+ * `spanCountedElsewhere` so block counting does not count its span twice. Pairs of two anchors are
22
+ * skipped, and a group needs at least one non-anchor block.
23
+ */
24
+ export declare function collectCrossFileNearMissGroups(files: NearMissSourceFile[], reportedSpansByFile: {
25
+ startTokenIndex: number;
26
+ endTokenIndex: number;
27
+ }[][], minSimilarityPercent: number): NearMissOccurrence[][];
@@ -0,0 +1,2 @@
1
+ import{createLcsLengthCounter as e}from"./duplication.js";function t(t,o,c){if(c>=100)return[];let l=s(t),u=o.map(n),d=l.map(({fileIndex:e,range:t})=>u[e]?.(t)??!1),f=l.map((e,t)=>t),p=e=>{let t=e;for(;f[t]!==t;)t=f[t]??t;for(let n=e;f[n]!==t;){let e=f[n]??t;f[n]=t,n=e}return t},m=-1,h,g=(t,n)=>((m!==t||!h)&&(m=t,h=e(l[t]?.sequence??new Int32Array)),h(n));i(l,d,c,(e,t)=>{if(a(l[e],l[t],c,e=>g(t,e))){let n=p(e),r=p(t);f[Math.max(n,r)]=Math.min(n,r)}});let _=new Map;for(let e of l.keys()){let t=p(e),n=_.get(t)??[];n.push(e),_.set(t,n)}let v=[];for(let e of _.values())e.length<2||e.every(e=>d[e])||v.push(e.flatMap(e=>{let t=l[e];return t?[r(t,d[e]??!1)]:[]}));return v}function n(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 r({fileIndex:e,range:t},n){return{fileIndex:e,spanCountedElsewhere:n||void 0,segments:[{startTokenIndex:t.startTokenIndex,endTokenIndex:t.endTokenIndex}],tokenCount:t.endTokenIndex-t.startTokenIndex,startTokenIndex:t.startTokenIndex,endTokenIndex:t.endTokenIndex,startIndex:t.startIndex,endIndex:t.endIndex,startLine:t.startLine,endLine:t.endLine}}function i(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.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 a(e,t,n,r){if(!e||!t)return!1;let i=n*Math.max(e.sequence.length,t.sequence.length);return Math.min(e.sequence.length,t.sequence.length)*100<i||o(e.sortedContent,t.sortedContent)*100<=50*Math.max(e.sortedContent.length,t.sortedContent.length)||o(e.sortedSequence,t.sortedSequence)*100<i?!1:r(e.sequence)*100>=i}function o(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 s(e){let t=new Map,n=[];for(let[r,{tokens:i,nearMissBlocks:a}]of e.entries())if(i)for(let e of a??[]){let a=new Int32Array(e.endTokenIndex-e.startTokenIndex),o=[],s=new Map;for(let n=e.startTokenIndex;n<e.endTokenIndex;n+=1){let r=i[n];if(!r)continue;if(r.kind===`id`){let t=s.get(r.text);t===void 0&&(t=s.size,s.set(r.text,t)),a[n-e.startTokenIndex]=-(t+1);continue}let l=c(r),u=t.get(l);u===void 0&&(u=t.size,t.set(l,u)),a[n-e.startTokenIndex]=u,(r.isName||r.literalHash!==void 0)&&o.push(u)}n.push({fileIndex:r,range:e,sequence:a,sortedSequence:a.toSorted(),sortedContent:Int32Array.from(o).toSorted(),ngrams:l(a)})}return n}function c(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 l(e){let t=new Set;for(let n=0;n+5<=e.length;n+=1){let r=5381;for(let t=0;t<5;t+=1)r=Math.imul(r,31)+(e[n+t]??0)|0;t.add(r)}return Int32Array.from(t)}export{t as collectCrossFileNearMissGroups};
2
+ //# sourceMappingURL=crossFileNearMiss.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"crossFileNearMiss.js","names":[],"sources":["../src/crossFileNearMiss.ts"],"sourcesContent":["import { createLcsLengthCounter, type CountedOccurrence, type Token, type TokenRange } from './duplication.js';\n\n/**\n * Cross-file near-miss (Type-3) clone detection, following the within-file detector's model (the\n * native collect_near_miss_groups): candidate block pairs are filtered through an n-gram inverted\n * index (NIL, Nakagawa et al. 2021), then verified by token-level longest common subsequence against\n * the larger block (NiCad's per-fragment similarity). Only pairs of blocks in different files are\n * compared: a same-file pair is the within-file detector's concern.\n */\n\nexport interface NearMissSourceFile {\n tokens?: Token[];\n nearMissBlocks?: TokenRange[];\n}\n\n/** A block the exact cross-file pipeline did not report, or one it did (an anchor). */\nexport interface NearMissOccurrence extends CountedOccurrence {\n fileIndex: number;\n}\n\n/** N-gram size of the candidate index (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 * A structural match must also share content: more than this percent of the larger block's\n * content-bearing tokens (names and literal values), so blocks of the same shape that call\n * different APIs on different data are not clones.\n */\nconst minContentSimilarityPercent = 50;\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 /** Identifiers as -(first-occurrence index + 1); other tokens as interned symbols (>= 0). */\n sequence: Int32Array;\n /** The sequence sorted, for the token-bag upper bound on the LCS. */\n sortedSequence: Int32Array;\n /** Content-bearing symbols (names and literal values) sorted, for the content gate. */\n sortedContent: Int32Array;\n /** Distinct non-stop n-gram hashes. */\n ngrams: Int32Array;\n}\n\n/**\n * Clusters verified cross-file near-miss pairs into groups. A block overlapping an occurrence of\n * `reportedSpansByFile` (the exact cross-file groups) is an anchor: it links near-miss copies to\n * the content an exact group already reports, and appears in the near-miss group marked\n * `spanCountedElsewhere` so block counting does not count its span twice. Pairs of two anchors are\n * skipped, and a group needs at least one non-anchor block.\n */\nexport function collectCrossFileNearMissGroups(\n files: NearMissSourceFile[],\n reportedSpansByFile: { startTokenIndex: number; endTokenIndex: number }[][],\n minSimilarityPercent: number\n): NearMissOccurrence[][] {\n if (minSimilarityPercent >= 100) {\n return [];\n }\n const blocks = normalizeBlocks(files);\n const overlapsReportedSpan = reportedSpansByFile.map(createOverlapTest);\n const anchored = blocks.map(({ fileIndex, range }) => overlapsReportedSpan[fileIndex]?.(range) ?? false);\n const parent = blocks.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 // 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: number, sequence: Int32Array): number => {\n if (counterBlock !== right || !counter) {\n counterBlock = right;\n counter = createLcsLengthCounter(blocks[right]?.sequence ?? new Int32Array());\n }\n return counter(sequence);\n };\n forEachCandidatePair(blocks, anchored, minSimilarityPercent, (left, right) => {\n if (\n isNearMissPair(blocks[left], blocks[right], minSimilarityPercent, (sequence) =>\n lcsLengthWithRight(right, sequence)\n )\n ) {\n const leftRoot = find(left);\n const rightRoot = find(right);\n parent[Math.max(leftRoot, rightRoot)] = Math.min(leftRoot, rightRoot);\n }\n });\n\n const membersByRoot = new Map<number, number[]>();\n for (const index of blocks.keys()) {\n const root = find(index);\n const members = membersByRoot.get(root) ?? [];\n members.push(index);\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((index) => anchored[index])) {\n continue;\n }\n groups.push(\n members.flatMap((index) => {\n const block = blocks[index];\n return block ? [toOccurrence(block, anchored[index] ?? false)] : [];\n })\n );\n }\n return groups;\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\nfunction toOccurrence({ fileIndex, range }: NormalizedBlock, anchor: boolean): NearMissOccurrence {\n return {\n fileIndex,\n spanCountedElsewhere: anchor || undefined,\n segments: [{ startTokenIndex: range.startTokenIndex, endTokenIndex: range.endTokenIndex }],\n tokenCount: range.endTokenIndex - range.startTokenIndex,\n startTokenIndex: range.startTokenIndex,\n endTokenIndex: range.endTokenIndex,\n startIndex: range.startIndex,\n endIndex: range.endIndex,\n startLine: range.startLine,\n endLine: range.endLine,\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 anchors and pairs whose length ratio alone rules out the\n * similarity requirement (the LCS cannot exceed the shorter block). Blocks are indexed in ascending\n * length, so each posting list is scanned backwards only while its blocks are long enough; shared\n * counts accumulate in a dense counter, so no pair map is materialized.\n */\nfunction forEachCandidatePair(\n blocks: NormalizedBlock[],\n anchored: 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 anchorFlags = Uint8Array.from(anchored, 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 rightAnchored = anchorFlags[right] === 1;\n const minLeftLength = Math.ceil((minSimilarityPercent * (lengths[right] ?? 0)) / 100);\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 || (rightAnchored && anchorFlags[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 * Verifies a filtered pair, cheapest bounds first. The LCS cannot exceed the shorter block's\n * length nor the token-bag overlap, so either bound falling below the similarity requirement\n * rejects the pair exactly without running the LCS.\n */\nfunction isNearMissPair(\n left: NormalizedBlock | undefined,\n right: NormalizedBlock | undefined,\n minSimilarityPercent: number,\n lcsLengthWithRight: (sequence: Int32Array) => number\n): boolean {\n if (!left || !right) {\n return false;\n }\n const required = minSimilarityPercent * Math.max(left.sequence.length, right.sequence.length);\n if (Math.min(left.sequence.length, right.sequence.length) * 100 < required) {\n return false;\n }\n if (\n sortedOverlap(left.sortedContent, right.sortedContent) * 100 <=\n minContentSimilarityPercent * Math.max(left.sortedContent.length, right.sortedContent.length)\n ) {\n return false;\n }\n if (sortedOverlap(left.sortedSequence, right.sortedSequence) * 100 < required) {\n return false;\n }\n return lcsLengthWithRight(left.sequence) * 100 >= required;\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: identifiers are anonymized by first\n * occurrence within the block, and every other token keeps its text and literal value. Symbols are\n * interned project-wide from the tokens' hash pairs, so equal tokens compare equal across files.\n */\nfunction normalizeBlocks(files: NearMissSourceFile[]): NormalizedBlock[] {\n const symbolByTokenKey = new Map<number, number>();\n const blocks: NormalizedBlock[] = [];\n for (const [fileIndex, { tokens, nearMissBlocks }] of files.entries()) {\n if (!tokens) {\n continue;\n }\n for (const range of nearMissBlocks ?? []) {\n const sequence = new Int32Array(range.endTokenIndex - range.startTokenIndex);\n const content: number[] = [];\n const indexByIdentifier = new Map<string, number>();\n for (let index = range.startTokenIndex; index < range.endTokenIndex; index += 1) {\n const token = tokens[index];\n if (!token) {\n continue;\n }\n if (token.kind === 'id') {\n let identifierIndex = indexByIdentifier.get(token.text);\n if (identifierIndex === undefined) {\n identifierIndex = indexByIdentifier.size;\n indexByIdentifier.set(token.text, identifierIndex);\n }\n sequence[index - range.startTokenIndex] = -(identifierIndex + 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 sequence[index - range.startTokenIndex] = symbol;\n if (token.isName || token.literalHash !== undefined) {\n content.push(symbol);\n }\n }\n blocks.push({\n fileIndex,\n range,\n sequence,\n sortedSequence: sequence.toSorted(),\n sortedContent: Int32Array.from(content).toSorted(),\n ngrams: collectNgrams(sequence),\n });\n }\n }\n return blocks;\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\nfunction collectNgrams(sequence: Int32Array): Int32Array {\n const ngrams = new Set<number>();\n for (let start = 0; start + ngramSize <= sequence.length; start += 1) {\n let hash = 5381;\n for (let offset = 0; offset < ngramSize; offset += 1) {\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) + (sequence[start + offset] ?? 0)) | 0;\n }\n ngrams.add(hash);\n }\n return Int32Array.from(ngrams);\n}\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- "use strict";const e=require("./_virtual/_rolldown/runtime.cjs"),t=require("./crossFileDuplication.cjs"),n=require("./metrics.cjs"),r=require("./regressionGate.cjs"),i=require("./cliConfig.cjs"),a=require("./git.cjs"),o=require("./scan.cjs");let s=require("node:path");s=e.__toESM(s,1);let c=require("node:fs/promises");async function l(e,t){try{await u(e,t)}catch(e){o.writeStderr(`Error: ${o.formatError(e)}\n`),process.exitCode=2}}async function u(e,t){let n=o.resolveTarget(e),s=await i.loadConfig(t.config,await o.configSearchDirectory(n)),l=i.resolveOptions(t,s),u=i.resolveGateOptions(s),p=await(0,c.realpath)(await a.resolveRepoRoot(await v(await o.configSearchDirectory(n)))),m=await a.resolveMergeBase(p,t.base),h=await a.listChangedFiles(p,m),g=await a.listRepositoryFiles(p),_=await a.listSymlinkPathsAtRevision(p,m),y=await o.scanListedFiles(p,g,l);if(y.fatalError)throw Error(y.fatalError);let S=y.files.map(e=>({relativePath:o.formatPath(e.file,y.displayRoot),file:e})),C=new Set(h.flatMap(e=>[e.headPath,...e.basePath===void 0?[]:[e.basePath]]).filter(e=>o.isScannedPath(e,l))),T=[],E=[...y.warnings];for(let e of y.errors)[...C].some(t=>e.startsWith(`${t}:`))?T.push(e):E.push(e);let{canonicalTarget:D,targetExists:k}=await d(n),A=await f(h,{repoRoot:p,mergeBase:m,canonicalTarget:D,options:l,scannedFiles:S,baseSymlinkPaths:_},T,E);if(!k&&!A.some(e=>e.gated))throw Error(`target "${e}" does not exist and matches no changed file`);let{baseCross:j,headCross:M}=b(A,S,l),N=A.map(e=>x(e,j,M)),P=r.evaluateRegressionGate(N,u);t.json?O(t,m,P,N,T,E):w(t,m,P,T,E),T.length>0?process.exitCode=2:P.violations.length>0&&(process.exitCode=1)}async function d(e){try{return{canonicalTarget:await(0,c.realpath)(e),targetExists:!0}}catch{return{canonicalTarget:e,targetExists:!1}}}async function f(e,t,n,r){let i=new Map(t.scannedFiles.map(({relativePath:e,file:t})=>[e,t])),a=[];for(let o of e){let e=await p(o,t,i,n,r);e&&a.push(e)}return a}async function p(e,t,n,r,i){let a=e.status!==`deleted`&&o.isScannedPath(e.headPath,t.options)&&!await _(s.default.join(t.repoRoot,e.headPath)),c=e.basePath!==void 0&&o.isScannedPath(e.basePath,t.options)&&!t.baseSymlinkPaths.has(e.basePath);if(!a&&!c)return;let l=e.status===`deleted`?e.basePath:e.headPath,u=a?n.get(e.headPath):void 0;if(a&&!u){m(e.headPath,r);return}let d={changed:e,displayFile:l,gated:a||e.status===`deleted`?y(s.default.join(t.repoRoot,l),t.canonicalTarget):!1,headFile:u};if(!c||await g(d,e.basePath,t,r,i))return u&&await h(d,u,t,i),d}function m(e,t){t.some(t=>t.startsWith(`${e}:`))||t.push(`${e}: changed file was not measured`)}async function h(e,t,r,i){try{let i=await(0,c.readFile)(t.file,`utf8`);e.headFunctionTokens=n.collectFunctionTokenSequences(i,{language:o.getLanguage(e.changed.headPath,r.options),duplication:r.options.duplication})}catch(t){i.push(`${e.displayFile}: function token sequences unavailable: ${o.formatError(t)}`)}}async function g(e,t,r,i,s){let c={language:o.getLanguage(t,r.options),duplication:r.options.duplication},l;try{l=await a.readFileAtRevision(r.repoRoot,r.mergeBase,t),e.baseMetrics=n.measureCode(l,c)}catch(e){return i.push(`${t} (at merge-base): ${o.formatError(e)}`),!1}try{e.baseCandidates=n.collectCrossFileDuplicationFileData(l,c),e.baseFunctionTokens=n.collectFunctionTokenSequences(l,c)}catch(e){s.push(`${t} (at merge-base): duplication candidates and token sequences unavailable: ${o.formatError(e)}`)}return!0}async function _(e){return(await(0,c.lstat)(e).catch(()=>{}))?.isSymbolicLink()??!1}async function v(e){let t=e;for(;;){if((await(0,c.stat)(t).catch(()=>{}))?.isDirectory())return t;let e=s.default.dirname(t);if(e===t)return t;t=e}}function y(e,t){let n=s.default.relative(t,e);return n===``||!n.startsWith(`..${s.default.sep}`)&&n!==`..`&&!s.default.isAbsolute(n)}function b(e,n,r){let i=n.flatMap(({relativePath:e,file:t})=>t.duplicationCandidates?[{file:e,...t.duplicationCandidates}]:[]),a=new Set(e.flatMap(e=>e.changed.status===`deleted`?[]:[e.changed.headPath])),o=i.filter(e=>!a.has(e.file));for(let t of e)t.baseCandidates&&t.changed.basePath!==void 0&&o.push({file:t.changed.basePath,...t.baseCandidates});return{baseCross:o.length>=2?t.measureCrossFileDuplication(o,r.duplication):void 0,headCross:i.length>=2?t.measureCrossFileDuplication(i,r.duplication):void 0}}function x(e,t,n){return{file:e.displayFile,baseMetrics:e.baseMetrics,headMetrics:e.headFile?.metrics,baseFunctionTokens:e.baseFunctionTokens,headFunctionTokens:e.headFunctionTokens,baseDuplicatedLineCount:e.baseMetrics===void 0||e.changed.basePath===void 0?0:S(e.baseMetrics,t,e.changed.basePath),headDuplicatedLineCount:e.changed.status===`deleted`?0:S(e.headFile?.metrics,n,e.changed.headPath),duplicationPartners:C(n,e.changed.headPath),gated:e.gated}}function S(e,t,n){return o.collectDuplicatedLineNumbers(e,t,n).size}function C(e,t){if(!e)return[];let n=new Set;for(let r of e.groups)if(r.files.includes(t))for(let e of r.files)e!==t&&n.add(e);return[...n].toSorted()}function w(e,t,n,r,i){let a=t.slice(0,12);r.length>0?(o.writeStdout(`Regression gate could not complete: ${r.length} measurement failures (details on stderr)${n.violations.length>0?`; ${n.violations.length} violations in the measured files`:``} (base ${e.base}, merge-base ${a}).\n`),T(n)):n.violations.length===0?o.writeStdout(`Regression gate passed: ${n.checkedFileCount} changed files, ${n.checkedFunctionCount} functions checked (base ${e.base}, merge-base ${a}).\n`):(o.writeStdout(`Regression gate vs ${e.base} (merge-base ${a}): ${n.violations.length} violations\n`),T(n)),e.full&&E(n);for(let e of i)o.writeStderr(`Warning: ${e}\n`);for(let e of r)o.writeStderr(`Error: ${e}\n`)}function T(e){for(let[t,n]of e.violations.entries())o.writeStdout(`${t+1}. ${n.message}\n`)}function E(e){if(e.checkedFunctions.length!==0){o.writeStdout(`
1
+ "use strict";const e=require("./_virtual/_rolldown/runtime.cjs"),t=require("./crossFileDuplication.cjs"),n=require("./metrics.cjs"),r=require("./regressionGate.cjs"),i=require("./cliConfig.cjs"),a=require("./git.cjs"),o=require("./scan.cjs");let s=require("node:path");s=e.__toESM(s,1);let c=require("node:fs/promises");async function l(e,t){try{await u(e,t)}catch(e){o.writeStderr(`Error: ${o.formatError(e)}\n`),process.exitCode=2}}async function u(e,t){let n=o.resolveTarget(e),s=await i.loadConfig(t.config,await o.configSearchDirectory(n)),l=i.resolveOptions(t,s),u=i.resolveGateOptions(s),p=await(0,c.realpath)(await a.resolveRepoRoot(await v(await o.configSearchDirectory(n)))),m=await a.resolveMergeBase(p,t.base),h=await a.listChangedFiles(p,m),g=await a.listRepositoryFiles(p),_=await a.listSymlinkPathsAtRevision(p,m),y=await o.scanListedFiles(p,g,l);if(y.fatalError)throw Error(y.fatalError);let S=y.files.map(e=>({relativePath:o.formatPath(e.file,y.displayRoot),file:e})),C=new Set(h.flatMap(e=>[e.headPath,...e.basePath===void 0?[]:[e.basePath]]).filter(e=>o.isScannedPath(e,l))),T=[],E=[...y.warnings];for(let e of y.errors)[...C].some(t=>e.startsWith(`${t}:`))?T.push(e):E.push(e);let{canonicalTarget:D,targetExists:k}=await d(n),A=await f(h,{repoRoot:p,mergeBase:m,canonicalTarget:D,options:l,scannedFiles:S,baseSymlinkPaths:_},T,E);if(!k&&!A.some(e=>e.gated))throw Error(`target "${e}" does not exist and matches no changed file`);let{baseCross:j,headCross:M}=b(A,S,l),N=A.map(e=>x(e,j,M)),P=r.evaluateRegressionGate(N,u);t.json?O(t,m,P,N,T,E):w(t,m,P,T,E),T.length>0?process.exitCode=2:P.violations.length>0&&(process.exitCode=1)}async function d(e){try{return{canonicalTarget:await(0,c.realpath)(e),targetExists:!0}}catch{return{canonicalTarget:e,targetExists:!1}}}async function f(e,t,n,r){let i=new Map(t.scannedFiles.map(({relativePath:e,file:t})=>[e,t])),a=[];for(let o of e){let e=await p(o,t,i,n,r);e&&a.push(e)}return a}async function p(e,t,n,r,i){let a=e.status!==`deleted`&&o.isScannedPath(e.headPath,t.options)&&!await _(s.default.join(t.repoRoot,e.headPath)),c=e.basePath!==void 0&&o.isScannedPath(e.basePath,t.options)&&!t.baseSymlinkPaths.has(e.basePath);if(!a&&!c)return;let l=e.status===`deleted`?e.basePath:e.headPath,u=a?n.get(e.headPath):void 0;if(a&&!u){m(e.headPath,r);return}let d={changed:e,displayFile:l,gated:a||e.status===`deleted`?y(s.default.join(t.repoRoot,l),t.canonicalTarget):!1,headFile:u};if(!c||await g(d,e.basePath,t,r,i))return u&&await h(d,u,t,i),d}function m(e,t){t.some(t=>t.startsWith(`${e}:`))||t.push(`${e}: changed file was not measured`)}async function h(e,t,r,i){try{let i=await(0,c.readFile)(t.file,`utf8`);e.headFunctionTokens=n.collectFunctionTokenSequences(i,{language:o.getLanguage(e.changed.headPath,r.options),duplication:r.options.duplication})}catch(t){i.push(`${e.displayFile}: function token sequences unavailable: ${o.formatError(t)}`)}}async function g(e,t,r,i,s){let c={language:o.getLanguage(t,r.options),duplication:r.options.duplication},l;try{l=await a.readFileAtRevision(r.repoRoot,r.mergeBase,t);let n=o.measureWithCrossFileData(l,c);e.baseMetrics=n.metrics,e.baseCandidates=n.crossFileData,n.crossFileError!==void 0&&s.push(`${t} (at merge-base): duplication candidates unavailable: ${n.crossFileError}`)}catch(e){return i.push(`${t} (at merge-base): ${o.formatError(e)}`),!1}try{e.baseFunctionTokens=n.collectFunctionTokenSequences(l,c)}catch(e){s.push(`${t} (at merge-base): function token sequences unavailable: ${o.formatError(e)}`)}return!0}async function _(e){return(await(0,c.lstat)(e).catch(()=>{}))?.isSymbolicLink()??!1}async function v(e){let t=e;for(;;){if((await(0,c.stat)(t).catch(()=>{}))?.isDirectory())return t;let e=s.default.dirname(t);if(e===t)return t;t=e}}function y(e,t){let n=s.default.relative(t,e);return n===``||!n.startsWith(`..${s.default.sep}`)&&n!==`..`&&!s.default.isAbsolute(n)}function b(e,n,r){let i=n.flatMap(({relativePath:e,file:t})=>t.duplicationCandidates?[{file:e,...t.duplicationCandidates}]:[]),a=new Set(e.flatMap(e=>e.changed.status===`deleted`?[]:[e.changed.headPath])),o=i.filter(e=>!a.has(e.file));for(let t of e)t.baseCandidates&&t.changed.basePath!==void 0&&o.push({file:t.changed.basePath,...t.baseCandidates});return{baseCross:o.length>=2?t.measureCrossFileDuplication(o,r.duplication):void 0,headCross:i.length>=2?t.measureCrossFileDuplication(i,r.duplication):void 0}}function x(e,t,n){return{file:e.displayFile,baseMetrics:e.baseMetrics,headMetrics:e.headFile?.metrics,baseFunctionTokens:e.baseFunctionTokens,headFunctionTokens:e.headFunctionTokens,baseDuplicatedLineCount:e.baseMetrics===void 0||e.changed.basePath===void 0?0:S(e.baseMetrics,t,e.changed.basePath),headDuplicatedLineCount:e.changed.status===`deleted`?0:S(e.headFile?.metrics,n,e.changed.headPath),duplicationPartners:C(n,e.changed.headPath),gated:e.gated}}function S(e,t,n){return o.collectDuplicatedLineNumbers(e,t,n).size}function C(e,t){if(!e)return[];let n=new Set;for(let r of e.groups)if(r.files.includes(t))for(let e of r.files)e!==t&&n.add(e);return[...n].toSorted()}function w(e,t,n,r,i){let a=t.slice(0,12);r.length>0?(o.writeStdout(`Regression gate could not complete: ${r.length} measurement failures (details on stderr)${n.violations.length>0?`; ${n.violations.length} violations in the measured files`:``} (base ${e.base}, merge-base ${a}).\n`),T(n)):n.violations.length===0?o.writeStdout(`Regression gate passed: ${n.checkedFileCount} changed files, ${n.checkedFunctionCount} functions checked (base ${e.base}, merge-base ${a}).\n`):(o.writeStdout(`Regression gate vs ${e.base} (merge-base ${a}): ${n.violations.length} violations\n`),T(n)),e.full&&E(n);for(let e of i)o.writeStderr(`Warning: ${e}\n`);for(let e of r)o.writeStderr(`Error: ${e}\n`)}function T(e){for(let[t,n]of e.violations.entries())o.writeStdout(`${t+1}. ${n.message}\n`)}function E(e){if(e.checkedFunctions.length!==0){o.writeStdout(`
2
2
  Checked functions (base -> head):
3
3
  `);for(let t of e.checkedFunctions)o.writeStdout(`- ${D(t)}\n`)}}function D(e){let t=(t,n=String)=>{let r=n(t(e.head));return e.base?`${n(t(e.base))} -> ${r}`:r},n=[`cognitive ${t(e=>e.cognitiveComplexity)}`,`NCSS ${t(e=>e.ncss)}`,`nesting ${t(e=>e.nestingDepth)}`,`DepDegree ${t(e=>e.depDegree)}`,`volume ${t(e=>e.halsteadVolume,e=>e.toFixed(1))}`];return`${e.file}:${e.startLine}-${e.endLine} ${e.name}${e.base?``:` (new)`}: ${n.join(`, `)}`}function O(e,t,n,r,i,a){let s={base:e.base,mergeBase:t,passed:n.violations.length===0&&i.length===0,violations:n.violations,checkedFileCount:n.checkedFileCount,checkedFunctionCount:n.checkedFunctionCount,newFunctionCount:n.newFunctionCount,errors:i,warnings:a};e.full&&(s.files=r.filter(e=>e.gated!==!1).map(e=>({file:e.file,baseFunctionCount:e.baseMetrics?.functions.length??0,headFunctionCount:e.headMetrics?.functions.length??0,baseNcss:e.baseMetrics?.ncssCount??0,headNcss:e.headMetrics?.ncssCount??0,baseMaxCognitiveComplexity:e.baseMetrics?.maxCognitiveComplexity??0,headMaxCognitiveComplexity:e.headMetrics?.maxCognitiveComplexity??0,baseDuplicatedLineCount:e.baseDuplicatedLineCount,headDuplicatedLineCount:e.headDuplicatedLineCount,duplicationPartners:e.duplicationPartners,functions:n.checkedFunctions.filter(t=>t.file===e.file)}))),o.writeStdout(JSON.stringify(s,void 0,2)+`
4
4
  `)}exports.runDiffCommand=l;