code-gauge 10.2.2 → 10.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/THIRD-PARTY-NOTICES.txt +116 -74
- package/dist/checkCommand.cjs.map +1 -1
- package/dist/checkCommand.js.map +1 -1
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/cliConfig.cjs.map +1 -1
- package/dist/cliConfig.js.map +1 -1
- package/dist/crossFileDuplication.cjs.map +1 -1
- package/dist/crossFileDuplication.js.map +1 -1
- package/dist/crossFileNearMiss.cjs.map +1 -1
- package/dist/crossFileNearMiss.js.map +1 -1
- package/dist/duplicateSelection.cjs.map +1 -1
- package/dist/duplicateSelection.js.map +1 -1
- package/dist/duplication.cjs.map +1 -1
- package/dist/duplication.js.map +1 -1
- package/dist/exclusion.cjs.map +1 -1
- package/dist/exclusion.js.map +1 -1
- package/dist/git.cjs.map +1 -1
- package/dist/git.js.map +1 -1
- package/dist/languages.cjs.map +1 -1
- package/dist/languages.js.map +1 -1
- package/dist/metrics.cjs.map +1 -1
- package/dist/metrics.js.map +1 -1
- package/dist/nativeMetrics.cjs.map +1 -1
- package/dist/nativeMetrics.js.map +1 -1
- package/dist/scan.cjs.map +1 -1
- package/dist/scan.js.map +1 -1
- package/dist/thresholdCheck.cjs.map +1 -1
- package/dist/thresholdCheck.js.map +1 -1
- package/dist/thresholds.cjs.map +1 -1
- package/dist/thresholds.js.map +1 -1
- package/dist/wasmBinding.cjs +1 -1
- package/dist/wasmBinding.cjs.map +1 -1
- package/dist/wasmBinding.js +1 -1
- package/dist/wasmBinding.js.map +1 -1
- package/dist/worker.cjs.map +1 -1
- package/dist/worker.js.map +1 -1
- package/native/Cargo.lock +58 -58
- package/native/Cargo.toml +15 -13
- package/native/code-gauge.wasm +0 -0
- package/package.json +14 -14
package/dist/scan.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scan.cjs","names":["os","path","stat","realpath","getErrorMessage","lstat","readFile","readdir","measureCode","NativeAddonError","measureCodeWithCrossFileDataAsync","measureCrossFileDuplication","detectLanguage"],"sources":["../src/scan.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { measureCrossFileDuplication, type CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport type { CrossFileDuplicationFileData } from './duplication.js';\nimport type { Exclusion } from './exclusion.js';\nimport { detectLanguage } from './languages.js';\nimport { measureCode, measureCodeWithCrossFileDataAsync } from './metrics.js';\nimport { NativeAddonError } from './nativeMetrics.js';\nimport type { CodeMetrics, DuplicationOptions, LanguageName, MeasureOptions } from './types.js';\n\n/** The scan settings shared by every command (a structural subset of each command's options). */\nexport interface ScanOptions {\n duplication: Required<DuplicationOptions>;\n includeTests: boolean;\n /**\n * Loads the exclusion covering the files a directory scan found (absolute paths); an explicitly\n * targeted file is always measured.\n */\n loadExclusion?: (absolutePaths: readonly string[]) => Promise<Exclusion>;\n}\n\nexport interface FileMetrics {\n file: string;\n metrics: CodeMetrics;\n /** Cross-file duplicate candidates and token/statement data, collected only for directory scans. */\n duplicationCandidates?: CrossFileDuplicationFileData;\n}\n\nexport interface ScanResult {\n crossFileDuplication?: CrossFileDuplicationMetrics;\n displayRoot: string;\n errors: string[];\n /** Non-fatal degradations (e.g. cross-file candidates unavailable); the file is still measured. */\n warnings: string[];\n /** Files skipped because their content is generated code (see Exclusion.isGeneratedCode). */\n generatedFiles: string[];\n fatalError?: string;\n files: FileMetrics[];\n}\n\nconst ignoredDirectoryNames = new Set([\n '.agents',\n '.claude',\n '.cursor',\n '.git',\n '.next',\n '.playwright-cli',\n '.tox',\n '.tmp',\n '.turbo',\n '.venv',\n '.yarn',\n '__fixtures__',\n '__generated__',\n '__pycache__',\n 'coverage',\n 'dist',\n 'fixtures',\n 'generated',\n 'node_modules',\n // .NET SDK intermediate output (generated sources such as `*.GlobalUsings.g.cs`).\n 'obj',\n 'target',\n 'test-fixtures',\n 'vendor',\n 'venv',\n]);\n\nconst testDirectoryNames = new Set(['__tests__', 'test', 'tests', 'spec']);\nconst testFilePattern = /(?:^test(?:[_-].*)?|\\.(?:spec|test)|[_-](?:test|spec))\\.[^.]+$/iu;\n// JUnit (Java/Kotlin) and xUnit/NUnit (C#) tests use case-sensitive `Test`/`Tests` class-name\n// suffixes; case-insensitive matching would catch production files like `contest.java`.\nconst suffixTestFilePattern = /Tests?\\.(?:java|kt|cs)$/u;\n\nexport function resolveTarget(target: string): string {\n if (target === '~') {\n return os.homedir();\n }\n\n if (target.startsWith('~/')) {\n return path.join(os.homedir(), target.slice(2));\n }\n\n return path.resolve(target);\n}\n\n/** Returns the directory from which the config file search should start (the target itself if it is a directory). */\nexport async function configSearchDirectory(target: string): Promise<string> {\n try {\n const targetStat = await stat(target);\n return targetStat.isDirectory() ? target : path.dirname(target);\n } catch {\n return path.dirname(target);\n }\n}\n\n/** Shared state of one scan, threaded through the directory walk instead of positional plumbing. */\ninterface ScanContext {\n options: ScanOptions;\n /**\n * Outcomes in discovery order. Files are measured concurrently, so a measurement is recorded as\n * a promise here and applied in this order once all have settled, keeping results deterministic.\n */\n outcomes: (ScanOutcome | Promise<ScanOutcome>)[];\n /** Measurements in flight, bounded so file contents and payloads do not pile up. */\n inFlight: Set<Promise<ScanOutcome>>;\n /** Set once a measurement fails fatally; no further measurement starts. */\n fatalSeen: boolean;\n visitedDirectories: Set<string>;\n visitedFiles: Set<string>;\n /** Scan root: paths are displayed relative to it, and symbolic links may not escape it. */\n rootDirectory: string;\n /** Files the walk found, measured once the exclusion covering all of them is loaded. */\n candidates: ScanCandidate[];\n exclusion?: Exclusion;\n}\n\ninterface ScanCandidate {\n file: string;\n language: LanguageName;\n /** The resolved path when the file was reached through a symbolic link. */\n realFile?: string;\n}\n\n/**\n * A missing native addon fails every file identically, so it ends the scan as one fatal error\n * instead of one \"skipped\" entry per file behind a successful exit code.\n */\ntype ScanOutcome =\n | { file: FileMetrics; warning?: string }\n | { generatedFile: string }\n | { error: string }\n | { fatal: NativeAddonError };\n\n// Twice the addon's worker count keeps its pool busy while finished payloads are parsed.\nconst maxMeasurementsInFlight = os.availableParallelism() * 2;\n\nexport async function scanTarget(target: string, options: ScanOptions): Promise<ScanResult> {\n let canonicalTarget = target;\n try {\n canonicalTarget = await realpath(target);\n } catch {\n // stat below reports missing targets with the original path.\n }\n\n const fallbackDisplayRoot = path.dirname(canonicalTarget);\n let targetStat;\n\n try {\n targetStat = await stat(canonicalTarget);\n } catch (error) {\n const fatalError = `${formatPath(canonicalTarget, fallbackDisplayRoot)}: ${getErrorMessage(error)}`;\n return {\n displayRoot: fallbackDisplayRoot,\n files: [],\n errors: [fatalError],\n warnings: [],\n generatedFiles: [],\n fatalError,\n };\n }\n\n if (targetStat.isFile()) {\n const displayRoot = path.dirname(canonicalTarget);\n const language = getLanguage(canonicalTarget, options, true);\n if (!language) {\n const fatalError = `${formatPath(canonicalTarget, displayRoot)}: unsupported file type`;\n return { displayRoot, files: [], errors: [fatalError], warnings: [], generatedFiles: [], fatalError };\n }\n\n const context = makeScanContext(options, displayRoot);\n await measureFile(canonicalTarget, language, 'single-file', context, canonicalTarget);\n return settleScan(context, displayRoot);\n }\n\n const context = makeScanContext(options, canonicalTarget);\n await scanDirectory(canonicalTarget, context);\n await measureCandidates(context);\n return settleScan(context, canonicalTarget);\n}\n\n/**\n * Measures an explicit list of repository-relative files (the git-visible ones of `check`) instead\n * of walking the directory tree, so ignored artifact directories are never parsed. Paths outside\n * the scan scope (ignored/test directories, unsupported or test file names) are skipped with the\n * same rules as the walk. Like the walk, which never tests the directory it starts in, the rules\n * apply to a file under `targetDirectory` (repository-relative, forward slashes) by its path below\n * that directory, so naming `vendor` or `test` as the target measures what is in it.\n */\nexport async function scanListedFiles(\n rootDirectory: string,\n relativePaths: Iterable<string>,\n options: ScanOptions,\n explicitFiles: ReadonlySet<string> = new Set(),\n targetDirectory = ''\n): Promise<ScanResult> {\n const context = makeScanContext(options, rootDirectory);\n const targetPrefix = targetDirectory === '' ? '' : `${targetDirectory}/`;\n for (const relativePath of relativePaths) {\n const absolutePath = path.join(rootDirectory, relativePath);\n const scopedPath = relativePath.startsWith(targetPrefix) ? relativePath.slice(targetPrefix.length) : relativePath;\n const language = isScannedPath(scopedPath, options, explicitFiles.has(absolutePath))\n ? getLanguage(relativePath, options, explicitFiles.has(absolutePath))\n : undefined;\n if (!language) {\n continue;\n }\n // Like the walk, only regular files are sources. A symbolic link is not one: git stores only its\n // target string, so measuring through it would diverge from what any revision of the repository\n // contains. Reading a FIFO would block forever. A path lstat cannot inspect stays a candidate so\n // that measuring it reports the error.\n const stats = await lstat(absolutePath).catch(() => {});\n if (!stats || stats.isFile()) {\n context.candidates.push({ file: absolutePath, language });\n }\n }\n await measureCandidates(context);\n return settleScan(context, rootDirectory);\n}\n\nasync function measureCandidates(context: ScanContext): Promise<void> {\n const exclusion = await context.options.loadExclusion?.(\n context.candidates.flatMap(({ file, realFile }) => (realFile === undefined ? [file] : [file, realFile]))\n );\n context.exclusion = exclusion;\n // Every path reaching the same real file, in discovery order, so one alias cannot decide alone.\n const pathsByRealFile = new Map<string, ScanCandidate[]>();\n for (const candidate of context.candidates) {\n let realFile;\n try {\n realFile = candidate.realFile ?? (await realpath(candidate.file));\n } catch (error) {\n recordError(context, candidate.file, error);\n continue;\n }\n pathsByRealFile.set(realFile, [...(pathsByRealFile.get(realFile) ?? []), candidate]);\n }\n for (const [realFile, candidates] of pathsByRealFile) {\n if (context.fatalSeen) {\n return;\n }\n const chosen = await chooseMeasuredPath(realFile, candidates, exclusion);\n if (chosen !== undefined) {\n await measureFile(chosen.file, chosen.language, 'directory', context, realFile);\n }\n }\n}\n\n/**\n * The path to measure a real file through. A path is excluded when its own exclusion or the real\n * file's applies, so the real path, when the scan reached it directly, is never less measurable\n * than an alias; otherwise the first alias not excluded, preferring one whose own attributes do\n * not judge the content generated.\n */\nasync function chooseMeasuredPath(\n realFile: string,\n candidates: readonly ScanCandidate[],\n exclusion: Exclusion | undefined\n): Promise<ScanCandidate | undefined> {\n if (exclusion === undefined) {\n return candidates[0];\n }\n if (exclusion.isExcludedPath(realFile)) {\n return undefined;\n }\n const measurable = candidates.filter(({ file }) => !exclusion.isExcludedPath(file));\n const direct = measurable.find(({ file }) => file === realFile);\n if (direct !== undefined || measurable.length <= 1) {\n return direct ?? measurable[0];\n }\n const code = await readFile(realFile, 'utf8').catch(() => {});\n return measurable.find(({ file }) => code !== undefined && !exclusion.isGeneratedCode(file, code)) ?? measurable[0];\n}\n\n/** Applies the scan's outcomes in discovery order once every measurement has settled. */\nasync function settleScan(context: ScanContext, displayRoot: string): Promise<ScanResult> {\n const files: FileMetrics[] = [];\n const errors: string[] = [];\n const warnings: string[] = [];\n const generatedFiles: string[] = [];\n for (const pending of context.outcomes) {\n const outcome = await pending;\n if ('fatal' in outcome) {\n const fatalError = getErrorMessage(outcome.fatal);\n // Errors the walk recorded before the fatal failure stay reported alongside it.\n return { displayRoot, files, errors: [...errors, fatalError], warnings, generatedFiles, fatalError };\n }\n if ('generatedFile' in outcome) {\n generatedFiles.push(outcome.generatedFile);\n continue;\n }\n if ('error' in outcome) {\n errors.push(outcome.error);\n continue;\n }\n files.push(outcome.file);\n if (outcome.warning !== undefined) {\n warnings.push(outcome.warning);\n }\n }\n return { displayRoot, files, errors, warnings, generatedFiles };\n}\n\nfunction makeScanContext(options: ScanOptions, rootDirectory: string): ScanContext {\n return {\n options,\n outcomes: [],\n inFlight: new Set(),\n fatalSeen: false,\n visitedDirectories: new Set(),\n visitedFiles: new Set(),\n rootDirectory,\n candidates: [],\n };\n}\n\nfunction recordError(context: ScanContext, target: string, error: unknown): void {\n context.outcomes.push({ error: `${formatPath(target, context.rootDirectory)}: ${getErrorMessage(error)}` });\n}\n\n/** Runs a filesystem operation, recording a scan error and returning undefined when it fails. */\nasync function tryFileSystem<T>(\n operation: () => Promise<T>,\n target: string,\n context: ScanContext\n): Promise<T | undefined> {\n try {\n return await operation();\n } catch (error) {\n recordError(context, target, error);\n return undefined;\n }\n}\n\n/** Resolves the path (recording errors); undefined when that fails or the result escapes the root. */\nasync function resolveWithinRoot(target: string, context: ScanContext): Promise<string | undefined> {\n const resolved = await tryFileSystem(() => realpath(target), target, context);\n return resolved !== undefined && isWithinDirectory(resolved, context.rootDirectory) ? resolved : undefined;\n}\n\nasync function scanDirectory(directory: string, context: ScanContext): Promise<void> {\n const resolvedDirectory = await resolveWithinRoot(directory, context);\n if (resolvedDirectory === undefined || context.visitedDirectories.has(resolvedDirectory)) {\n return;\n }\n context.visitedDirectories.add(resolvedDirectory);\n\n const entries = await tryFileSystem(() => readdir(directory, { withFileTypes: true }), directory, context);\n if (entries === undefined) {\n return;\n }\n // readdir order differs between file systems; sorting keeps which path reaches a file first,\n // and so the scan's result, the same everywhere.\n entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));\n\n for (const entry of entries) {\n const entryPath = path.join(directory, entry.name);\n if (entry.isSymbolicLink()) {\n await scanSymbolicLink(entry.name, entryPath, context);\n continue;\n }\n\n if (entry.isDirectory()) {\n if (shouldSkipDirectory(entry.name, context.options)) {\n continue;\n }\n await scanDirectory(entryPath, context);\n continue;\n }\n\n if (entry.isFile()) {\n // Under a symbolically linked directory the real path differs, and exclusion checks both.\n const realFile = resolvedDirectory === directory ? undefined : path.join(resolvedDirectory, entry.name);\n collectScannableFile(entryPath, context, entryPath, realFile);\n }\n }\n}\n\nasync function scanSymbolicLink(name: string, entryPath: string, context: ScanContext): Promise<void> {\n const resolvedPath = await resolveWithinRoot(entryPath, context);\n if (resolvedPath === undefined) {\n return;\n }\n\n const entryStat = await tryFileSystem(() => stat(entryPath), entryPath, context);\n if (entryStat === undefined) {\n return;\n }\n\n if (entryStat.isDirectory()) {\n if (\n shouldSkipDirectory(name, context.options) ||\n shouldSkipDirectory(path.basename(resolvedPath), context.options)\n ) {\n return;\n }\n await scanDirectory(entryPath, context);\n return;\n }\n\n if (entryStat.isFile()) {\n collectScannableFile(entryPath, context, resolvedPath, resolvedPath);\n }\n}\n\nfunction collectScannableFile(file: string, context: ScanContext, languageFile = file, realFile?: string): void {\n const language = getLanguage(languageFile, context.options);\n if (language) {\n context.candidates.push({ file, language, realFile });\n }\n}\n\n/**\n * Resolves and deduplicates the file in discovery order, then starts measuring it concurrently; its\n * outcome is recorded in discovery order (see ScanContext.outcomes).\n */\nasync function measureFile(\n file: string,\n language: LanguageName,\n mode: 'single-file' | 'directory',\n context: ScanContext,\n realFile?: string\n): Promise<void> {\n if (context.fatalSeen) {\n return;\n }\n let resolvedFile;\n try {\n resolvedFile = realFile ?? (await realpath(file));\n } catch (error) {\n recordError(context, file, error);\n return;\n }\n if (context.visitedFiles.has(resolvedFile)) {\n return;\n }\n context.visitedFiles.add(resolvedFile);\n\n // Any settled measurement frees its slot (removed by the callback below, which runs before the\n // race resumes), so one slow file never idles the pool.\n while (context.inFlight.size >= maxMeasurementsInFlight) {\n await Promise.race(context.inFlight);\n }\n const outcome = readAndMeasureFile(file, resolvedFile, language, mode, context);\n context.inFlight.add(outcome);\n void outcome.then(() => context.inFlight.delete(outcome));\n context.outcomes.push(outcome);\n}\n\nasync function readAndMeasureFile(\n file: string,\n resolvedFile: string,\n language: LanguageName,\n mode: 'single-file' | 'directory',\n context: ScanContext\n): Promise<ScanOutcome> {\n try {\n const code = await readFile(file, 'utf8');\n const measureOptions = { language, duplication: context.options.duplication };\n // Only directory scans compare files against each other; a single-file target has no peers.\n if (mode === 'single-file') {\n return { file: { file, metrics: measureCode(code, measureOptions) } };\n }\n // Like path exclusion, either the scanned path or the real one can judge the code generated.\n const exclusion = context.exclusion;\n if (exclusion && (exclusion.isGeneratedCode(file, code) || exclusion.isGeneratedCode(resolvedFile, code))) {\n return { generatedFile: file };\n }\n const { metrics, crossFileData, crossFileError } = await measureWithCrossFileData(code, measureOptions);\n return {\n file: { file, metrics, duplicationCandidates: crossFileData },\n // A warning, not an error: the file's metrics are complete, only its participation in\n // cross-file matching is lost, so it is not \"skipped\" and must not fail --fail-on-error.\n warning:\n crossFileError === undefined\n ? undefined\n : `${formatPath(file, context.rootDirectory)}: cross-file duplication candidates unavailable: ${crossFileError}`,\n };\n } catch (error) {\n if (error instanceof NativeAddonError) {\n context.fatalSeen = true;\n return { fatal: error };\n }\n return { error: `${formatPath(file, context.rootDirectory)}: ${getErrorMessage(error)}` };\n }\n}\n\n/**\n * Measures a file together with its cross-file contribution from one parse. The contribution is\n * auxiliary: if collecting it fails where plain measurement succeeds (e.g. a payload too large to\n * cross the addon boundary), the metrics are still returned with the failure message, which\n * callers report as a warning rather than an error.\n */\nasync function measureWithCrossFileData(\n code: string,\n measureOptions: MeasureOptions\n): Promise<{ metrics: CodeMetrics; crossFileData?: CrossFileDuplicationFileData; crossFileError?: string }> {\n try {\n return await measureCodeWithCrossFileDataAsync(code, measureOptions);\n } catch (error) {\n if (error instanceof NativeAddonError) {\n throw error;\n }\n return { metrics: measureCode(code, measureOptions), crossFileError: getErrorMessage(error) };\n }\n}\n\n/** Runs after the scan so every measured file's candidates participate. */\nexport function addCrossFileDuplication(result: ScanResult, options: ScanOptions): void {\n if (result.fatalError || result.files.length < 2) {\n return;\n }\n const sourceFiles = result.files.flatMap(({ file, duplicationCandidates }) =>\n duplicationCandidates ? [{ file: formatPath(file, result.displayRoot), ...duplicationCandidates }] : []\n );\n if (sourceFiles.length < 2) {\n return;\n }\n result.crossFileDuplication = measureCrossFileDuplication(sourceFiles, options.duplication);\n}\n\n/**\n * Distinct 1-based code lines covered by within-file or cross-file duplicated content. Both\n * sources expose the exact lines carrying matched tokens (never block bounding ranges, which\n * would over-count comment/blank lines and the unmatched gap of a merged clone), so the union is\n * a subset of the file's code lines and a ratio derived over code lines can never exceed 1.\n */\nexport function collectDuplicatedLineNumbers(\n metrics: CodeMetrics | undefined,\n crossFileDuplication: CrossFileDuplicationMetrics | undefined,\n formattedFile: string\n): Set<number> {\n const lines = new Set(metrics?.duplication.duplicateLineNumbers);\n // Object.hasOwn: a file named like an Object.prototype member must not read an inherited value.\n const crossFileLines =\n crossFileDuplication && Object.hasOwn(crossFileDuplication.duplicateLineNumbersByFile, formattedFile)\n ? (crossFileDuplication.duplicateLineNumbersByFile[formattedFile] ?? [])\n : [];\n for (const line of crossFileLines) {\n lines.add(line);\n }\n return lines;\n}\n\nfunction shouldSkipDirectory(name: string, options: ScanOptions): boolean {\n if (ignoredDirectoryNames.has(name)) {\n return true;\n }\n\n if (options.includeTests) {\n return false;\n }\n\n return testDirectoryNames.has(name);\n}\n\nexport function isWithinDirectory(candidate: string, directory: string): boolean {\n const relative = path.relative(directory, candidate);\n return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));\n}\n\n/**\n * Whether a repository-relative path would be scanned: no ignored or excluded-test directory\n * segment and a supported, non-test file name. An explicitly targeted file only needs a supported\n * language, as in the ranking command.\n */\nfunction isScannedPath(relativePath: string, options: ScanOptions, explicitTarget = false): boolean {\n if (explicitTarget) {\n return getLanguage(relativePath, options, true) !== undefined;\n }\n const segments = relativePath.split('/');\n for (const segment of segments.slice(0, -1)) {\n if (ignoredDirectoryNames.has(segment) || (!options.includeTests && testDirectoryNames.has(segment))) {\n return false;\n }\n }\n return getLanguage(relativePath, options) !== undefined;\n}\n\nexport function getLanguage(file: string, options: ScanOptions, explicitTarget = false): LanguageName | undefined {\n const lowerFile = file.toLowerCase();\n if (\n !explicitTarget &&\n (lowerFile.endsWith('.d.ts') ||\n lowerFile.endsWith('.d.mts') ||\n lowerFile.endsWith('.d.cts') ||\n lowerFile.endsWith('.min.js') ||\n lowerFile.endsWith('.pnp.cjs'))\n ) {\n return undefined;\n }\n\n if (\n !explicitTarget &&\n !options.includeTests &&\n (testFilePattern.test(path.basename(file)) || suffixTestFilePattern.test(path.basename(file)))\n ) {\n return undefined;\n }\n\n return detectLanguage(file);\n}\n\n/** The path relative to `base` with forward slashes on every platform, so it compares equal to the paths git prints. */\nexport function formatPath(file: string, base: string): string {\n return path.relative(base, file).replaceAll(path.sep, '/') || path.basename(file);\n}\n\nexport function writeStdout(message: string): void {\n process.stdout.write(message);\n}\n\nexport function writeStderr(message: string): void {\n process.stderr.write(message);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"scan.cjs","names":["os","path","stat","realpath","getErrorMessage","lstat","readFile","readdir","measureCode","NativeAddonError","measureCodeWithCrossFileDataAsync","measureCrossFileDuplication","detectLanguage","require_runtime","require_crossFileDuplication","require_languages","require_nativeMetrics","require_metrics","_willbooster_shared_lib","node_fs_promises","node_path","node_os","ignoredDirectoryNames","testDirectoryNames","testFilePattern","suffixTestFilePattern","resolveTarget","target","configSearchDirectory","maxMeasurementsInFlight","scanTarget","canonicalTarget","fallbackDisplayRoot","targetStat","error","fatalError","formatPath","displayRoot","language","getLanguage","options","context","makeScanContext","measureFile","settleScan","scanDirectory","measureCandidates","scanListedFiles","rootDirectory","targetPrefix","targetDirectory","relativePath","relativePaths","absolutePath","isScannedPath","explicitFiles","stats","exclusion","realFile","file","pathsByRealFile","candidate","recordError","candidates","chosen","chooseMeasuredPath","measurable","direct","code","files","errors","warnings","generatedFiles","pending","outcome","tryFileSystem","operation","resolveWithinRoot","resolved","isWithinDirectory","resolvedDirectory","directory","entries","left","right","entry","entryPath","scanSymbolicLink","shouldSkipDirectory","collectScannableFile","resolvedPath","entryStat","name","languageFile","resolvedFile","readAndMeasureFile","mode","measureOptions","measureWithCrossFileData","crossFileData","crossFileError","addCrossFileDuplication","result","sourceFiles","duplicationCandidates","collectDuplicatedLineNumbers","lines","metrics","crossFileLines","crossFileDuplication","formattedFile","line","relative","explicitTarget","segments","segment","lowerFile","base","writeStdout","message","writeStderr"],"sources":["../src/scan.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { measureCrossFileDuplication, type CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport type { CrossFileDuplicationFileData } from './duplication.js';\nimport type { Exclusion } from './exclusion.js';\nimport { detectLanguage } from './languages.js';\nimport { measureCode, measureCodeWithCrossFileDataAsync } from './metrics.js';\nimport { NativeAddonError } from './nativeMetrics.js';\nimport type { CodeMetrics, DuplicationOptions, LanguageName, MeasureOptions } from './types.js';\n\n/** The scan settings shared by every command (a structural subset of each command's options). */\nexport interface ScanOptions {\n duplication: Required<DuplicationOptions>;\n includeTests: boolean;\n /**\n * Loads the exclusion covering the files a directory scan found (absolute paths); an explicitly\n * targeted file is always measured.\n */\n loadExclusion?: (absolutePaths: readonly string[]) => Promise<Exclusion>;\n}\n\nexport interface FileMetrics {\n file: string;\n metrics: CodeMetrics;\n /** Cross-file duplicate candidates and token/statement data, collected only for directory scans. */\n duplicationCandidates?: CrossFileDuplicationFileData;\n}\n\nexport interface ScanResult {\n crossFileDuplication?: CrossFileDuplicationMetrics;\n displayRoot: string;\n errors: string[];\n /** Non-fatal degradations (e.g. cross-file candidates unavailable); the file is still measured. */\n warnings: string[];\n /** Files skipped because their content is generated code (see Exclusion.isGeneratedCode). */\n generatedFiles: string[];\n fatalError?: string;\n files: FileMetrics[];\n}\n\nconst ignoredDirectoryNames = new Set([\n '.agents',\n '.claude',\n '.cursor',\n '.git',\n '.next',\n '.playwright-cli',\n '.tox',\n '.tmp',\n '.turbo',\n '.venv',\n '.yarn',\n '__fixtures__',\n '__generated__',\n '__pycache__',\n 'coverage',\n 'dist',\n 'fixtures',\n 'generated',\n 'node_modules',\n // .NET SDK intermediate output (generated sources such as `*.GlobalUsings.g.cs`).\n 'obj',\n 'target',\n 'test-fixtures',\n 'vendor',\n 'venv',\n]);\n\nconst testDirectoryNames = new Set(['__tests__', 'test', 'tests', 'spec']);\nconst testFilePattern = /(?:^test(?:[_-].*)?|\\.(?:spec|test)|[_-](?:test|spec))\\.[^.]+$/iu;\n// JUnit (Java/Kotlin) and xUnit/NUnit (C#) tests use case-sensitive `Test`/`Tests` class-name\n// suffixes; case-insensitive matching would catch production files like `contest.java`.\nconst suffixTestFilePattern = /Tests?\\.(?:java|kt|cs)$/u;\n\nexport function resolveTarget(target: string): string {\n if (target === '~') {\n return os.homedir();\n }\n\n if (target.startsWith('~/')) {\n return path.join(os.homedir(), target.slice(2));\n }\n\n return path.resolve(target);\n}\n\n/** Returns the directory from which the config file search should start (the target itself if it is a directory). */\nexport async function configSearchDirectory(target: string): Promise<string> {\n try {\n const targetStat = await stat(target);\n return targetStat.isDirectory() ? target : path.dirname(target);\n } catch {\n return path.dirname(target);\n }\n}\n\n/** Shared state of one scan, threaded through the directory walk instead of positional plumbing. */\ninterface ScanContext {\n options: ScanOptions;\n /**\n * Outcomes in discovery order. Files are measured concurrently, so a measurement is recorded as\n * a promise here and applied in this order once all have settled, keeping results deterministic.\n */\n outcomes: (ScanOutcome | Promise<ScanOutcome>)[];\n /** Measurements in flight, bounded so file contents and payloads do not pile up. */\n inFlight: Set<Promise<ScanOutcome>>;\n /** Set once a measurement fails fatally; no further measurement starts. */\n fatalSeen: boolean;\n visitedDirectories: Set<string>;\n visitedFiles: Set<string>;\n /** Scan root: paths are displayed relative to it, and symbolic links may not escape it. */\n rootDirectory: string;\n /** Files the walk found, measured once the exclusion covering all of them is loaded. */\n candidates: ScanCandidate[];\n exclusion?: Exclusion;\n}\n\ninterface ScanCandidate {\n file: string;\n language: LanguageName;\n /** The resolved path when the file was reached through a symbolic link. */\n realFile?: string;\n}\n\n/**\n * A missing native addon fails every file identically, so it ends the scan as one fatal error\n * instead of one \"skipped\" entry per file behind a successful exit code.\n */\ntype ScanOutcome =\n | { file: FileMetrics; warning?: string }\n | { generatedFile: string }\n | { error: string }\n | { fatal: NativeAddonError };\n\n// Twice the addon's worker count keeps its pool busy while finished payloads are parsed.\nconst maxMeasurementsInFlight = os.availableParallelism() * 2;\n\nexport async function scanTarget(target: string, options: ScanOptions): Promise<ScanResult> {\n let canonicalTarget = target;\n try {\n canonicalTarget = await realpath(target);\n } catch {\n // stat below reports missing targets with the original path.\n }\n\n const fallbackDisplayRoot = path.dirname(canonicalTarget);\n let targetStat;\n\n try {\n targetStat = await stat(canonicalTarget);\n } catch (error) {\n const fatalError = `${formatPath(canonicalTarget, fallbackDisplayRoot)}: ${getErrorMessage(error)}`;\n return {\n displayRoot: fallbackDisplayRoot,\n files: [],\n errors: [fatalError],\n warnings: [],\n generatedFiles: [],\n fatalError,\n };\n }\n\n if (targetStat.isFile()) {\n const displayRoot = path.dirname(canonicalTarget);\n const language = getLanguage(canonicalTarget, options, true);\n if (!language) {\n const fatalError = `${formatPath(canonicalTarget, displayRoot)}: unsupported file type`;\n return { displayRoot, files: [], errors: [fatalError], warnings: [], generatedFiles: [], fatalError };\n }\n\n const context = makeScanContext(options, displayRoot);\n await measureFile(canonicalTarget, language, 'single-file', context, canonicalTarget);\n return settleScan(context, displayRoot);\n }\n\n const context = makeScanContext(options, canonicalTarget);\n await scanDirectory(canonicalTarget, context);\n await measureCandidates(context);\n return settleScan(context, canonicalTarget);\n}\n\n/**\n * Measures an explicit list of repository-relative files (the git-visible ones of `check`) instead\n * of walking the directory tree, so ignored artifact directories are never parsed. Paths outside\n * the scan scope (ignored/test directories, unsupported or test file names) are skipped with the\n * same rules as the walk. Like the walk, which never tests the directory it starts in, the rules\n * apply to a file under `targetDirectory` (repository-relative, forward slashes) by its path below\n * that directory, so naming `vendor` or `test` as the target measures what is in it.\n */\nexport async function scanListedFiles(\n rootDirectory: string,\n relativePaths: Iterable<string>,\n options: ScanOptions,\n explicitFiles: ReadonlySet<string> = new Set(),\n targetDirectory = ''\n): Promise<ScanResult> {\n const context = makeScanContext(options, rootDirectory);\n const targetPrefix = targetDirectory === '' ? '' : `${targetDirectory}/`;\n for (const relativePath of relativePaths) {\n const absolutePath = path.join(rootDirectory, relativePath);\n const scopedPath = relativePath.startsWith(targetPrefix) ? relativePath.slice(targetPrefix.length) : relativePath;\n const language = isScannedPath(scopedPath, options, explicitFiles.has(absolutePath))\n ? getLanguage(relativePath, options, explicitFiles.has(absolutePath))\n : undefined;\n if (!language) {\n continue;\n }\n // Like the walk, only regular files are sources. A symbolic link is not one: git stores only its\n // target string, so measuring through it would diverge from what any revision of the repository\n // contains. Reading a FIFO would block forever. A path lstat cannot inspect stays a candidate so\n // that measuring it reports the error.\n const stats = await lstat(absolutePath).catch(() => {});\n if (!stats || stats.isFile()) {\n context.candidates.push({ file: absolutePath, language });\n }\n }\n await measureCandidates(context);\n return settleScan(context, rootDirectory);\n}\n\nasync function measureCandidates(context: ScanContext): Promise<void> {\n const exclusion = await context.options.loadExclusion?.(\n context.candidates.flatMap(({ file, realFile }) => (realFile === undefined ? [file] : [file, realFile]))\n );\n context.exclusion = exclusion;\n // Every path reaching the same real file, in discovery order, so one alias cannot decide alone.\n const pathsByRealFile = new Map<string, ScanCandidate[]>();\n for (const candidate of context.candidates) {\n let realFile;\n try {\n realFile = candidate.realFile ?? (await realpath(candidate.file));\n } catch (error) {\n recordError(context, candidate.file, error);\n continue;\n }\n pathsByRealFile.set(realFile, [...(pathsByRealFile.get(realFile) ?? []), candidate]);\n }\n for (const [realFile, candidates] of pathsByRealFile) {\n if (context.fatalSeen) {\n return;\n }\n const chosen = await chooseMeasuredPath(realFile, candidates, exclusion);\n if (chosen !== undefined) {\n await measureFile(chosen.file, chosen.language, 'directory', context, realFile);\n }\n }\n}\n\n/**\n * The path to measure a real file through. A path is excluded when its own exclusion or the real\n * file's applies, so the real path, when the scan reached it directly, is never less measurable\n * than an alias; otherwise the first alias not excluded, preferring one whose own attributes do\n * not judge the content generated.\n */\nasync function chooseMeasuredPath(\n realFile: string,\n candidates: readonly ScanCandidate[],\n exclusion: Exclusion | undefined\n): Promise<ScanCandidate | undefined> {\n if (exclusion === undefined) {\n return candidates[0];\n }\n if (exclusion.isExcludedPath(realFile)) {\n return undefined;\n }\n const measurable = candidates.filter(({ file }) => !exclusion.isExcludedPath(file));\n const direct = measurable.find(({ file }) => file === realFile);\n if (direct !== undefined || measurable.length <= 1) {\n return direct ?? measurable[0];\n }\n const code = await readFile(realFile, 'utf8').catch(() => {});\n return measurable.find(({ file }) => code !== undefined && !exclusion.isGeneratedCode(file, code)) ?? measurable[0];\n}\n\n/** Applies the scan's outcomes in discovery order once every measurement has settled. */\nasync function settleScan(context: ScanContext, displayRoot: string): Promise<ScanResult> {\n const files: FileMetrics[] = [];\n const errors: string[] = [];\n const warnings: string[] = [];\n const generatedFiles: string[] = [];\n for (const pending of context.outcomes) {\n const outcome = await pending;\n if ('fatal' in outcome) {\n const fatalError = getErrorMessage(outcome.fatal);\n // Errors the walk recorded before the fatal failure stay reported alongside it.\n return { displayRoot, files, errors: [...errors, fatalError], warnings, generatedFiles, fatalError };\n }\n if ('generatedFile' in outcome) {\n generatedFiles.push(outcome.generatedFile);\n continue;\n }\n if ('error' in outcome) {\n errors.push(outcome.error);\n continue;\n }\n files.push(outcome.file);\n if (outcome.warning !== undefined) {\n warnings.push(outcome.warning);\n }\n }\n return { displayRoot, files, errors, warnings, generatedFiles };\n}\n\nfunction makeScanContext(options: ScanOptions, rootDirectory: string): ScanContext {\n return {\n options,\n outcomes: [],\n inFlight: new Set(),\n fatalSeen: false,\n visitedDirectories: new Set(),\n visitedFiles: new Set(),\n rootDirectory,\n candidates: [],\n };\n}\n\nfunction recordError(context: ScanContext, target: string, error: unknown): void {\n context.outcomes.push({ error: `${formatPath(target, context.rootDirectory)}: ${getErrorMessage(error)}` });\n}\n\n/** Runs a filesystem operation, recording a scan error and returning undefined when it fails. */\nasync function tryFileSystem<T>(\n operation: () => Promise<T>,\n target: string,\n context: ScanContext\n): Promise<T | undefined> {\n try {\n return await operation();\n } catch (error) {\n recordError(context, target, error);\n return undefined;\n }\n}\n\n/** Resolves the path (recording errors); undefined when that fails or the result escapes the root. */\nasync function resolveWithinRoot(target: string, context: ScanContext): Promise<string | undefined> {\n const resolved = await tryFileSystem(() => realpath(target), target, context);\n return resolved !== undefined && isWithinDirectory(resolved, context.rootDirectory) ? resolved : undefined;\n}\n\nasync function scanDirectory(directory: string, context: ScanContext): Promise<void> {\n const resolvedDirectory = await resolveWithinRoot(directory, context);\n if (resolvedDirectory === undefined || context.visitedDirectories.has(resolvedDirectory)) {\n return;\n }\n context.visitedDirectories.add(resolvedDirectory);\n\n const entries = await tryFileSystem(() => readdir(directory, { withFileTypes: true }), directory, context);\n if (entries === undefined) {\n return;\n }\n // readdir order differs between file systems; sorting keeps which path reaches a file first,\n // and so the scan's result, the same everywhere.\n entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));\n\n for (const entry of entries) {\n const entryPath = path.join(directory, entry.name);\n if (entry.isSymbolicLink()) {\n await scanSymbolicLink(entry.name, entryPath, context);\n continue;\n }\n\n if (entry.isDirectory()) {\n if (shouldSkipDirectory(entry.name, context.options)) {\n continue;\n }\n await scanDirectory(entryPath, context);\n continue;\n }\n\n if (entry.isFile()) {\n // Under a symbolically linked directory the real path differs, and exclusion checks both.\n const realFile = resolvedDirectory === directory ? undefined : path.join(resolvedDirectory, entry.name);\n collectScannableFile(entryPath, context, entryPath, realFile);\n }\n }\n}\n\nasync function scanSymbolicLink(name: string, entryPath: string, context: ScanContext): Promise<void> {\n const resolvedPath = await resolveWithinRoot(entryPath, context);\n if (resolvedPath === undefined) {\n return;\n }\n\n const entryStat = await tryFileSystem(() => stat(entryPath), entryPath, context);\n if (entryStat === undefined) {\n return;\n }\n\n if (entryStat.isDirectory()) {\n if (\n shouldSkipDirectory(name, context.options) ||\n shouldSkipDirectory(path.basename(resolvedPath), context.options)\n ) {\n return;\n }\n await scanDirectory(entryPath, context);\n return;\n }\n\n if (entryStat.isFile()) {\n collectScannableFile(entryPath, context, resolvedPath, resolvedPath);\n }\n}\n\nfunction collectScannableFile(file: string, context: ScanContext, languageFile = file, realFile?: string): void {\n const language = getLanguage(languageFile, context.options);\n if (language) {\n context.candidates.push({ file, language, realFile });\n }\n}\n\n/**\n * Resolves and deduplicates the file in discovery order, then starts measuring it concurrently; its\n * outcome is recorded in discovery order (see ScanContext.outcomes).\n */\nasync function measureFile(\n file: string,\n language: LanguageName,\n mode: 'single-file' | 'directory',\n context: ScanContext,\n realFile?: string\n): Promise<void> {\n if (context.fatalSeen) {\n return;\n }\n let resolvedFile;\n try {\n resolvedFile = realFile ?? (await realpath(file));\n } catch (error) {\n recordError(context, file, error);\n return;\n }\n if (context.visitedFiles.has(resolvedFile)) {\n return;\n }\n context.visitedFiles.add(resolvedFile);\n\n // Any settled measurement frees its slot (removed by the callback below, which runs before the\n // race resumes), so one slow file never idles the pool.\n while (context.inFlight.size >= maxMeasurementsInFlight) {\n await Promise.race(context.inFlight);\n }\n const outcome = readAndMeasureFile(file, resolvedFile, language, mode, context);\n context.inFlight.add(outcome);\n void outcome.then(() => context.inFlight.delete(outcome));\n context.outcomes.push(outcome);\n}\n\nasync function readAndMeasureFile(\n file: string,\n resolvedFile: string,\n language: LanguageName,\n mode: 'single-file' | 'directory',\n context: ScanContext\n): Promise<ScanOutcome> {\n try {\n const code = await readFile(file, 'utf8');\n const measureOptions = { language, duplication: context.options.duplication };\n // Only directory scans compare files against each other; a single-file target has no peers.\n if (mode === 'single-file') {\n return { file: { file, metrics: measureCode(code, measureOptions) } };\n }\n // Like path exclusion, either the scanned path or the real one can judge the code generated.\n const exclusion = context.exclusion;\n if (exclusion && (exclusion.isGeneratedCode(file, code) || exclusion.isGeneratedCode(resolvedFile, code))) {\n return { generatedFile: file };\n }\n const { metrics, crossFileData, crossFileError } = await measureWithCrossFileData(code, measureOptions);\n return {\n file: { file, metrics, duplicationCandidates: crossFileData },\n // A warning, not an error: the file's metrics are complete, only its participation in\n // cross-file matching is lost, so it is not \"skipped\" and must not fail --fail-on-error.\n warning:\n crossFileError === undefined\n ? undefined\n : `${formatPath(file, context.rootDirectory)}: cross-file duplication candidates unavailable: ${crossFileError}`,\n };\n } catch (error) {\n if (error instanceof NativeAddonError) {\n context.fatalSeen = true;\n return { fatal: error };\n }\n return { error: `${formatPath(file, context.rootDirectory)}: ${getErrorMessage(error)}` };\n }\n}\n\n/**\n * Measures a file together with its cross-file contribution from one parse. The contribution is\n * auxiliary: if collecting it fails where plain measurement succeeds (e.g. a payload too large to\n * cross the addon boundary), the metrics are still returned with the failure message, which\n * callers report as a warning rather than an error.\n */\nasync function measureWithCrossFileData(\n code: string,\n measureOptions: MeasureOptions\n): Promise<{ metrics: CodeMetrics; crossFileData?: CrossFileDuplicationFileData; crossFileError?: string }> {\n try {\n return await measureCodeWithCrossFileDataAsync(code, measureOptions);\n } catch (error) {\n if (error instanceof NativeAddonError) {\n throw error;\n }\n return { metrics: measureCode(code, measureOptions), crossFileError: getErrorMessage(error) };\n }\n}\n\n/** Runs after the scan so every measured file's candidates participate. */\nexport function addCrossFileDuplication(result: ScanResult, options: ScanOptions): void {\n if (result.fatalError || result.files.length < 2) {\n return;\n }\n const sourceFiles = result.files.flatMap(({ file, duplicationCandidates }) =>\n duplicationCandidates ? [{ file: formatPath(file, result.displayRoot), ...duplicationCandidates }] : []\n );\n if (sourceFiles.length < 2) {\n return;\n }\n result.crossFileDuplication = measureCrossFileDuplication(sourceFiles, options.duplication);\n}\n\n/**\n * Distinct 1-based code lines covered by within-file or cross-file duplicated content. Both\n * sources expose the exact lines carrying matched tokens (never block bounding ranges, which\n * would over-count comment/blank lines and the unmatched gap of a merged clone), so the union is\n * a subset of the file's code lines and a ratio derived over code lines can never exceed 1.\n */\nexport function collectDuplicatedLineNumbers(\n metrics: CodeMetrics | undefined,\n crossFileDuplication: CrossFileDuplicationMetrics | undefined,\n formattedFile: string\n): Set<number> {\n const lines = new Set(metrics?.duplication.duplicateLineNumbers);\n // Object.hasOwn: a file named like an Object.prototype member must not read an inherited value.\n const crossFileLines =\n crossFileDuplication && Object.hasOwn(crossFileDuplication.duplicateLineNumbersByFile, formattedFile)\n ? (crossFileDuplication.duplicateLineNumbersByFile[formattedFile] ?? [])\n : [];\n for (const line of crossFileLines) {\n lines.add(line);\n }\n return lines;\n}\n\nfunction shouldSkipDirectory(name: string, options: ScanOptions): boolean {\n if (ignoredDirectoryNames.has(name)) {\n return true;\n }\n\n if (options.includeTests) {\n return false;\n }\n\n return testDirectoryNames.has(name);\n}\n\nexport function isWithinDirectory(candidate: string, directory: string): boolean {\n const relative = path.relative(directory, candidate);\n return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));\n}\n\n/**\n * Whether a repository-relative path would be scanned: no ignored or excluded-test directory\n * segment and a supported, non-test file name. An explicitly targeted file only needs a supported\n * language, as in the ranking command.\n */\nfunction isScannedPath(relativePath: string, options: ScanOptions, explicitTarget = false): boolean {\n if (explicitTarget) {\n return getLanguage(relativePath, options, true) !== undefined;\n }\n const segments = relativePath.split('/');\n for (const segment of segments.slice(0, -1)) {\n if (ignoredDirectoryNames.has(segment) || (!options.includeTests && testDirectoryNames.has(segment))) {\n return false;\n }\n }\n return getLanguage(relativePath, options) !== undefined;\n}\n\nexport function getLanguage(file: string, options: ScanOptions, explicitTarget = false): LanguageName | undefined {\n const lowerFile = file.toLowerCase();\n if (\n !explicitTarget &&\n (lowerFile.endsWith('.d.ts') ||\n lowerFile.endsWith('.d.mts') ||\n lowerFile.endsWith('.d.cts') ||\n lowerFile.endsWith('.min.js') ||\n lowerFile.endsWith('.pnp.cjs'))\n ) {\n return undefined;\n }\n\n if (\n !explicitTarget &&\n !options.includeTests &&\n (testFilePattern.test(path.basename(file)) || suffixTestFilePattern.test(path.basename(file)))\n ) {\n return undefined;\n }\n\n return detectLanguage(file);\n}\n\n/** The path relative to `base` with forward slashes on every platform, so it compares equal to the paths git prints. */\nexport function formatPath(file: string, base: string): string {\n return path.relative(base, file).replaceAll(path.sep, '/') || path.basename(file);\n}\n\nexport function writeStdout(message: string): void {\n process.stdout.write(message);\n}\n\nexport function writeStderr(message: string): void {\n process.stderr.write(message);\n}\n"],"mappings":"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"}
|
package/dist/scan.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scan.js","names":[],"sources":["../src/scan.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { measureCrossFileDuplication, type CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport type { CrossFileDuplicationFileData } from './duplication.js';\nimport type { Exclusion } from './exclusion.js';\nimport { detectLanguage } from './languages.js';\nimport { measureCode, measureCodeWithCrossFileDataAsync } from './metrics.js';\nimport { NativeAddonError } from './nativeMetrics.js';\nimport type { CodeMetrics, DuplicationOptions, LanguageName, MeasureOptions } from './types.js';\n\n/** The scan settings shared by every command (a structural subset of each command's options). */\nexport interface ScanOptions {\n duplication: Required<DuplicationOptions>;\n includeTests: boolean;\n /**\n * Loads the exclusion covering the files a directory scan found (absolute paths); an explicitly\n * targeted file is always measured.\n */\n loadExclusion?: (absolutePaths: readonly string[]) => Promise<Exclusion>;\n}\n\nexport interface FileMetrics {\n file: string;\n metrics: CodeMetrics;\n /** Cross-file duplicate candidates and token/statement data, collected only for directory scans. */\n duplicationCandidates?: CrossFileDuplicationFileData;\n}\n\nexport interface ScanResult {\n crossFileDuplication?: CrossFileDuplicationMetrics;\n displayRoot: string;\n errors: string[];\n /** Non-fatal degradations (e.g. cross-file candidates unavailable); the file is still measured. */\n warnings: string[];\n /** Files skipped because their content is generated code (see Exclusion.isGeneratedCode). */\n generatedFiles: string[];\n fatalError?: string;\n files: FileMetrics[];\n}\n\nconst ignoredDirectoryNames = new Set([\n '.agents',\n '.claude',\n '.cursor',\n '.git',\n '.next',\n '.playwright-cli',\n '.tox',\n '.tmp',\n '.turbo',\n '.venv',\n '.yarn',\n '__fixtures__',\n '__generated__',\n '__pycache__',\n 'coverage',\n 'dist',\n 'fixtures',\n 'generated',\n 'node_modules',\n // .NET SDK intermediate output (generated sources such as `*.GlobalUsings.g.cs`).\n 'obj',\n 'target',\n 'test-fixtures',\n 'vendor',\n 'venv',\n]);\n\nconst testDirectoryNames = new Set(['__tests__', 'test', 'tests', 'spec']);\nconst testFilePattern = /(?:^test(?:[_-].*)?|\\.(?:spec|test)|[_-](?:test|spec))\\.[^.]+$/iu;\n// JUnit (Java/Kotlin) and xUnit/NUnit (C#) tests use case-sensitive `Test`/`Tests` class-name\n// suffixes; case-insensitive matching would catch production files like `contest.java`.\nconst suffixTestFilePattern = /Tests?\\.(?:java|kt|cs)$/u;\n\nexport function resolveTarget(target: string): string {\n if (target === '~') {\n return os.homedir();\n }\n\n if (target.startsWith('~/')) {\n return path.join(os.homedir(), target.slice(2));\n }\n\n return path.resolve(target);\n}\n\n/** Returns the directory from which the config file search should start (the target itself if it is a directory). */\nexport async function configSearchDirectory(target: string): Promise<string> {\n try {\n const targetStat = await stat(target);\n return targetStat.isDirectory() ? target : path.dirname(target);\n } catch {\n return path.dirname(target);\n }\n}\n\n/** Shared state of one scan, threaded through the directory walk instead of positional plumbing. */\ninterface ScanContext {\n options: ScanOptions;\n /**\n * Outcomes in discovery order. Files are measured concurrently, so a measurement is recorded as\n * a promise here and applied in this order once all have settled, keeping results deterministic.\n */\n outcomes: (ScanOutcome | Promise<ScanOutcome>)[];\n /** Measurements in flight, bounded so file contents and payloads do not pile up. */\n inFlight: Set<Promise<ScanOutcome>>;\n /** Set once a measurement fails fatally; no further measurement starts. */\n fatalSeen: boolean;\n visitedDirectories: Set<string>;\n visitedFiles: Set<string>;\n /** Scan root: paths are displayed relative to it, and symbolic links may not escape it. */\n rootDirectory: string;\n /** Files the walk found, measured once the exclusion covering all of them is loaded. */\n candidates: ScanCandidate[];\n exclusion?: Exclusion;\n}\n\ninterface ScanCandidate {\n file: string;\n language: LanguageName;\n /** The resolved path when the file was reached through a symbolic link. */\n realFile?: string;\n}\n\n/**\n * A missing native addon fails every file identically, so it ends the scan as one fatal error\n * instead of one \"skipped\" entry per file behind a successful exit code.\n */\ntype ScanOutcome =\n | { file: FileMetrics; warning?: string }\n | { generatedFile: string }\n | { error: string }\n | { fatal: NativeAddonError };\n\n// Twice the addon's worker count keeps its pool busy while finished payloads are parsed.\nconst maxMeasurementsInFlight = os.availableParallelism() * 2;\n\nexport async function scanTarget(target: string, options: ScanOptions): Promise<ScanResult> {\n let canonicalTarget = target;\n try {\n canonicalTarget = await realpath(target);\n } catch {\n // stat below reports missing targets with the original path.\n }\n\n const fallbackDisplayRoot = path.dirname(canonicalTarget);\n let targetStat;\n\n try {\n targetStat = await stat(canonicalTarget);\n } catch (error) {\n const fatalError = `${formatPath(canonicalTarget, fallbackDisplayRoot)}: ${getErrorMessage(error)}`;\n return {\n displayRoot: fallbackDisplayRoot,\n files: [],\n errors: [fatalError],\n warnings: [],\n generatedFiles: [],\n fatalError,\n };\n }\n\n if (targetStat.isFile()) {\n const displayRoot = path.dirname(canonicalTarget);\n const language = getLanguage(canonicalTarget, options, true);\n if (!language) {\n const fatalError = `${formatPath(canonicalTarget, displayRoot)}: unsupported file type`;\n return { displayRoot, files: [], errors: [fatalError], warnings: [], generatedFiles: [], fatalError };\n }\n\n const context = makeScanContext(options, displayRoot);\n await measureFile(canonicalTarget, language, 'single-file', context, canonicalTarget);\n return settleScan(context, displayRoot);\n }\n\n const context = makeScanContext(options, canonicalTarget);\n await scanDirectory(canonicalTarget, context);\n await measureCandidates(context);\n return settleScan(context, canonicalTarget);\n}\n\n/**\n * Measures an explicit list of repository-relative files (the git-visible ones of `check`) instead\n * of walking the directory tree, so ignored artifact directories are never parsed. Paths outside\n * the scan scope (ignored/test directories, unsupported or test file names) are skipped with the\n * same rules as the walk. Like the walk, which never tests the directory it starts in, the rules\n * apply to a file under `targetDirectory` (repository-relative, forward slashes) by its path below\n * that directory, so naming `vendor` or `test` as the target measures what is in it.\n */\nexport async function scanListedFiles(\n rootDirectory: string,\n relativePaths: Iterable<string>,\n options: ScanOptions,\n explicitFiles: ReadonlySet<string> = new Set(),\n targetDirectory = ''\n): Promise<ScanResult> {\n const context = makeScanContext(options, rootDirectory);\n const targetPrefix = targetDirectory === '' ? '' : `${targetDirectory}/`;\n for (const relativePath of relativePaths) {\n const absolutePath = path.join(rootDirectory, relativePath);\n const scopedPath = relativePath.startsWith(targetPrefix) ? relativePath.slice(targetPrefix.length) : relativePath;\n const language = isScannedPath(scopedPath, options, explicitFiles.has(absolutePath))\n ? getLanguage(relativePath, options, explicitFiles.has(absolutePath))\n : undefined;\n if (!language) {\n continue;\n }\n // Like the walk, only regular files are sources. A symbolic link is not one: git stores only its\n // target string, so measuring through it would diverge from what any revision of the repository\n // contains. Reading a FIFO would block forever. A path lstat cannot inspect stays a candidate so\n // that measuring it reports the error.\n const stats = await lstat(absolutePath).catch(() => {});\n if (!stats || stats.isFile()) {\n context.candidates.push({ file: absolutePath, language });\n }\n }\n await measureCandidates(context);\n return settleScan(context, rootDirectory);\n}\n\nasync function measureCandidates(context: ScanContext): Promise<void> {\n const exclusion = await context.options.loadExclusion?.(\n context.candidates.flatMap(({ file, realFile }) => (realFile === undefined ? [file] : [file, realFile]))\n );\n context.exclusion = exclusion;\n // Every path reaching the same real file, in discovery order, so one alias cannot decide alone.\n const pathsByRealFile = new Map<string, ScanCandidate[]>();\n for (const candidate of context.candidates) {\n let realFile;\n try {\n realFile = candidate.realFile ?? (await realpath(candidate.file));\n } catch (error) {\n recordError(context, candidate.file, error);\n continue;\n }\n pathsByRealFile.set(realFile, [...(pathsByRealFile.get(realFile) ?? []), candidate]);\n }\n for (const [realFile, candidates] of pathsByRealFile) {\n if (context.fatalSeen) {\n return;\n }\n const chosen = await chooseMeasuredPath(realFile, candidates, exclusion);\n if (chosen !== undefined) {\n await measureFile(chosen.file, chosen.language, 'directory', context, realFile);\n }\n }\n}\n\n/**\n * The path to measure a real file through. A path is excluded when its own exclusion or the real\n * file's applies, so the real path, when the scan reached it directly, is never less measurable\n * than an alias; otherwise the first alias not excluded, preferring one whose own attributes do\n * not judge the content generated.\n */\nasync function chooseMeasuredPath(\n realFile: string,\n candidates: readonly ScanCandidate[],\n exclusion: Exclusion | undefined\n): Promise<ScanCandidate | undefined> {\n if (exclusion === undefined) {\n return candidates[0];\n }\n if (exclusion.isExcludedPath(realFile)) {\n return undefined;\n }\n const measurable = candidates.filter(({ file }) => !exclusion.isExcludedPath(file));\n const direct = measurable.find(({ file }) => file === realFile);\n if (direct !== undefined || measurable.length <= 1) {\n return direct ?? measurable[0];\n }\n const code = await readFile(realFile, 'utf8').catch(() => {});\n return measurable.find(({ file }) => code !== undefined && !exclusion.isGeneratedCode(file, code)) ?? measurable[0];\n}\n\n/** Applies the scan's outcomes in discovery order once every measurement has settled. */\nasync function settleScan(context: ScanContext, displayRoot: string): Promise<ScanResult> {\n const files: FileMetrics[] = [];\n const errors: string[] = [];\n const warnings: string[] = [];\n const generatedFiles: string[] = [];\n for (const pending of context.outcomes) {\n const outcome = await pending;\n if ('fatal' in outcome) {\n const fatalError = getErrorMessage(outcome.fatal);\n // Errors the walk recorded before the fatal failure stay reported alongside it.\n return { displayRoot, files, errors: [...errors, fatalError], warnings, generatedFiles, fatalError };\n }\n if ('generatedFile' in outcome) {\n generatedFiles.push(outcome.generatedFile);\n continue;\n }\n if ('error' in outcome) {\n errors.push(outcome.error);\n continue;\n }\n files.push(outcome.file);\n if (outcome.warning !== undefined) {\n warnings.push(outcome.warning);\n }\n }\n return { displayRoot, files, errors, warnings, generatedFiles };\n}\n\nfunction makeScanContext(options: ScanOptions, rootDirectory: string): ScanContext {\n return {\n options,\n outcomes: [],\n inFlight: new Set(),\n fatalSeen: false,\n visitedDirectories: new Set(),\n visitedFiles: new Set(),\n rootDirectory,\n candidates: [],\n };\n}\n\nfunction recordError(context: ScanContext, target: string, error: unknown): void {\n context.outcomes.push({ error: `${formatPath(target, context.rootDirectory)}: ${getErrorMessage(error)}` });\n}\n\n/** Runs a filesystem operation, recording a scan error and returning undefined when it fails. */\nasync function tryFileSystem<T>(\n operation: () => Promise<T>,\n target: string,\n context: ScanContext\n): Promise<T | undefined> {\n try {\n return await operation();\n } catch (error) {\n recordError(context, target, error);\n return undefined;\n }\n}\n\n/** Resolves the path (recording errors); undefined when that fails or the result escapes the root. */\nasync function resolveWithinRoot(target: string, context: ScanContext): Promise<string | undefined> {\n const resolved = await tryFileSystem(() => realpath(target), target, context);\n return resolved !== undefined && isWithinDirectory(resolved, context.rootDirectory) ? resolved : undefined;\n}\n\nasync function scanDirectory(directory: string, context: ScanContext): Promise<void> {\n const resolvedDirectory = await resolveWithinRoot(directory, context);\n if (resolvedDirectory === undefined || context.visitedDirectories.has(resolvedDirectory)) {\n return;\n }\n context.visitedDirectories.add(resolvedDirectory);\n\n const entries = await tryFileSystem(() => readdir(directory, { withFileTypes: true }), directory, context);\n if (entries === undefined) {\n return;\n }\n // readdir order differs between file systems; sorting keeps which path reaches a file first,\n // and so the scan's result, the same everywhere.\n entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));\n\n for (const entry of entries) {\n const entryPath = path.join(directory, entry.name);\n if (entry.isSymbolicLink()) {\n await scanSymbolicLink(entry.name, entryPath, context);\n continue;\n }\n\n if (entry.isDirectory()) {\n if (shouldSkipDirectory(entry.name, context.options)) {\n continue;\n }\n await scanDirectory(entryPath, context);\n continue;\n }\n\n if (entry.isFile()) {\n // Under a symbolically linked directory the real path differs, and exclusion checks both.\n const realFile = resolvedDirectory === directory ? undefined : path.join(resolvedDirectory, entry.name);\n collectScannableFile(entryPath, context, entryPath, realFile);\n }\n }\n}\n\nasync function scanSymbolicLink(name: string, entryPath: string, context: ScanContext): Promise<void> {\n const resolvedPath = await resolveWithinRoot(entryPath, context);\n if (resolvedPath === undefined) {\n return;\n }\n\n const entryStat = await tryFileSystem(() => stat(entryPath), entryPath, context);\n if (entryStat === undefined) {\n return;\n }\n\n if (entryStat.isDirectory()) {\n if (\n shouldSkipDirectory(name, context.options) ||\n shouldSkipDirectory(path.basename(resolvedPath), context.options)\n ) {\n return;\n }\n await scanDirectory(entryPath, context);\n return;\n }\n\n if (entryStat.isFile()) {\n collectScannableFile(entryPath, context, resolvedPath, resolvedPath);\n }\n}\n\nfunction collectScannableFile(file: string, context: ScanContext, languageFile = file, realFile?: string): void {\n const language = getLanguage(languageFile, context.options);\n if (language) {\n context.candidates.push({ file, language, realFile });\n }\n}\n\n/**\n * Resolves and deduplicates the file in discovery order, then starts measuring it concurrently; its\n * outcome is recorded in discovery order (see ScanContext.outcomes).\n */\nasync function measureFile(\n file: string,\n language: LanguageName,\n mode: 'single-file' | 'directory',\n context: ScanContext,\n realFile?: string\n): Promise<void> {\n if (context.fatalSeen) {\n return;\n }\n let resolvedFile;\n try {\n resolvedFile = realFile ?? (await realpath(file));\n } catch (error) {\n recordError(context, file, error);\n return;\n }\n if (context.visitedFiles.has(resolvedFile)) {\n return;\n }\n context.visitedFiles.add(resolvedFile);\n\n // Any settled measurement frees its slot (removed by the callback below, which runs before the\n // race resumes), so one slow file never idles the pool.\n while (context.inFlight.size >= maxMeasurementsInFlight) {\n await Promise.race(context.inFlight);\n }\n const outcome = readAndMeasureFile(file, resolvedFile, language, mode, context);\n context.inFlight.add(outcome);\n void outcome.then(() => context.inFlight.delete(outcome));\n context.outcomes.push(outcome);\n}\n\nasync function readAndMeasureFile(\n file: string,\n resolvedFile: string,\n language: LanguageName,\n mode: 'single-file' | 'directory',\n context: ScanContext\n): Promise<ScanOutcome> {\n try {\n const code = await readFile(file, 'utf8');\n const measureOptions = { language, duplication: context.options.duplication };\n // Only directory scans compare files against each other; a single-file target has no peers.\n if (mode === 'single-file') {\n return { file: { file, metrics: measureCode(code, measureOptions) } };\n }\n // Like path exclusion, either the scanned path or the real one can judge the code generated.\n const exclusion = context.exclusion;\n if (exclusion && (exclusion.isGeneratedCode(file, code) || exclusion.isGeneratedCode(resolvedFile, code))) {\n return { generatedFile: file };\n }\n const { metrics, crossFileData, crossFileError } = await measureWithCrossFileData(code, measureOptions);\n return {\n file: { file, metrics, duplicationCandidates: crossFileData },\n // A warning, not an error: the file's metrics are complete, only its participation in\n // cross-file matching is lost, so it is not \"skipped\" and must not fail --fail-on-error.\n warning:\n crossFileError === undefined\n ? undefined\n : `${formatPath(file, context.rootDirectory)}: cross-file duplication candidates unavailable: ${crossFileError}`,\n };\n } catch (error) {\n if (error instanceof NativeAddonError) {\n context.fatalSeen = true;\n return { fatal: error };\n }\n return { error: `${formatPath(file, context.rootDirectory)}: ${getErrorMessage(error)}` };\n }\n}\n\n/**\n * Measures a file together with its cross-file contribution from one parse. The contribution is\n * auxiliary: if collecting it fails where plain measurement succeeds (e.g. a payload too large to\n * cross the addon boundary), the metrics are still returned with the failure message, which\n * callers report as a warning rather than an error.\n */\nasync function measureWithCrossFileData(\n code: string,\n measureOptions: MeasureOptions\n): Promise<{ metrics: CodeMetrics; crossFileData?: CrossFileDuplicationFileData; crossFileError?: string }> {\n try {\n return await measureCodeWithCrossFileDataAsync(code, measureOptions);\n } catch (error) {\n if (error instanceof NativeAddonError) {\n throw error;\n }\n return { metrics: measureCode(code, measureOptions), crossFileError: getErrorMessage(error) };\n }\n}\n\n/** Runs after the scan so every measured file's candidates participate. */\nexport function addCrossFileDuplication(result: ScanResult, options: ScanOptions): void {\n if (result.fatalError || result.files.length < 2) {\n return;\n }\n const sourceFiles = result.files.flatMap(({ file, duplicationCandidates }) =>\n duplicationCandidates ? [{ file: formatPath(file, result.displayRoot), ...duplicationCandidates }] : []\n );\n if (sourceFiles.length < 2) {\n return;\n }\n result.crossFileDuplication = measureCrossFileDuplication(sourceFiles, options.duplication);\n}\n\n/**\n * Distinct 1-based code lines covered by within-file or cross-file duplicated content. Both\n * sources expose the exact lines carrying matched tokens (never block bounding ranges, which\n * would over-count comment/blank lines and the unmatched gap of a merged clone), so the union is\n * a subset of the file's code lines and a ratio derived over code lines can never exceed 1.\n */\nexport function collectDuplicatedLineNumbers(\n metrics: CodeMetrics | undefined,\n crossFileDuplication: CrossFileDuplicationMetrics | undefined,\n formattedFile: string\n): Set<number> {\n const lines = new Set(metrics?.duplication.duplicateLineNumbers);\n // Object.hasOwn: a file named like an Object.prototype member must not read an inherited value.\n const crossFileLines =\n crossFileDuplication && Object.hasOwn(crossFileDuplication.duplicateLineNumbersByFile, formattedFile)\n ? (crossFileDuplication.duplicateLineNumbersByFile[formattedFile] ?? [])\n : [];\n for (const line of crossFileLines) {\n lines.add(line);\n }\n return lines;\n}\n\nfunction shouldSkipDirectory(name: string, options: ScanOptions): boolean {\n if (ignoredDirectoryNames.has(name)) {\n return true;\n }\n\n if (options.includeTests) {\n return false;\n }\n\n return testDirectoryNames.has(name);\n}\n\nexport function isWithinDirectory(candidate: string, directory: string): boolean {\n const relative = path.relative(directory, candidate);\n return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));\n}\n\n/**\n * Whether a repository-relative path would be scanned: no ignored or excluded-test directory\n * segment and a supported, non-test file name. An explicitly targeted file only needs a supported\n * language, as in the ranking command.\n */\nfunction isScannedPath(relativePath: string, options: ScanOptions, explicitTarget = false): boolean {\n if (explicitTarget) {\n return getLanguage(relativePath, options, true) !== undefined;\n }\n const segments = relativePath.split('/');\n for (const segment of segments.slice(0, -1)) {\n if (ignoredDirectoryNames.has(segment) || (!options.includeTests && testDirectoryNames.has(segment))) {\n return false;\n }\n }\n return getLanguage(relativePath, options) !== undefined;\n}\n\nexport function getLanguage(file: string, options: ScanOptions, explicitTarget = false): LanguageName | undefined {\n const lowerFile = file.toLowerCase();\n if (\n !explicitTarget &&\n (lowerFile.endsWith('.d.ts') ||\n lowerFile.endsWith('.d.mts') ||\n lowerFile.endsWith('.d.cts') ||\n lowerFile.endsWith('.min.js') ||\n lowerFile.endsWith('.pnp.cjs'))\n ) {\n return undefined;\n }\n\n if (\n !explicitTarget &&\n !options.includeTests &&\n (testFilePattern.test(path.basename(file)) || suffixTestFilePattern.test(path.basename(file)))\n ) {\n return undefined;\n }\n\n return detectLanguage(file);\n}\n\n/** The path relative to `base` with forward slashes on every platform, so it compares equal to the paths git prints. */\nexport function formatPath(file: string, base: string): string {\n return path.relative(base, file).replaceAll(path.sep, '/') || path.basename(file);\n}\n\nexport function writeStdout(message: string): void {\n process.stdout.write(message);\n}\n\nexport function writeStderr(message: string): void {\n process.stderr.write(message);\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"scan.js","names":["path","os","ignoredDirectoryNames","testDirectoryNames","testFilePattern","suffixTestFilePattern","resolveTarget","target","configSearchDirectory","stat","maxMeasurementsInFlight","scanTarget","canonicalTarget","realpath","fallbackDisplayRoot","targetStat","error","fatalError","formatPath","getErrorMessage","displayRoot","language","getLanguage","options","context","makeScanContext","measureFile","settleScan","scanDirectory","measureCandidates","scanListedFiles","rootDirectory","targetPrefix","targetDirectory","relativePath","relativePaths","absolutePath","isScannedPath","explicitFiles","stats","lstat","exclusion","realFile","file","pathsByRealFile","candidate","recordError","candidates","chosen","chooseMeasuredPath","measurable","direct","code","readFile","files","errors","warnings","generatedFiles","pending","outcome","tryFileSystem","operation","resolveWithinRoot","resolved","isWithinDirectory","resolvedDirectory","directory","entries","readdir","left","right","entry","entryPath","scanSymbolicLink","shouldSkipDirectory","collectScannableFile","resolvedPath","entryStat","name","languageFile","resolvedFile","readAndMeasureFile","mode","measureOptions","measureCode","measureWithCrossFileData","crossFileData","crossFileError","NativeAddonError","measureCodeWithCrossFileDataAsync","addCrossFileDuplication","result","sourceFiles","duplicationCandidates","measureCrossFileDuplication","collectDuplicatedLineNumbers","lines","metrics","crossFileLines","crossFileDuplication","formattedFile","line","relative","explicitTarget","segments","segment","lowerFile","detectLanguage","base","writeStdout","message","writeStderr"],"sources":["../src/scan.ts"],"sourcesContent":["import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { getErrorMessage } from '@willbooster/shared-lib';\nimport { measureCrossFileDuplication, type CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport type { CrossFileDuplicationFileData } from './duplication.js';\nimport type { Exclusion } from './exclusion.js';\nimport { detectLanguage } from './languages.js';\nimport { measureCode, measureCodeWithCrossFileDataAsync } from './metrics.js';\nimport { NativeAddonError } from './nativeMetrics.js';\nimport type { CodeMetrics, DuplicationOptions, LanguageName, MeasureOptions } from './types.js';\n\n/** The scan settings shared by every command (a structural subset of each command's options). */\nexport interface ScanOptions {\n duplication: Required<DuplicationOptions>;\n includeTests: boolean;\n /**\n * Loads the exclusion covering the files a directory scan found (absolute paths); an explicitly\n * targeted file is always measured.\n */\n loadExclusion?: (absolutePaths: readonly string[]) => Promise<Exclusion>;\n}\n\nexport interface FileMetrics {\n file: string;\n metrics: CodeMetrics;\n /** Cross-file duplicate candidates and token/statement data, collected only for directory scans. */\n duplicationCandidates?: CrossFileDuplicationFileData;\n}\n\nexport interface ScanResult {\n crossFileDuplication?: CrossFileDuplicationMetrics;\n displayRoot: string;\n errors: string[];\n /** Non-fatal degradations (e.g. cross-file candidates unavailable); the file is still measured. */\n warnings: string[];\n /** Files skipped because their content is generated code (see Exclusion.isGeneratedCode). */\n generatedFiles: string[];\n fatalError?: string;\n files: FileMetrics[];\n}\n\nconst ignoredDirectoryNames = new Set([\n '.agents',\n '.claude',\n '.cursor',\n '.git',\n '.next',\n '.playwright-cli',\n '.tox',\n '.tmp',\n '.turbo',\n '.venv',\n '.yarn',\n '__fixtures__',\n '__generated__',\n '__pycache__',\n 'coverage',\n 'dist',\n 'fixtures',\n 'generated',\n 'node_modules',\n // .NET SDK intermediate output (generated sources such as `*.GlobalUsings.g.cs`).\n 'obj',\n 'target',\n 'test-fixtures',\n 'vendor',\n 'venv',\n]);\n\nconst testDirectoryNames = new Set(['__tests__', 'test', 'tests', 'spec']);\nconst testFilePattern = /(?:^test(?:[_-].*)?|\\.(?:spec|test)|[_-](?:test|spec))\\.[^.]+$/iu;\n// JUnit (Java/Kotlin) and xUnit/NUnit (C#) tests use case-sensitive `Test`/`Tests` class-name\n// suffixes; case-insensitive matching would catch production files like `contest.java`.\nconst suffixTestFilePattern = /Tests?\\.(?:java|kt|cs)$/u;\n\nexport function resolveTarget(target: string): string {\n if (target === '~') {\n return os.homedir();\n }\n\n if (target.startsWith('~/')) {\n return path.join(os.homedir(), target.slice(2));\n }\n\n return path.resolve(target);\n}\n\n/** Returns the directory from which the config file search should start (the target itself if it is a directory). */\nexport async function configSearchDirectory(target: string): Promise<string> {\n try {\n const targetStat = await stat(target);\n return targetStat.isDirectory() ? target : path.dirname(target);\n } catch {\n return path.dirname(target);\n }\n}\n\n/** Shared state of one scan, threaded through the directory walk instead of positional plumbing. */\ninterface ScanContext {\n options: ScanOptions;\n /**\n * Outcomes in discovery order. Files are measured concurrently, so a measurement is recorded as\n * a promise here and applied in this order once all have settled, keeping results deterministic.\n */\n outcomes: (ScanOutcome | Promise<ScanOutcome>)[];\n /** Measurements in flight, bounded so file contents and payloads do not pile up. */\n inFlight: Set<Promise<ScanOutcome>>;\n /** Set once a measurement fails fatally; no further measurement starts. */\n fatalSeen: boolean;\n visitedDirectories: Set<string>;\n visitedFiles: Set<string>;\n /** Scan root: paths are displayed relative to it, and symbolic links may not escape it. */\n rootDirectory: string;\n /** Files the walk found, measured once the exclusion covering all of them is loaded. */\n candidates: ScanCandidate[];\n exclusion?: Exclusion;\n}\n\ninterface ScanCandidate {\n file: string;\n language: LanguageName;\n /** The resolved path when the file was reached through a symbolic link. */\n realFile?: string;\n}\n\n/**\n * A missing native addon fails every file identically, so it ends the scan as one fatal error\n * instead of one \"skipped\" entry per file behind a successful exit code.\n */\ntype ScanOutcome =\n | { file: FileMetrics; warning?: string }\n | { generatedFile: string }\n | { error: string }\n | { fatal: NativeAddonError };\n\n// Twice the addon's worker count keeps its pool busy while finished payloads are parsed.\nconst maxMeasurementsInFlight = os.availableParallelism() * 2;\n\nexport async function scanTarget(target: string, options: ScanOptions): Promise<ScanResult> {\n let canonicalTarget = target;\n try {\n canonicalTarget = await realpath(target);\n } catch {\n // stat below reports missing targets with the original path.\n }\n\n const fallbackDisplayRoot = path.dirname(canonicalTarget);\n let targetStat;\n\n try {\n targetStat = await stat(canonicalTarget);\n } catch (error) {\n const fatalError = `${formatPath(canonicalTarget, fallbackDisplayRoot)}: ${getErrorMessage(error)}`;\n return {\n displayRoot: fallbackDisplayRoot,\n files: [],\n errors: [fatalError],\n warnings: [],\n generatedFiles: [],\n fatalError,\n };\n }\n\n if (targetStat.isFile()) {\n const displayRoot = path.dirname(canonicalTarget);\n const language = getLanguage(canonicalTarget, options, true);\n if (!language) {\n const fatalError = `${formatPath(canonicalTarget, displayRoot)}: unsupported file type`;\n return { displayRoot, files: [], errors: [fatalError], warnings: [], generatedFiles: [], fatalError };\n }\n\n const context = makeScanContext(options, displayRoot);\n await measureFile(canonicalTarget, language, 'single-file', context, canonicalTarget);\n return settleScan(context, displayRoot);\n }\n\n const context = makeScanContext(options, canonicalTarget);\n await scanDirectory(canonicalTarget, context);\n await measureCandidates(context);\n return settleScan(context, canonicalTarget);\n}\n\n/**\n * Measures an explicit list of repository-relative files (the git-visible ones of `check`) instead\n * of walking the directory tree, so ignored artifact directories are never parsed. Paths outside\n * the scan scope (ignored/test directories, unsupported or test file names) are skipped with the\n * same rules as the walk. Like the walk, which never tests the directory it starts in, the rules\n * apply to a file under `targetDirectory` (repository-relative, forward slashes) by its path below\n * that directory, so naming `vendor` or `test` as the target measures what is in it.\n */\nexport async function scanListedFiles(\n rootDirectory: string,\n relativePaths: Iterable<string>,\n options: ScanOptions,\n explicitFiles: ReadonlySet<string> = new Set(),\n targetDirectory = ''\n): Promise<ScanResult> {\n const context = makeScanContext(options, rootDirectory);\n const targetPrefix = targetDirectory === '' ? '' : `${targetDirectory}/`;\n for (const relativePath of relativePaths) {\n const absolutePath = path.join(rootDirectory, relativePath);\n const scopedPath = relativePath.startsWith(targetPrefix) ? relativePath.slice(targetPrefix.length) : relativePath;\n const language = isScannedPath(scopedPath, options, explicitFiles.has(absolutePath))\n ? getLanguage(relativePath, options, explicitFiles.has(absolutePath))\n : undefined;\n if (!language) {\n continue;\n }\n // Like the walk, only regular files are sources. A symbolic link is not one: git stores only its\n // target string, so measuring through it would diverge from what any revision of the repository\n // contains. Reading a FIFO would block forever. A path lstat cannot inspect stays a candidate so\n // that measuring it reports the error.\n const stats = await lstat(absolutePath).catch(() => {});\n if (!stats || stats.isFile()) {\n context.candidates.push({ file: absolutePath, language });\n }\n }\n await measureCandidates(context);\n return settleScan(context, rootDirectory);\n}\n\nasync function measureCandidates(context: ScanContext): Promise<void> {\n const exclusion = await context.options.loadExclusion?.(\n context.candidates.flatMap(({ file, realFile }) => (realFile === undefined ? [file] : [file, realFile]))\n );\n context.exclusion = exclusion;\n // Every path reaching the same real file, in discovery order, so one alias cannot decide alone.\n const pathsByRealFile = new Map<string, ScanCandidate[]>();\n for (const candidate of context.candidates) {\n let realFile;\n try {\n realFile = candidate.realFile ?? (await realpath(candidate.file));\n } catch (error) {\n recordError(context, candidate.file, error);\n continue;\n }\n pathsByRealFile.set(realFile, [...(pathsByRealFile.get(realFile) ?? []), candidate]);\n }\n for (const [realFile, candidates] of pathsByRealFile) {\n if (context.fatalSeen) {\n return;\n }\n const chosen = await chooseMeasuredPath(realFile, candidates, exclusion);\n if (chosen !== undefined) {\n await measureFile(chosen.file, chosen.language, 'directory', context, realFile);\n }\n }\n}\n\n/**\n * The path to measure a real file through. A path is excluded when its own exclusion or the real\n * file's applies, so the real path, when the scan reached it directly, is never less measurable\n * than an alias; otherwise the first alias not excluded, preferring one whose own attributes do\n * not judge the content generated.\n */\nasync function chooseMeasuredPath(\n realFile: string,\n candidates: readonly ScanCandidate[],\n exclusion: Exclusion | undefined\n): Promise<ScanCandidate | undefined> {\n if (exclusion === undefined) {\n return candidates[0];\n }\n if (exclusion.isExcludedPath(realFile)) {\n return undefined;\n }\n const measurable = candidates.filter(({ file }) => !exclusion.isExcludedPath(file));\n const direct = measurable.find(({ file }) => file === realFile);\n if (direct !== undefined || measurable.length <= 1) {\n return direct ?? measurable[0];\n }\n const code = await readFile(realFile, 'utf8').catch(() => {});\n return measurable.find(({ file }) => code !== undefined && !exclusion.isGeneratedCode(file, code)) ?? measurable[0];\n}\n\n/** Applies the scan's outcomes in discovery order once every measurement has settled. */\nasync function settleScan(context: ScanContext, displayRoot: string): Promise<ScanResult> {\n const files: FileMetrics[] = [];\n const errors: string[] = [];\n const warnings: string[] = [];\n const generatedFiles: string[] = [];\n for (const pending of context.outcomes) {\n const outcome = await pending;\n if ('fatal' in outcome) {\n const fatalError = getErrorMessage(outcome.fatal);\n // Errors the walk recorded before the fatal failure stay reported alongside it.\n return { displayRoot, files, errors: [...errors, fatalError], warnings, generatedFiles, fatalError };\n }\n if ('generatedFile' in outcome) {\n generatedFiles.push(outcome.generatedFile);\n continue;\n }\n if ('error' in outcome) {\n errors.push(outcome.error);\n continue;\n }\n files.push(outcome.file);\n if (outcome.warning !== undefined) {\n warnings.push(outcome.warning);\n }\n }\n return { displayRoot, files, errors, warnings, generatedFiles };\n}\n\nfunction makeScanContext(options: ScanOptions, rootDirectory: string): ScanContext {\n return {\n options,\n outcomes: [],\n inFlight: new Set(),\n fatalSeen: false,\n visitedDirectories: new Set(),\n visitedFiles: new Set(),\n rootDirectory,\n candidates: [],\n };\n}\n\nfunction recordError(context: ScanContext, target: string, error: unknown): void {\n context.outcomes.push({ error: `${formatPath(target, context.rootDirectory)}: ${getErrorMessage(error)}` });\n}\n\n/** Runs a filesystem operation, recording a scan error and returning undefined when it fails. */\nasync function tryFileSystem<T>(\n operation: () => Promise<T>,\n target: string,\n context: ScanContext\n): Promise<T | undefined> {\n try {\n return await operation();\n } catch (error) {\n recordError(context, target, error);\n return undefined;\n }\n}\n\n/** Resolves the path (recording errors); undefined when that fails or the result escapes the root. */\nasync function resolveWithinRoot(target: string, context: ScanContext): Promise<string | undefined> {\n const resolved = await tryFileSystem(() => realpath(target), target, context);\n return resolved !== undefined && isWithinDirectory(resolved, context.rootDirectory) ? resolved : undefined;\n}\n\nasync function scanDirectory(directory: string, context: ScanContext): Promise<void> {\n const resolvedDirectory = await resolveWithinRoot(directory, context);\n if (resolvedDirectory === undefined || context.visitedDirectories.has(resolvedDirectory)) {\n return;\n }\n context.visitedDirectories.add(resolvedDirectory);\n\n const entries = await tryFileSystem(() => readdir(directory, { withFileTypes: true }), directory, context);\n if (entries === undefined) {\n return;\n }\n // readdir order differs between file systems; sorting keeps which path reaches a file first,\n // and so the scan's result, the same everywhere.\n entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));\n\n for (const entry of entries) {\n const entryPath = path.join(directory, entry.name);\n if (entry.isSymbolicLink()) {\n await scanSymbolicLink(entry.name, entryPath, context);\n continue;\n }\n\n if (entry.isDirectory()) {\n if (shouldSkipDirectory(entry.name, context.options)) {\n continue;\n }\n await scanDirectory(entryPath, context);\n continue;\n }\n\n if (entry.isFile()) {\n // Under a symbolically linked directory the real path differs, and exclusion checks both.\n const realFile = resolvedDirectory === directory ? undefined : path.join(resolvedDirectory, entry.name);\n collectScannableFile(entryPath, context, entryPath, realFile);\n }\n }\n}\n\nasync function scanSymbolicLink(name: string, entryPath: string, context: ScanContext): Promise<void> {\n const resolvedPath = await resolveWithinRoot(entryPath, context);\n if (resolvedPath === undefined) {\n return;\n }\n\n const entryStat = await tryFileSystem(() => stat(entryPath), entryPath, context);\n if (entryStat === undefined) {\n return;\n }\n\n if (entryStat.isDirectory()) {\n if (\n shouldSkipDirectory(name, context.options) ||\n shouldSkipDirectory(path.basename(resolvedPath), context.options)\n ) {\n return;\n }\n await scanDirectory(entryPath, context);\n return;\n }\n\n if (entryStat.isFile()) {\n collectScannableFile(entryPath, context, resolvedPath, resolvedPath);\n }\n}\n\nfunction collectScannableFile(file: string, context: ScanContext, languageFile = file, realFile?: string): void {\n const language = getLanguage(languageFile, context.options);\n if (language) {\n context.candidates.push({ file, language, realFile });\n }\n}\n\n/**\n * Resolves and deduplicates the file in discovery order, then starts measuring it concurrently; its\n * outcome is recorded in discovery order (see ScanContext.outcomes).\n */\nasync function measureFile(\n file: string,\n language: LanguageName,\n mode: 'single-file' | 'directory',\n context: ScanContext,\n realFile?: string\n): Promise<void> {\n if (context.fatalSeen) {\n return;\n }\n let resolvedFile;\n try {\n resolvedFile = realFile ?? (await realpath(file));\n } catch (error) {\n recordError(context, file, error);\n return;\n }\n if (context.visitedFiles.has(resolvedFile)) {\n return;\n }\n context.visitedFiles.add(resolvedFile);\n\n // Any settled measurement frees its slot (removed by the callback below, which runs before the\n // race resumes), so one slow file never idles the pool.\n while (context.inFlight.size >= maxMeasurementsInFlight) {\n await Promise.race(context.inFlight);\n }\n const outcome = readAndMeasureFile(file, resolvedFile, language, mode, context);\n context.inFlight.add(outcome);\n void outcome.then(() => context.inFlight.delete(outcome));\n context.outcomes.push(outcome);\n}\n\nasync function readAndMeasureFile(\n file: string,\n resolvedFile: string,\n language: LanguageName,\n mode: 'single-file' | 'directory',\n context: ScanContext\n): Promise<ScanOutcome> {\n try {\n const code = await readFile(file, 'utf8');\n const measureOptions = { language, duplication: context.options.duplication };\n // Only directory scans compare files against each other; a single-file target has no peers.\n if (mode === 'single-file') {\n return { file: { file, metrics: measureCode(code, measureOptions) } };\n }\n // Like path exclusion, either the scanned path or the real one can judge the code generated.\n const exclusion = context.exclusion;\n if (exclusion && (exclusion.isGeneratedCode(file, code) || exclusion.isGeneratedCode(resolvedFile, code))) {\n return { generatedFile: file };\n }\n const { metrics, crossFileData, crossFileError } = await measureWithCrossFileData(code, measureOptions);\n return {\n file: { file, metrics, duplicationCandidates: crossFileData },\n // A warning, not an error: the file's metrics are complete, only its participation in\n // cross-file matching is lost, so it is not \"skipped\" and must not fail --fail-on-error.\n warning:\n crossFileError === undefined\n ? undefined\n : `${formatPath(file, context.rootDirectory)}: cross-file duplication candidates unavailable: ${crossFileError}`,\n };\n } catch (error) {\n if (error instanceof NativeAddonError) {\n context.fatalSeen = true;\n return { fatal: error };\n }\n return { error: `${formatPath(file, context.rootDirectory)}: ${getErrorMessage(error)}` };\n }\n}\n\n/**\n * Measures a file together with its cross-file contribution from one parse. The contribution is\n * auxiliary: if collecting it fails where plain measurement succeeds (e.g. a payload too large to\n * cross the addon boundary), the metrics are still returned with the failure message, which\n * callers report as a warning rather than an error.\n */\nasync function measureWithCrossFileData(\n code: string,\n measureOptions: MeasureOptions\n): Promise<{ metrics: CodeMetrics; crossFileData?: CrossFileDuplicationFileData; crossFileError?: string }> {\n try {\n return await measureCodeWithCrossFileDataAsync(code, measureOptions);\n } catch (error) {\n if (error instanceof NativeAddonError) {\n throw error;\n }\n return { metrics: measureCode(code, measureOptions), crossFileError: getErrorMessage(error) };\n }\n}\n\n/** Runs after the scan so every measured file's candidates participate. */\nexport function addCrossFileDuplication(result: ScanResult, options: ScanOptions): void {\n if (result.fatalError || result.files.length < 2) {\n return;\n }\n const sourceFiles = result.files.flatMap(({ file, duplicationCandidates }) =>\n duplicationCandidates ? [{ file: formatPath(file, result.displayRoot), ...duplicationCandidates }] : []\n );\n if (sourceFiles.length < 2) {\n return;\n }\n result.crossFileDuplication = measureCrossFileDuplication(sourceFiles, options.duplication);\n}\n\n/**\n * Distinct 1-based code lines covered by within-file or cross-file duplicated content. Both\n * sources expose the exact lines carrying matched tokens (never block bounding ranges, which\n * would over-count comment/blank lines and the unmatched gap of a merged clone), so the union is\n * a subset of the file's code lines and a ratio derived over code lines can never exceed 1.\n */\nexport function collectDuplicatedLineNumbers(\n metrics: CodeMetrics | undefined,\n crossFileDuplication: CrossFileDuplicationMetrics | undefined,\n formattedFile: string\n): Set<number> {\n const lines = new Set(metrics?.duplication.duplicateLineNumbers);\n // Object.hasOwn: a file named like an Object.prototype member must not read an inherited value.\n const crossFileLines =\n crossFileDuplication && Object.hasOwn(crossFileDuplication.duplicateLineNumbersByFile, formattedFile)\n ? (crossFileDuplication.duplicateLineNumbersByFile[formattedFile] ?? [])\n : [];\n for (const line of crossFileLines) {\n lines.add(line);\n }\n return lines;\n}\n\nfunction shouldSkipDirectory(name: string, options: ScanOptions): boolean {\n if (ignoredDirectoryNames.has(name)) {\n return true;\n }\n\n if (options.includeTests) {\n return false;\n }\n\n return testDirectoryNames.has(name);\n}\n\nexport function isWithinDirectory(candidate: string, directory: string): boolean {\n const relative = path.relative(directory, candidate);\n return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));\n}\n\n/**\n * Whether a repository-relative path would be scanned: no ignored or excluded-test directory\n * segment and a supported, non-test file name. An explicitly targeted file only needs a supported\n * language, as in the ranking command.\n */\nfunction isScannedPath(relativePath: string, options: ScanOptions, explicitTarget = false): boolean {\n if (explicitTarget) {\n return getLanguage(relativePath, options, true) !== undefined;\n }\n const segments = relativePath.split('/');\n for (const segment of segments.slice(0, -1)) {\n if (ignoredDirectoryNames.has(segment) || (!options.includeTests && testDirectoryNames.has(segment))) {\n return false;\n }\n }\n return getLanguage(relativePath, options) !== undefined;\n}\n\nexport function getLanguage(file: string, options: ScanOptions, explicitTarget = false): LanguageName | undefined {\n const lowerFile = file.toLowerCase();\n if (\n !explicitTarget &&\n (lowerFile.endsWith('.d.ts') ||\n lowerFile.endsWith('.d.mts') ||\n lowerFile.endsWith('.d.cts') ||\n lowerFile.endsWith('.min.js') ||\n lowerFile.endsWith('.pnp.cjs'))\n ) {\n return undefined;\n }\n\n if (\n !explicitTarget &&\n !options.includeTests &&\n (testFilePattern.test(path.basename(file)) || suffixTestFilePattern.test(path.basename(file)))\n ) {\n return undefined;\n }\n\n return detectLanguage(file);\n}\n\n/** The path relative to `base` with forward slashes on every platform, so it compares equal to the paths git prints. */\nexport function formatPath(file: string, base: string): string {\n return path.relative(base, file).replaceAll(path.sep, '/') || path.basename(file);\n}\n\nexport function writeStdout(message: string): void {\n process.stdout.write(message);\n}\n\nexport function writeStderr(message: string): void {\n process.stderr.write(message);\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"thresholdCheck.cjs","names":["functionThresholds","metricNameOf","fileThresholds","levels","duplicationThreshold"],"sources":["../src/thresholdCheck.ts"],"sourcesContent":["import assert from 'node:assert';\nimport type {\n CrossFileDuplicateBlockGroup,\n CrossFileDuplicateOccurrence,\n CrossFileDuplicationMetrics,\n} from './crossFileDuplication.js';\nimport type { LineHunk } from './git.js';\nimport {\n duplicationThreshold,\n fileThresholds,\n functionThresholds,\n levels,\n metricNameOf,\n type Level,\n type LimitsByLevel,\n type Threshold,\n} from './thresholds.js';\nimport type { CodeMetrics, CognitiveBlock, FunctionMetrics } from './types.js';\n\n/** A measured file to check, under the path cross-file duplication knows it by. */\nexport interface CheckedFile {\n file: string;\n metrics: CodeMetrics;\n /** The change to the file; when set, only what the change touches is checked. */\n hunks?: readonly LineHunk[];\n}\n\n/** The duplication of the measured files, in which duplicated blocks and their copies are found. */\nexport interface CheckedDuplication {\n crossFile?: CrossFileDuplicationMetrics;\n /** The detection's `minSimilarityPercent`, the share of a block a copy of it holds at least. */\n minSimilarityPercent: number;\n}\n\nexport interface ExceededLimit {\n metric: string;\n /** What reports call the metric (`cognitive complexity`, `duplicated lines`). */\n label: string;\n value: number;\n /** The most severe level whose limit the value violates. */\n level: Level;\n /** That level's limit: the value is allowed up to it; a duplicated block is allowed below it. */\n limit: number;\n}\n\nexport interface BlockLocation {\n file: string;\n startLine: number;\n endLine: number;\n}\n\n/** One copy of a clone group. */\ninterface CloneOccurrence extends BlockLocation {\n /**\n * Its code lines matched in another copy, whose number is the length of a duplicated block. Its\n * line span would also count the lines the copies do not share.\n */\n lineNumbers: readonly number[];\n tokenCount: number;\n}\n\nexport interface Violation extends BlockLocation {\n kind: 'function' | 'file' | 'duplication';\n /** The most severe level among `exceeded`. */\n level: Level;\n /** The function's name, qualified with the class it is a member of, for kind `function`. */\n name?: string;\n exceeded: ExceededLimit[];\n /**\n * The parts adding the most to the cognitive complexity, largest first, for a function that\n * exceeds a limit of it and has such parts: operator sequences and jumps outside any branching\n * construct add to the complexity without being one.\n */\n largestBlocks?: CognitiveBlock[];\n /** The other copies of the block, most matched tokens first, for kind `duplication`. */\n partners?: BlockLocation[];\n}\n\nexport interface CheckResult {\n violations: Violation[];\n checkedFunctionCount: number;\n}\n\nconst violationKinds: readonly Violation['kind'][] = ['file', 'function', 'duplication'];\n\nconst cognitiveComplexityMetric = 'functionCognitiveComplexity';\n\nconst nestedInclusiveMetrics = new Set(\n functionThresholds\n .filter(({ includesNestedFunctions }) => includesNestedFunctions)\n .map((threshold) => metricNameOf(threshold))\n);\n\n/** Every limit the files exceed, errors first, then ordered by path, then line. */\nexport function checkThresholds(\n files: readonly CheckedFile[],\n duplication: CheckedDuplication,\n limitsOf: (language: string) => LimitsByLevel\n): CheckResult {\n const crossFileGroupsByFile = indexGroupsByFile(duplication.crossFile, new Set(files.map(({ file }) => file)));\n const violations: Violation[] = [];\n let checkedFunctionCount = 0;\n for (const checkedFile of files) {\n const { file, metrics, hunks } = checkedFile;\n const limits = limitsOf(metrics.language);\n // A rename or a mode change lists the file as changed without touching any of its lines.\n const fileExceeded = hunks?.length === 0 ? [] : collectExceeded(fileThresholds, metrics, limits);\n if (fileExceeded.length > 0) {\n violations.push(toViolation({ file, startLine: 1, endLine: metrics.lines.total }, 'file', fileExceeded));\n }\n const checkedFunctions = listCheckedFunctions(checkedFile);\n checkedFunctionCount += checkedFunctions.length;\n const exceedingFunctions = checkedFunctions\n .map((fn) => ({ fn, exceeded: collectExceeded(functionThresholds, fn, limits) }))\n .filter(({ exceeded }) => exceeded.length > 0);\n for (const { fn, exceeded } of exceedingFunctions) {\n const ownExceeded = exceeded.filter((limit) => !isReportedByEnclosingFunction(limit, fn, exceedingFunctions));\n if (ownExceeded.length > 0) {\n violations.push(toFunctionViolation(file, fn, ownExceeded));\n }\n }\n violations.push(\n ...collectDuplicationViolations(checkedFile, crossFileGroupsByFile.get(file) ?? [], limits, duplication)\n );\n }\n return { violations: violations.toSorted(compareViolations), checkedFunctionCount };\n}\n\n// The kind is compared before the end line because a file violation's end line is corrected after\n// this ordering (to the last line that exists), and the order must not depend on that correction.\nfunction compareViolations(left: Violation, right: Violation): number {\n return (\n levels.indexOf(right.level) - levels.indexOf(left.level) ||\n compareStrings(left.file, right.file) ||\n left.startLine - right.startLine ||\n violationKinds.indexOf(left.kind) - violationKinds.indexOf(right.kind) ||\n left.endLine - right.endLine\n );\n}\n\n/** The file's functions or, for a changed file, those the change touches. */\nfunction listCheckedFunctions({ metrics, hunks }: CheckedFile): FunctionMetrics[] {\n const endsByIndentation = metrics.language === 'python';\n return hunks\n ? metrics.functions.filter((fn) =>\n hunks.some((hunk) => touchesSpan(hunk, fn.startLine, fn.endLine, endsByIndentation))\n )\n : metrics.functions;\n}\n\n/**\n * Whether a function enclosing `fn` exceeds the same limit with a value that covers `fn`, so\n * reporting it for `fn` too would count the same code twice. The enclosing value is at least that\n * of `fn` and both share the file's limits, so its level is never milder.\n */\nfunction isReportedByEnclosingFunction(\n limit: ExceededLimit,\n fn: FunctionMetrics,\n exceedingFunctions: readonly { fn: FunctionMetrics; exceeded: ExceededLimit[] }[]\n): boolean {\n if (!nestedInclusiveMetrics.has(limit.metric)) {\n return false;\n }\n return exceedingFunctions.some(\n (outer) => outer.fn !== fn && encloses(outer.fn, fn) && outer.exceeded.some(({ metric }) => metric === limit.metric)\n );\n}\n\nfunction encloses(outer: FunctionMetrics, inner: FunctionMetrics): boolean {\n const startsBefore =\n outer.startLine < inner.startLine ||\n (outer.startLine === inner.startLine && outer.startColumn <= inner.startColumn);\n const endsAfter =\n outer.endLine > inner.endLine || (outer.endLine === inner.endLine && outer.endColumn >= inner.endColumn);\n return startsBefore && endsAfter;\n}\n\nfunction toFunctionViolation(file: string, fn: FunctionMetrics, exceeded: ExceededLimit[]): Violation {\n const { startLine, endLine } = fn;\n const exceedsCognitiveComplexity = exceeded.some(({ metric }) => metric === cognitiveComplexityMetric);\n return {\n ...toViolation({ file, startLine, endLine }, 'function', exceeded),\n name: qualifyFunctionName(fn),\n ...(exceedsCognitiveComplexity && fn.cognitiveBlocks.length > 0 && { largestBlocks: fn.cognitiveBlocks }),\n };\n}\n\n/** How reports name a function: with the type or module it is a member of, so that it can be found. */\nexport function qualifyFunctionName({ name = '<anonymous>', containerName }: FunctionMetrics): string {\n return containerName ? `${containerName}.${name}` : name;\n}\n\nfunction toViolation(location: BlockLocation, kind: Violation['kind'], exceeded: ExceededLimit[]): Violation {\n const level = exceeded.some((limit) => limit.level === 'error') ? 'error' : 'warning';\n return { kind, level, ...location, exceeded };\n}\n\nfunction collectExceeded<Subject>(\n thresholds: readonly Threshold<Subject>[],\n subject: Subject,\n limits: LimitsByLevel\n): ExceededLimit[] {\n return thresholds.flatMap((threshold) => findExceeded(threshold, subject, limits, (value, limit) => value > limit));\n}\n\n/** The most severe violated limit of the threshold, if any. */\nfunction findExceeded<Subject>(\n threshold: Threshold<Subject>,\n subject: Subject,\n limits: LimitsByLevel,\n violates: (value: number, limit: number) => boolean\n): ExceededLimit[] {\n const value = threshold.measure(subject);\n const level = levels.findLast((candidate) => violates(value, limits[candidate][threshold.key] as number));\n return level\n ? [\n {\n metric: metricNameOf(threshold),\n label: threshold.label,\n value,\n level,\n limit: limits[level][threshold.key] as number,\n },\n ]\n : [];\n}\n\n/**\n * Whether the hunk changes the head lines [startLine, endLine]. A hunk without head lines (a pure\n * deletion) sits between its headStart line and the next one. Where indentation ends a span, its\n * last line is a statement rather than a closing delimiter, so a deletion right after that line may\n * have removed the span's tail; `endsByIndentation` counts it as touching, since the head alone\n * cannot tell it from a deletion after the span.\n */\nfunction touchesSpan(hunk: LineHunk, startLine: number, endLine: number, endsByIndentation = false): boolean {\n return hunk.headCount === 0\n ? hunk.headStart >= startLine && (hunk.headStart < endLine || (endsByIndentation && hunk.headStart === endLine))\n : hunk.headStart <= endLine && hunk.headStart + hunk.headCount > startLine;\n}\n\n/** The cross-file groups with an occurrence in each of the given files. */\nfunction indexGroupsByFile(\n crossFileDuplication: CrossFileDuplicationMetrics | undefined,\n files: ReadonlySet<string>\n): Map<string, CrossFileDuplicateBlockGroup[]> {\n const groupsByFile = new Map<string, CrossFileDuplicateBlockGroup[]>();\n for (const group of crossFileDuplication?.groups ?? []) {\n for (const file of group.files) {\n if (files.has(file)) {\n const groups = groupsByFile.get(file) ?? [];\n groups.push(group);\n groupsByFile.set(file, groups);\n }\n }\n }\n return groupsByFile;\n}\n\n/**\n * One violation per duplicated region of the file: its clone occurrences, within-file and\n * cross-file, that hold at least the duplicated lines of a level (with `hunks`, only those\n * overlapping added lines), with overlapping occurrences merged into one region unless they are\n * copies of each other.\n */\nfunction collectDuplicationViolations(\n { file, metrics, hunks }: CheckedFile,\n crossFileGroups: readonly CrossFileDuplicateBlockGroup[],\n limits: LimitsByLevel,\n { minSimilarityPercent }: CheckedDuplication\n): Violation[] {\n const findExceededLines = (duplicatedLineCount: number): ExceededLimit[] =>\n findExceeded(duplicationThreshold, { duplicatedLineCount }, limits, (value, limit) => value >= limit);\n const groups: CloneOccurrence[][] = [\n ...metrics.duplication.duplicateBlockGroups.map((group) => group.map((occurrence) => ({ ...occurrence, file }))),\n ...crossFileGroups.map((group) => group.occurrences.map((occurrence) => toCloneOccurrence(occurrence))),\n ];\n const isReported = (block: CloneOccurrence): boolean =>\n block.file === file &&\n findExceededLines(block.lineNumbers.length).length > 0 &&\n (!hunks || hunks.some((hunk) => hunk.headCount > 0 && touchesSpan(hunk, block.startLine, block.endLine)));\n const groupOf = new Map(groups.flatMap((group) => group.map((occurrence) => [occurrence, group] as const)));\n // Copies of one group that share a line (the branches of an `else if` chain, the entries of a\n // list) stay apart: merged, they would be one block without a copy. One lying within another is\n // a repetition inside that block, not a block beside it.\n const areCopies = (left: CloneOccurrence, right: CloneOccurrence): boolean =>\n groupOf.get(left) === groupOf.get(right) && !contains(left, right) && !contains(right, left);\n const reportedBlocks = groups.flat().filter((occurrence) => isReported(occurrence));\n return mergeOverlapping(reportedBlocks, areCopies).map(({ merged, sources }) => {\n const listCopies = (minTokenPercent: number): BlockLocation[] =>\n mergeOverlapping(\n sources.flatMap((source) => {\n // The region merges every clone that overlaps it, so a copy of an occurrence lying\n // within a larger one is measured against the larger one.\n const tokenCount = Math.max(\n ...sources.filter((outer) => contains(outer, source)).map((outer) => outer.tokenCount)\n );\n return (groupOf.get(source) ?? []).filter(\n (copy) =>\n copy.tokenCount * 100 >= tokenCount * minTokenPercent &&\n // The region's own occurrences are among the copies of the groups it belongs to, and\n // so may be an occurrence within one of them that is not reported itself: a\n // repetition inside the block, not another place.\n !sources.some((outer) => contains(outer, copy))\n );\n }),\n areCopies\n )\n // The largest say the most about what to share.\n .toSorted((left, right) => sumTokens(right.sources) - sumTokens(left.sources))\n .map((partner) => partner.merged);\n // A group joins a block with every place repeating a part of it: a function another file\n // copies whole is grouped with each file holding a few of its lines. Such a fragment is not a\n // copy of the block: sharing it would not remove the block, and a report listing it reads as\n // one more copy. Sizes are compared in tokens, since the same code may be laid out over any\n // number of lines.\n const copies = listCopies(minSimilarityPercent);\n return {\n ...toViolation(\n merged,\n 'duplication',\n // Occurrences that overlap share lines, which count once.\n findExceededLines(new Set(sources.flatMap((source) => source.lineNumbers)).size)\n ),\n // A block whose parts were matched with different places has no copy of its size. The\n // places then are all that locates what it repeats.\n partners: copies.length > 0 ? copies : listCopies(0),\n };\n });\n}\n\nfunction toCloneOccurrence(occurrence: CrossFileDuplicateOccurrence): CloneOccurrence {\n const { lineNumbers } = occurrence;\n assert.ok(lineNumbers, `No matched lines for ${occurrence.file}, a scanned file.`);\n return { ...occurrence, lineNumbers };\n}\n\nfunction sumTokens(occurrences: readonly CloneOccurrence[]): number {\n return occurrences.reduce((sum, occurrence) => sum + occurrence.tokenCount, 0);\n}\n\n/**\n * The locations with those overlapping in the same file merged, except those `areCopies` holds\n * for, ordered by file, then line.\n */\nfunction mergeOverlapping(\n locations: readonly CloneOccurrence[],\n areCopies: (left: CloneOccurrence, right: CloneOccurrence) => boolean\n): { merged: BlockLocation; sources: CloneOccurrence[] }[] {\n const regions: { merged: BlockLocation; sources: CloneOccurrence[] }[] = [];\n const ordered = locations.toSorted(\n (left, right) => compareStrings(left.file, right.file) || left.startLine - right.startLine\n );\n for (const location of ordered) {\n const last = regions.at(-1);\n // A block containing the location holds it whatever it is a copy of.\n const joins =\n last &&\n overlaps(last.merged, location) &&\n (last.sources.some((source) => contains(source, location)) ||\n !last.sources.some((source) => areCopies(source, location)));\n if (joins) {\n last.merged.endLine = Math.max(last.merged.endLine, location.endLine);\n last.sources.push(location);\n } else {\n const { file, startLine, endLine } = location;\n regions.push({ merged: { file, startLine, endLine }, sources: [location] });\n }\n }\n return regions;\n}\n\n/** Whether `outer` spans every line of `inner`; a block contains itself. */\nfunction contains(outer: BlockLocation, inner: BlockLocation): boolean {\n return outer.file === inner.file && outer.startLine <= inner.startLine && inner.endLine <= outer.endLine;\n}\n\nfunction overlaps(left: BlockLocation, right: BlockLocation): boolean {\n return left.file === right.file && left.startLine <= right.endLine && right.startLine <= left.endLine;\n}\n\n/** Code-unit order, so reports do not depend on the locale. */\nfunction compareStrings(left: string, right: string): number {\n return left < right ? -1 : left > right ? 1 : 0;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"thresholdCheck.cjs","names":["functionThresholds","metricNameOf","fileThresholds","levels","duplicationThreshold","require_runtime","require_thresholds","node_assert","violationKinds","nestedInclusiveMetrics","includesNestedFunctions","threshold","checkThresholds","crossFileGroupsByFile","indexGroupsByFile","duplication","files","file","violations","checkedFunctionCount","checkedFile","limits","limitsOf","metrics","fileExceeded","hunks","collectExceeded","toViolation","checkedFunctions","listCheckedFunctions","exceedingFunctions","fn","exceeded","ownExceeded","isReportedByEnclosingFunction","limit","toFunctionViolation","collectDuplicationViolations","compareViolations","right","left","compareStrings","endsByIndentation","touchesSpan","hunk","outer","encloses","metric","startsBefore","inner","endsAfter","exceedsCognitiveComplexity","qualifyFunctionName","containerName","name","location","thresholds","findExceeded","subject","value","level","violates","candidate","startLine","endLine","groupsByFile","group","crossFileDuplication","groups","findExceededLines","occurrence","crossFileGroups","toCloneOccurrence","isReported","block","groupOf","areCopies","contains","mergeOverlapping","listCopies","sources","tokenCount","source","copy","minTokenPercent","sumTokens","partner","copies","minSimilarityPercent","merged","lineNumbers","occurrences","sum","regions","ordered","locations","last","overlaps"],"sources":["../src/thresholdCheck.ts"],"sourcesContent":["import assert from 'node:assert';\nimport type {\n CrossFileDuplicateBlockGroup,\n CrossFileDuplicateOccurrence,\n CrossFileDuplicationMetrics,\n} from './crossFileDuplication.js';\nimport type { LineHunk } from './git.js';\nimport {\n duplicationThreshold,\n fileThresholds,\n functionThresholds,\n levels,\n metricNameOf,\n type Level,\n type LimitsByLevel,\n type Threshold,\n} from './thresholds.js';\nimport type { CodeMetrics, CognitiveBlock, FunctionMetrics } from './types.js';\n\n/** A measured file to check, under the path cross-file duplication knows it by. */\nexport interface CheckedFile {\n file: string;\n metrics: CodeMetrics;\n /** The change to the file; when set, only what the change touches is checked. */\n hunks?: readonly LineHunk[];\n}\n\n/** The duplication of the measured files, in which duplicated blocks and their copies are found. */\nexport interface CheckedDuplication {\n crossFile?: CrossFileDuplicationMetrics;\n /** The detection's `minSimilarityPercent`, the share of a block a copy of it holds at least. */\n minSimilarityPercent: number;\n}\n\nexport interface ExceededLimit {\n metric: string;\n /** What reports call the metric (`cognitive complexity`, `duplicated lines`). */\n label: string;\n value: number;\n /** The most severe level whose limit the value violates. */\n level: Level;\n /** That level's limit: the value is allowed up to it; a duplicated block is allowed below it. */\n limit: number;\n}\n\nexport interface BlockLocation {\n file: string;\n startLine: number;\n endLine: number;\n}\n\n/** One copy of a clone group. */\ninterface CloneOccurrence extends BlockLocation {\n /**\n * Its code lines matched in another copy, whose number is the length of a duplicated block. Its\n * line span would also count the lines the copies do not share.\n */\n lineNumbers: readonly number[];\n tokenCount: number;\n}\n\nexport interface Violation extends BlockLocation {\n kind: 'function' | 'file' | 'duplication';\n /** The most severe level among `exceeded`. */\n level: Level;\n /** The function's name, qualified with the class it is a member of, for kind `function`. */\n name?: string;\n exceeded: ExceededLimit[];\n /**\n * The parts adding the most to the cognitive complexity, largest first, for a function that\n * exceeds a limit of it and has such parts: operator sequences and jumps outside any branching\n * construct add to the complexity without being one.\n */\n largestBlocks?: CognitiveBlock[];\n /** The other copies of the block, most matched tokens first, for kind `duplication`. */\n partners?: BlockLocation[];\n}\n\nexport interface CheckResult {\n violations: Violation[];\n checkedFunctionCount: number;\n}\n\nconst violationKinds: readonly Violation['kind'][] = ['file', 'function', 'duplication'];\n\nconst cognitiveComplexityMetric = 'functionCognitiveComplexity';\n\nconst nestedInclusiveMetrics = new Set(\n functionThresholds\n .filter(({ includesNestedFunctions }) => includesNestedFunctions)\n .map((threshold) => metricNameOf(threshold))\n);\n\n/** Every limit the files exceed, errors first, then ordered by path, then line. */\nexport function checkThresholds(\n files: readonly CheckedFile[],\n duplication: CheckedDuplication,\n limitsOf: (language: string) => LimitsByLevel\n): CheckResult {\n const crossFileGroupsByFile = indexGroupsByFile(duplication.crossFile, new Set(files.map(({ file }) => file)));\n const violations: Violation[] = [];\n let checkedFunctionCount = 0;\n for (const checkedFile of files) {\n const { file, metrics, hunks } = checkedFile;\n const limits = limitsOf(metrics.language);\n // A rename or a mode change lists the file as changed without touching any of its lines.\n const fileExceeded = hunks?.length === 0 ? [] : collectExceeded(fileThresholds, metrics, limits);\n if (fileExceeded.length > 0) {\n violations.push(toViolation({ file, startLine: 1, endLine: metrics.lines.total }, 'file', fileExceeded));\n }\n const checkedFunctions = listCheckedFunctions(checkedFile);\n checkedFunctionCount += checkedFunctions.length;\n const exceedingFunctions = checkedFunctions\n .map((fn) => ({ fn, exceeded: collectExceeded(functionThresholds, fn, limits) }))\n .filter(({ exceeded }) => exceeded.length > 0);\n for (const { fn, exceeded } of exceedingFunctions) {\n const ownExceeded = exceeded.filter((limit) => !isReportedByEnclosingFunction(limit, fn, exceedingFunctions));\n if (ownExceeded.length > 0) {\n violations.push(toFunctionViolation(file, fn, ownExceeded));\n }\n }\n violations.push(\n ...collectDuplicationViolations(checkedFile, crossFileGroupsByFile.get(file) ?? [], limits, duplication)\n );\n }\n return { violations: violations.toSorted(compareViolations), checkedFunctionCount };\n}\n\n// The kind is compared before the end line because a file violation's end line is corrected after\n// this ordering (to the last line that exists), and the order must not depend on that correction.\nfunction compareViolations(left: Violation, right: Violation): number {\n return (\n levels.indexOf(right.level) - levels.indexOf(left.level) ||\n compareStrings(left.file, right.file) ||\n left.startLine - right.startLine ||\n violationKinds.indexOf(left.kind) - violationKinds.indexOf(right.kind) ||\n left.endLine - right.endLine\n );\n}\n\n/** The file's functions or, for a changed file, those the change touches. */\nfunction listCheckedFunctions({ metrics, hunks }: CheckedFile): FunctionMetrics[] {\n const endsByIndentation = metrics.language === 'python';\n return hunks\n ? metrics.functions.filter((fn) =>\n hunks.some((hunk) => touchesSpan(hunk, fn.startLine, fn.endLine, endsByIndentation))\n )\n : metrics.functions;\n}\n\n/**\n * Whether a function enclosing `fn` exceeds the same limit with a value that covers `fn`, so\n * reporting it for `fn` too would count the same code twice. The enclosing value is at least that\n * of `fn` and both share the file's limits, so its level is never milder.\n */\nfunction isReportedByEnclosingFunction(\n limit: ExceededLimit,\n fn: FunctionMetrics,\n exceedingFunctions: readonly { fn: FunctionMetrics; exceeded: ExceededLimit[] }[]\n): boolean {\n if (!nestedInclusiveMetrics.has(limit.metric)) {\n return false;\n }\n return exceedingFunctions.some(\n (outer) => outer.fn !== fn && encloses(outer.fn, fn) && outer.exceeded.some(({ metric }) => metric === limit.metric)\n );\n}\n\nfunction encloses(outer: FunctionMetrics, inner: FunctionMetrics): boolean {\n const startsBefore =\n outer.startLine < inner.startLine ||\n (outer.startLine === inner.startLine && outer.startColumn <= inner.startColumn);\n const endsAfter =\n outer.endLine > inner.endLine || (outer.endLine === inner.endLine && outer.endColumn >= inner.endColumn);\n return startsBefore && endsAfter;\n}\n\nfunction toFunctionViolation(file: string, fn: FunctionMetrics, exceeded: ExceededLimit[]): Violation {\n const { startLine, endLine } = fn;\n const exceedsCognitiveComplexity = exceeded.some(({ metric }) => metric === cognitiveComplexityMetric);\n return {\n ...toViolation({ file, startLine, endLine }, 'function', exceeded),\n name: qualifyFunctionName(fn),\n ...(exceedsCognitiveComplexity && fn.cognitiveBlocks.length > 0 && { largestBlocks: fn.cognitiveBlocks }),\n };\n}\n\n/** How reports name a function: with the type or module it is a member of, so that it can be found. */\nexport function qualifyFunctionName({ name = '<anonymous>', containerName }: FunctionMetrics): string {\n return containerName ? `${containerName}.${name}` : name;\n}\n\nfunction toViolation(location: BlockLocation, kind: Violation['kind'], exceeded: ExceededLimit[]): Violation {\n const level = exceeded.some((limit) => limit.level === 'error') ? 'error' : 'warning';\n return { kind, level, ...location, exceeded };\n}\n\nfunction collectExceeded<Subject>(\n thresholds: readonly Threshold<Subject>[],\n subject: Subject,\n limits: LimitsByLevel\n): ExceededLimit[] {\n return thresholds.flatMap((threshold) => findExceeded(threshold, subject, limits, (value, limit) => value > limit));\n}\n\n/** The most severe violated limit of the threshold, if any. */\nfunction findExceeded<Subject>(\n threshold: Threshold<Subject>,\n subject: Subject,\n limits: LimitsByLevel,\n violates: (value: number, limit: number) => boolean\n): ExceededLimit[] {\n const value = threshold.measure(subject);\n const level = levels.findLast((candidate) => violates(value, limits[candidate][threshold.key] as number));\n return level\n ? [\n {\n metric: metricNameOf(threshold),\n label: threshold.label,\n value,\n level,\n limit: limits[level][threshold.key] as number,\n },\n ]\n : [];\n}\n\n/**\n * Whether the hunk changes the head lines [startLine, endLine]. A hunk without head lines (a pure\n * deletion) sits between its headStart line and the next one. Where indentation ends a span, its\n * last line is a statement rather than a closing delimiter, so a deletion right after that line may\n * have removed the span's tail; `endsByIndentation` counts it as touching, since the head alone\n * cannot tell it from a deletion after the span.\n */\nfunction touchesSpan(hunk: LineHunk, startLine: number, endLine: number, endsByIndentation = false): boolean {\n return hunk.headCount === 0\n ? hunk.headStart >= startLine && (hunk.headStart < endLine || (endsByIndentation && hunk.headStart === endLine))\n : hunk.headStart <= endLine && hunk.headStart + hunk.headCount > startLine;\n}\n\n/** The cross-file groups with an occurrence in each of the given files. */\nfunction indexGroupsByFile(\n crossFileDuplication: CrossFileDuplicationMetrics | undefined,\n files: ReadonlySet<string>\n): Map<string, CrossFileDuplicateBlockGroup[]> {\n const groupsByFile = new Map<string, CrossFileDuplicateBlockGroup[]>();\n for (const group of crossFileDuplication?.groups ?? []) {\n for (const file of group.files) {\n if (files.has(file)) {\n const groups = groupsByFile.get(file) ?? [];\n groups.push(group);\n groupsByFile.set(file, groups);\n }\n }\n }\n return groupsByFile;\n}\n\n/**\n * One violation per duplicated region of the file: its clone occurrences, within-file and\n * cross-file, that hold at least the duplicated lines of a level (with `hunks`, only those\n * overlapping added lines), with overlapping occurrences merged into one region unless they are\n * copies of each other.\n */\nfunction collectDuplicationViolations(\n { file, metrics, hunks }: CheckedFile,\n crossFileGroups: readonly CrossFileDuplicateBlockGroup[],\n limits: LimitsByLevel,\n { minSimilarityPercent }: CheckedDuplication\n): Violation[] {\n const findExceededLines = (duplicatedLineCount: number): ExceededLimit[] =>\n findExceeded(duplicationThreshold, { duplicatedLineCount }, limits, (value, limit) => value >= limit);\n const groups: CloneOccurrence[][] = [\n ...metrics.duplication.duplicateBlockGroups.map((group) => group.map((occurrence) => ({ ...occurrence, file }))),\n ...crossFileGroups.map((group) => group.occurrences.map((occurrence) => toCloneOccurrence(occurrence))),\n ];\n const isReported = (block: CloneOccurrence): boolean =>\n block.file === file &&\n findExceededLines(block.lineNumbers.length).length > 0 &&\n (!hunks || hunks.some((hunk) => hunk.headCount > 0 && touchesSpan(hunk, block.startLine, block.endLine)));\n const groupOf = new Map(groups.flatMap((group) => group.map((occurrence) => [occurrence, group] as const)));\n // Copies of one group that share a line (the branches of an `else if` chain, the entries of a\n // list) stay apart: merged, they would be one block without a copy. One lying within another is\n // a repetition inside that block, not a block beside it.\n const areCopies = (left: CloneOccurrence, right: CloneOccurrence): boolean =>\n groupOf.get(left) === groupOf.get(right) && !contains(left, right) && !contains(right, left);\n const reportedBlocks = groups.flat().filter((occurrence) => isReported(occurrence));\n return mergeOverlapping(reportedBlocks, areCopies).map(({ merged, sources }) => {\n const listCopies = (minTokenPercent: number): BlockLocation[] =>\n mergeOverlapping(\n sources.flatMap((source) => {\n // The region merges every clone that overlaps it, so a copy of an occurrence lying\n // within a larger one is measured against the larger one.\n const tokenCount = Math.max(\n ...sources.filter((outer) => contains(outer, source)).map((outer) => outer.tokenCount)\n );\n return (groupOf.get(source) ?? []).filter(\n (copy) =>\n copy.tokenCount * 100 >= tokenCount * minTokenPercent &&\n // The region's own occurrences are among the copies of the groups it belongs to, and\n // so may be an occurrence within one of them that is not reported itself: a\n // repetition inside the block, not another place.\n !sources.some((outer) => contains(outer, copy))\n );\n }),\n areCopies\n )\n // The largest say the most about what to share.\n .toSorted((left, right) => sumTokens(right.sources) - sumTokens(left.sources))\n .map((partner) => partner.merged);\n // A group joins a block with every place repeating a part of it: a function another file\n // copies whole is grouped with each file holding a few of its lines. Such a fragment is not a\n // copy of the block: sharing it would not remove the block, and a report listing it reads as\n // one more copy. Sizes are compared in tokens, since the same code may be laid out over any\n // number of lines.\n const copies = listCopies(minSimilarityPercent);\n return {\n ...toViolation(\n merged,\n 'duplication',\n // Occurrences that overlap share lines, which count once.\n findExceededLines(new Set(sources.flatMap((source) => source.lineNumbers)).size)\n ),\n // A block whose parts were matched with different places has no copy of its size. The\n // places then are all that locates what it repeats.\n partners: copies.length > 0 ? copies : listCopies(0),\n };\n });\n}\n\nfunction toCloneOccurrence(occurrence: CrossFileDuplicateOccurrence): CloneOccurrence {\n const { lineNumbers } = occurrence;\n assert.ok(lineNumbers, `No matched lines for ${occurrence.file}, a scanned file.`);\n return { ...occurrence, lineNumbers };\n}\n\nfunction sumTokens(occurrences: readonly CloneOccurrence[]): number {\n return occurrences.reduce((sum, occurrence) => sum + occurrence.tokenCount, 0);\n}\n\n/**\n * The locations with those overlapping in the same file merged, except those `areCopies` holds\n * for, ordered by file, then line.\n */\nfunction mergeOverlapping(\n locations: readonly CloneOccurrence[],\n areCopies: (left: CloneOccurrence, right: CloneOccurrence) => boolean\n): { merged: BlockLocation; sources: CloneOccurrence[] }[] {\n const regions: { merged: BlockLocation; sources: CloneOccurrence[] }[] = [];\n const ordered = locations.toSorted(\n (left, right) => compareStrings(left.file, right.file) || left.startLine - right.startLine\n );\n for (const location of ordered) {\n const last = regions.at(-1);\n // A block containing the location holds it whatever it is a copy of.\n const joins =\n last &&\n overlaps(last.merged, location) &&\n (last.sources.some((source) => contains(source, location)) ||\n !last.sources.some((source) => areCopies(source, location)));\n if (joins) {\n last.merged.endLine = Math.max(last.merged.endLine, location.endLine);\n last.sources.push(location);\n } else {\n const { file, startLine, endLine } = location;\n regions.push({ merged: { file, startLine, endLine }, sources: [location] });\n }\n }\n return regions;\n}\n\n/** Whether `outer` spans every line of `inner`; a block contains itself. */\nfunction contains(outer: BlockLocation, inner: BlockLocation): boolean {\n return outer.file === inner.file && outer.startLine <= inner.startLine && inner.endLine <= outer.endLine;\n}\n\nfunction overlaps(left: BlockLocation, right: BlockLocation): boolean {\n return left.file === right.file && left.startLine <= right.endLine && right.startLine <= left.endLine;\n}\n\n/** Code-unit order, so reports do not depend on the locale. */\nfunction compareStrings(left: string, right: string): number {\n return left < right ? -1 : left > right ? 1 : 0;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"thresholdCheck.js","names":[],"sources":["../src/thresholdCheck.ts"],"sourcesContent":["import assert from 'node:assert';\nimport type {\n CrossFileDuplicateBlockGroup,\n CrossFileDuplicateOccurrence,\n CrossFileDuplicationMetrics,\n} from './crossFileDuplication.js';\nimport type { LineHunk } from './git.js';\nimport {\n duplicationThreshold,\n fileThresholds,\n functionThresholds,\n levels,\n metricNameOf,\n type Level,\n type LimitsByLevel,\n type Threshold,\n} from './thresholds.js';\nimport type { CodeMetrics, CognitiveBlock, FunctionMetrics } from './types.js';\n\n/** A measured file to check, under the path cross-file duplication knows it by. */\nexport interface CheckedFile {\n file: string;\n metrics: CodeMetrics;\n /** The change to the file; when set, only what the change touches is checked. */\n hunks?: readonly LineHunk[];\n}\n\n/** The duplication of the measured files, in which duplicated blocks and their copies are found. */\nexport interface CheckedDuplication {\n crossFile?: CrossFileDuplicationMetrics;\n /** The detection's `minSimilarityPercent`, the share of a block a copy of it holds at least. */\n minSimilarityPercent: number;\n}\n\nexport interface ExceededLimit {\n metric: string;\n /** What reports call the metric (`cognitive complexity`, `duplicated lines`). */\n label: string;\n value: number;\n /** The most severe level whose limit the value violates. */\n level: Level;\n /** That level's limit: the value is allowed up to it; a duplicated block is allowed below it. */\n limit: number;\n}\n\nexport interface BlockLocation {\n file: string;\n startLine: number;\n endLine: number;\n}\n\n/** One copy of a clone group. */\ninterface CloneOccurrence extends BlockLocation {\n /**\n * Its code lines matched in another copy, whose number is the length of a duplicated block. Its\n * line span would also count the lines the copies do not share.\n */\n lineNumbers: readonly number[];\n tokenCount: number;\n}\n\nexport interface Violation extends BlockLocation {\n kind: 'function' | 'file' | 'duplication';\n /** The most severe level among `exceeded`. */\n level: Level;\n /** The function's name, qualified with the class it is a member of, for kind `function`. */\n name?: string;\n exceeded: ExceededLimit[];\n /**\n * The parts adding the most to the cognitive complexity, largest first, for a function that\n * exceeds a limit of it and has such parts: operator sequences and jumps outside any branching\n * construct add to the complexity without being one.\n */\n largestBlocks?: CognitiveBlock[];\n /** The other copies of the block, most matched tokens first, for kind `duplication`. */\n partners?: BlockLocation[];\n}\n\nexport interface CheckResult {\n violations: Violation[];\n checkedFunctionCount: number;\n}\n\nconst violationKinds: readonly Violation['kind'][] = ['file', 'function', 'duplication'];\n\nconst cognitiveComplexityMetric = 'functionCognitiveComplexity';\n\nconst nestedInclusiveMetrics = new Set(\n functionThresholds\n .filter(({ includesNestedFunctions }) => includesNestedFunctions)\n .map((threshold) => metricNameOf(threshold))\n);\n\n/** Every limit the files exceed, errors first, then ordered by path, then line. */\nexport function checkThresholds(\n files: readonly CheckedFile[],\n duplication: CheckedDuplication,\n limitsOf: (language: string) => LimitsByLevel\n): CheckResult {\n const crossFileGroupsByFile = indexGroupsByFile(duplication.crossFile, new Set(files.map(({ file }) => file)));\n const violations: Violation[] = [];\n let checkedFunctionCount = 0;\n for (const checkedFile of files) {\n const { file, metrics, hunks } = checkedFile;\n const limits = limitsOf(metrics.language);\n // A rename or a mode change lists the file as changed without touching any of its lines.\n const fileExceeded = hunks?.length === 0 ? [] : collectExceeded(fileThresholds, metrics, limits);\n if (fileExceeded.length > 0) {\n violations.push(toViolation({ file, startLine: 1, endLine: metrics.lines.total }, 'file', fileExceeded));\n }\n const checkedFunctions = listCheckedFunctions(checkedFile);\n checkedFunctionCount += checkedFunctions.length;\n const exceedingFunctions = checkedFunctions\n .map((fn) => ({ fn, exceeded: collectExceeded(functionThresholds, fn, limits) }))\n .filter(({ exceeded }) => exceeded.length > 0);\n for (const { fn, exceeded } of exceedingFunctions) {\n const ownExceeded = exceeded.filter((limit) => !isReportedByEnclosingFunction(limit, fn, exceedingFunctions));\n if (ownExceeded.length > 0) {\n violations.push(toFunctionViolation(file, fn, ownExceeded));\n }\n }\n violations.push(\n ...collectDuplicationViolations(checkedFile, crossFileGroupsByFile.get(file) ?? [], limits, duplication)\n );\n }\n return { violations: violations.toSorted(compareViolations), checkedFunctionCount };\n}\n\n// The kind is compared before the end line because a file violation's end line is corrected after\n// this ordering (to the last line that exists), and the order must not depend on that correction.\nfunction compareViolations(left: Violation, right: Violation): number {\n return (\n levels.indexOf(right.level) - levels.indexOf(left.level) ||\n compareStrings(left.file, right.file) ||\n left.startLine - right.startLine ||\n violationKinds.indexOf(left.kind) - violationKinds.indexOf(right.kind) ||\n left.endLine - right.endLine\n );\n}\n\n/** The file's functions or, for a changed file, those the change touches. */\nfunction listCheckedFunctions({ metrics, hunks }: CheckedFile): FunctionMetrics[] {\n const endsByIndentation = metrics.language === 'python';\n return hunks\n ? metrics.functions.filter((fn) =>\n hunks.some((hunk) => touchesSpan(hunk, fn.startLine, fn.endLine, endsByIndentation))\n )\n : metrics.functions;\n}\n\n/**\n * Whether a function enclosing `fn` exceeds the same limit with a value that covers `fn`, so\n * reporting it for `fn` too would count the same code twice. The enclosing value is at least that\n * of `fn` and both share the file's limits, so its level is never milder.\n */\nfunction isReportedByEnclosingFunction(\n limit: ExceededLimit,\n fn: FunctionMetrics,\n exceedingFunctions: readonly { fn: FunctionMetrics; exceeded: ExceededLimit[] }[]\n): boolean {\n if (!nestedInclusiveMetrics.has(limit.metric)) {\n return false;\n }\n return exceedingFunctions.some(\n (outer) => outer.fn !== fn && encloses(outer.fn, fn) && outer.exceeded.some(({ metric }) => metric === limit.metric)\n );\n}\n\nfunction encloses(outer: FunctionMetrics, inner: FunctionMetrics): boolean {\n const startsBefore =\n outer.startLine < inner.startLine ||\n (outer.startLine === inner.startLine && outer.startColumn <= inner.startColumn);\n const endsAfter =\n outer.endLine > inner.endLine || (outer.endLine === inner.endLine && outer.endColumn >= inner.endColumn);\n return startsBefore && endsAfter;\n}\n\nfunction toFunctionViolation(file: string, fn: FunctionMetrics, exceeded: ExceededLimit[]): Violation {\n const { startLine, endLine } = fn;\n const exceedsCognitiveComplexity = exceeded.some(({ metric }) => metric === cognitiveComplexityMetric);\n return {\n ...toViolation({ file, startLine, endLine }, 'function', exceeded),\n name: qualifyFunctionName(fn),\n ...(exceedsCognitiveComplexity && fn.cognitiveBlocks.length > 0 && { largestBlocks: fn.cognitiveBlocks }),\n };\n}\n\n/** How reports name a function: with the type or module it is a member of, so that it can be found. */\nexport function qualifyFunctionName({ name = '<anonymous>', containerName }: FunctionMetrics): string {\n return containerName ? `${containerName}.${name}` : name;\n}\n\nfunction toViolation(location: BlockLocation, kind: Violation['kind'], exceeded: ExceededLimit[]): Violation {\n const level = exceeded.some((limit) => limit.level === 'error') ? 'error' : 'warning';\n return { kind, level, ...location, exceeded };\n}\n\nfunction collectExceeded<Subject>(\n thresholds: readonly Threshold<Subject>[],\n subject: Subject,\n limits: LimitsByLevel\n): ExceededLimit[] {\n return thresholds.flatMap((threshold) => findExceeded(threshold, subject, limits, (value, limit) => value > limit));\n}\n\n/** The most severe violated limit of the threshold, if any. */\nfunction findExceeded<Subject>(\n threshold: Threshold<Subject>,\n subject: Subject,\n limits: LimitsByLevel,\n violates: (value: number, limit: number) => boolean\n): ExceededLimit[] {\n const value = threshold.measure(subject);\n const level = levels.findLast((candidate) => violates(value, limits[candidate][threshold.key] as number));\n return level\n ? [\n {\n metric: metricNameOf(threshold),\n label: threshold.label,\n value,\n level,\n limit: limits[level][threshold.key] as number,\n },\n ]\n : [];\n}\n\n/**\n * Whether the hunk changes the head lines [startLine, endLine]. A hunk without head lines (a pure\n * deletion) sits between its headStart line and the next one. Where indentation ends a span, its\n * last line is a statement rather than a closing delimiter, so a deletion right after that line may\n * have removed the span's tail; `endsByIndentation` counts it as touching, since the head alone\n * cannot tell it from a deletion after the span.\n */\nfunction touchesSpan(hunk: LineHunk, startLine: number, endLine: number, endsByIndentation = false): boolean {\n return hunk.headCount === 0\n ? hunk.headStart >= startLine && (hunk.headStart < endLine || (endsByIndentation && hunk.headStart === endLine))\n : hunk.headStart <= endLine && hunk.headStart + hunk.headCount > startLine;\n}\n\n/** The cross-file groups with an occurrence in each of the given files. */\nfunction indexGroupsByFile(\n crossFileDuplication: CrossFileDuplicationMetrics | undefined,\n files: ReadonlySet<string>\n): Map<string, CrossFileDuplicateBlockGroup[]> {\n const groupsByFile = new Map<string, CrossFileDuplicateBlockGroup[]>();\n for (const group of crossFileDuplication?.groups ?? []) {\n for (const file of group.files) {\n if (files.has(file)) {\n const groups = groupsByFile.get(file) ?? [];\n groups.push(group);\n groupsByFile.set(file, groups);\n }\n }\n }\n return groupsByFile;\n}\n\n/**\n * One violation per duplicated region of the file: its clone occurrences, within-file and\n * cross-file, that hold at least the duplicated lines of a level (with `hunks`, only those\n * overlapping added lines), with overlapping occurrences merged into one region unless they are\n * copies of each other.\n */\nfunction collectDuplicationViolations(\n { file, metrics, hunks }: CheckedFile,\n crossFileGroups: readonly CrossFileDuplicateBlockGroup[],\n limits: LimitsByLevel,\n { minSimilarityPercent }: CheckedDuplication\n): Violation[] {\n const findExceededLines = (duplicatedLineCount: number): ExceededLimit[] =>\n findExceeded(duplicationThreshold, { duplicatedLineCount }, limits, (value, limit) => value >= limit);\n const groups: CloneOccurrence[][] = [\n ...metrics.duplication.duplicateBlockGroups.map((group) => group.map((occurrence) => ({ ...occurrence, file }))),\n ...crossFileGroups.map((group) => group.occurrences.map((occurrence) => toCloneOccurrence(occurrence))),\n ];\n const isReported = (block: CloneOccurrence): boolean =>\n block.file === file &&\n findExceededLines(block.lineNumbers.length).length > 0 &&\n (!hunks || hunks.some((hunk) => hunk.headCount > 0 && touchesSpan(hunk, block.startLine, block.endLine)));\n const groupOf = new Map(groups.flatMap((group) => group.map((occurrence) => [occurrence, group] as const)));\n // Copies of one group that share a line (the branches of an `else if` chain, the entries of a\n // list) stay apart: merged, they would be one block without a copy. One lying within another is\n // a repetition inside that block, not a block beside it.\n const areCopies = (left: CloneOccurrence, right: CloneOccurrence): boolean =>\n groupOf.get(left) === groupOf.get(right) && !contains(left, right) && !contains(right, left);\n const reportedBlocks = groups.flat().filter((occurrence) => isReported(occurrence));\n return mergeOverlapping(reportedBlocks, areCopies).map(({ merged, sources }) => {\n const listCopies = (minTokenPercent: number): BlockLocation[] =>\n mergeOverlapping(\n sources.flatMap((source) => {\n // The region merges every clone that overlaps it, so a copy of an occurrence lying\n // within a larger one is measured against the larger one.\n const tokenCount = Math.max(\n ...sources.filter((outer) => contains(outer, source)).map((outer) => outer.tokenCount)\n );\n return (groupOf.get(source) ?? []).filter(\n (copy) =>\n copy.tokenCount * 100 >= tokenCount * minTokenPercent &&\n // The region's own occurrences are among the copies of the groups it belongs to, and\n // so may be an occurrence within one of them that is not reported itself: a\n // repetition inside the block, not another place.\n !sources.some((outer) => contains(outer, copy))\n );\n }),\n areCopies\n )\n // The largest say the most about what to share.\n .toSorted((left, right) => sumTokens(right.sources) - sumTokens(left.sources))\n .map((partner) => partner.merged);\n // A group joins a block with every place repeating a part of it: a function another file\n // copies whole is grouped with each file holding a few of its lines. Such a fragment is not a\n // copy of the block: sharing it would not remove the block, and a report listing it reads as\n // one more copy. Sizes are compared in tokens, since the same code may be laid out over any\n // number of lines.\n const copies = listCopies(minSimilarityPercent);\n return {\n ...toViolation(\n merged,\n 'duplication',\n // Occurrences that overlap share lines, which count once.\n findExceededLines(new Set(sources.flatMap((source) => source.lineNumbers)).size)\n ),\n // A block whose parts were matched with different places has no copy of its size. The\n // places then are all that locates what it repeats.\n partners: copies.length > 0 ? copies : listCopies(0),\n };\n });\n}\n\nfunction toCloneOccurrence(occurrence: CrossFileDuplicateOccurrence): CloneOccurrence {\n const { lineNumbers } = occurrence;\n assert.ok(lineNumbers, `No matched lines for ${occurrence.file}, a scanned file.`);\n return { ...occurrence, lineNumbers };\n}\n\nfunction sumTokens(occurrences: readonly CloneOccurrence[]): number {\n return occurrences.reduce((sum, occurrence) => sum + occurrence.tokenCount, 0);\n}\n\n/**\n * The locations with those overlapping in the same file merged, except those `areCopies` holds\n * for, ordered by file, then line.\n */\nfunction mergeOverlapping(\n locations: readonly CloneOccurrence[],\n areCopies: (left: CloneOccurrence, right: CloneOccurrence) => boolean\n): { merged: BlockLocation; sources: CloneOccurrence[] }[] {\n const regions: { merged: BlockLocation; sources: CloneOccurrence[] }[] = [];\n const ordered = locations.toSorted(\n (left, right) => compareStrings(left.file, right.file) || left.startLine - right.startLine\n );\n for (const location of ordered) {\n const last = regions.at(-1);\n // A block containing the location holds it whatever it is a copy of.\n const joins =\n last &&\n overlaps(last.merged, location) &&\n (last.sources.some((source) => contains(source, location)) ||\n !last.sources.some((source) => areCopies(source, location)));\n if (joins) {\n last.merged.endLine = Math.max(last.merged.endLine, location.endLine);\n last.sources.push(location);\n } else {\n const { file, startLine, endLine } = location;\n regions.push({ merged: { file, startLine, endLine }, sources: [location] });\n }\n }\n return regions;\n}\n\n/** Whether `outer` spans every line of `inner`; a block contains itself. */\nfunction contains(outer: BlockLocation, inner: BlockLocation): boolean {\n return outer.file === inner.file && outer.startLine <= inner.startLine && inner.endLine <= outer.endLine;\n}\n\nfunction overlaps(left: BlockLocation, right: BlockLocation): boolean {\n return left.file === right.file && left.startLine <= right.endLine && right.startLine <= left.endLine;\n}\n\n/** Code-unit order, so reports do not depend on the locale. */\nfunction compareStrings(left: string, right: string): number {\n return left < right ? -1 : left > right ? 1 : 0;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"thresholdCheck.js","names":["assert","violationKinds","nestedInclusiveMetrics","functionThresholds","includesNestedFunctions","metricNameOf","threshold","checkThresholds","crossFileGroupsByFile","indexGroupsByFile","duplication","files","file","violations","checkedFunctionCount","checkedFile","limits","limitsOf","metrics","fileExceeded","hunks","collectExceeded","fileThresholds","toViolation","checkedFunctions","listCheckedFunctions","exceedingFunctions","fn","exceeded","ownExceeded","isReportedByEnclosingFunction","limit","toFunctionViolation","collectDuplicationViolations","compareViolations","levels","right","left","compareStrings","endsByIndentation","touchesSpan","hunk","outer","encloses","metric","startsBefore","inner","endsAfter","exceedsCognitiveComplexity","qualifyFunctionName","containerName","name","location","thresholds","findExceeded","subject","value","level","violates","candidate","startLine","endLine","groupsByFile","group","crossFileDuplication","groups","findExceededLines","duplicationThreshold","occurrence","crossFileGroups","toCloneOccurrence","isReported","block","groupOf","areCopies","contains","mergeOverlapping","listCopies","sources","tokenCount","source","copy","minTokenPercent","sumTokens","partner","copies","minSimilarityPercent","merged","lineNumbers","occurrences","sum","regions","ordered","locations","last","overlaps"],"sources":["../src/thresholdCheck.ts"],"sourcesContent":["import assert from 'node:assert';\nimport type {\n CrossFileDuplicateBlockGroup,\n CrossFileDuplicateOccurrence,\n CrossFileDuplicationMetrics,\n} from './crossFileDuplication.js';\nimport type { LineHunk } from './git.js';\nimport {\n duplicationThreshold,\n fileThresholds,\n functionThresholds,\n levels,\n metricNameOf,\n type Level,\n type LimitsByLevel,\n type Threshold,\n} from './thresholds.js';\nimport type { CodeMetrics, CognitiveBlock, FunctionMetrics } from './types.js';\n\n/** A measured file to check, under the path cross-file duplication knows it by. */\nexport interface CheckedFile {\n file: string;\n metrics: CodeMetrics;\n /** The change to the file; when set, only what the change touches is checked. */\n hunks?: readonly LineHunk[];\n}\n\n/** The duplication of the measured files, in which duplicated blocks and their copies are found. */\nexport interface CheckedDuplication {\n crossFile?: CrossFileDuplicationMetrics;\n /** The detection's `minSimilarityPercent`, the share of a block a copy of it holds at least. */\n minSimilarityPercent: number;\n}\n\nexport interface ExceededLimit {\n metric: string;\n /** What reports call the metric (`cognitive complexity`, `duplicated lines`). */\n label: string;\n value: number;\n /** The most severe level whose limit the value violates. */\n level: Level;\n /** That level's limit: the value is allowed up to it; a duplicated block is allowed below it. */\n limit: number;\n}\n\nexport interface BlockLocation {\n file: string;\n startLine: number;\n endLine: number;\n}\n\n/** One copy of a clone group. */\ninterface CloneOccurrence extends BlockLocation {\n /**\n * Its code lines matched in another copy, whose number is the length of a duplicated block. Its\n * line span would also count the lines the copies do not share.\n */\n lineNumbers: readonly number[];\n tokenCount: number;\n}\n\nexport interface Violation extends BlockLocation {\n kind: 'function' | 'file' | 'duplication';\n /** The most severe level among `exceeded`. */\n level: Level;\n /** The function's name, qualified with the class it is a member of, for kind `function`. */\n name?: string;\n exceeded: ExceededLimit[];\n /**\n * The parts adding the most to the cognitive complexity, largest first, for a function that\n * exceeds a limit of it and has such parts: operator sequences and jumps outside any branching\n * construct add to the complexity without being one.\n */\n largestBlocks?: CognitiveBlock[];\n /** The other copies of the block, most matched tokens first, for kind `duplication`. */\n partners?: BlockLocation[];\n}\n\nexport interface CheckResult {\n violations: Violation[];\n checkedFunctionCount: number;\n}\n\nconst violationKinds: readonly Violation['kind'][] = ['file', 'function', 'duplication'];\n\nconst cognitiveComplexityMetric = 'functionCognitiveComplexity';\n\nconst nestedInclusiveMetrics = new Set(\n functionThresholds\n .filter(({ includesNestedFunctions }) => includesNestedFunctions)\n .map((threshold) => metricNameOf(threshold))\n);\n\n/** Every limit the files exceed, errors first, then ordered by path, then line. */\nexport function checkThresholds(\n files: readonly CheckedFile[],\n duplication: CheckedDuplication,\n limitsOf: (language: string) => LimitsByLevel\n): CheckResult {\n const crossFileGroupsByFile = indexGroupsByFile(duplication.crossFile, new Set(files.map(({ file }) => file)));\n const violations: Violation[] = [];\n let checkedFunctionCount = 0;\n for (const checkedFile of files) {\n const { file, metrics, hunks } = checkedFile;\n const limits = limitsOf(metrics.language);\n // A rename or a mode change lists the file as changed without touching any of its lines.\n const fileExceeded = hunks?.length === 0 ? [] : collectExceeded(fileThresholds, metrics, limits);\n if (fileExceeded.length > 0) {\n violations.push(toViolation({ file, startLine: 1, endLine: metrics.lines.total }, 'file', fileExceeded));\n }\n const checkedFunctions = listCheckedFunctions(checkedFile);\n checkedFunctionCount += checkedFunctions.length;\n const exceedingFunctions = checkedFunctions\n .map((fn) => ({ fn, exceeded: collectExceeded(functionThresholds, fn, limits) }))\n .filter(({ exceeded }) => exceeded.length > 0);\n for (const { fn, exceeded } of exceedingFunctions) {\n const ownExceeded = exceeded.filter((limit) => !isReportedByEnclosingFunction(limit, fn, exceedingFunctions));\n if (ownExceeded.length > 0) {\n violations.push(toFunctionViolation(file, fn, ownExceeded));\n }\n }\n violations.push(\n ...collectDuplicationViolations(checkedFile, crossFileGroupsByFile.get(file) ?? [], limits, duplication)\n );\n }\n return { violations: violations.toSorted(compareViolations), checkedFunctionCount };\n}\n\n// The kind is compared before the end line because a file violation's end line is corrected after\n// this ordering (to the last line that exists), and the order must not depend on that correction.\nfunction compareViolations(left: Violation, right: Violation): number {\n return (\n levels.indexOf(right.level) - levels.indexOf(left.level) ||\n compareStrings(left.file, right.file) ||\n left.startLine - right.startLine ||\n violationKinds.indexOf(left.kind) - violationKinds.indexOf(right.kind) ||\n left.endLine - right.endLine\n );\n}\n\n/** The file's functions or, for a changed file, those the change touches. */\nfunction listCheckedFunctions({ metrics, hunks }: CheckedFile): FunctionMetrics[] {\n const endsByIndentation = metrics.language === 'python';\n return hunks\n ? metrics.functions.filter((fn) =>\n hunks.some((hunk) => touchesSpan(hunk, fn.startLine, fn.endLine, endsByIndentation))\n )\n : metrics.functions;\n}\n\n/**\n * Whether a function enclosing `fn` exceeds the same limit with a value that covers `fn`, so\n * reporting it for `fn` too would count the same code twice. The enclosing value is at least that\n * of `fn` and both share the file's limits, so its level is never milder.\n */\nfunction isReportedByEnclosingFunction(\n limit: ExceededLimit,\n fn: FunctionMetrics,\n exceedingFunctions: readonly { fn: FunctionMetrics; exceeded: ExceededLimit[] }[]\n): boolean {\n if (!nestedInclusiveMetrics.has(limit.metric)) {\n return false;\n }\n return exceedingFunctions.some(\n (outer) => outer.fn !== fn && encloses(outer.fn, fn) && outer.exceeded.some(({ metric }) => metric === limit.metric)\n );\n}\n\nfunction encloses(outer: FunctionMetrics, inner: FunctionMetrics): boolean {\n const startsBefore =\n outer.startLine < inner.startLine ||\n (outer.startLine === inner.startLine && outer.startColumn <= inner.startColumn);\n const endsAfter =\n outer.endLine > inner.endLine || (outer.endLine === inner.endLine && outer.endColumn >= inner.endColumn);\n return startsBefore && endsAfter;\n}\n\nfunction toFunctionViolation(file: string, fn: FunctionMetrics, exceeded: ExceededLimit[]): Violation {\n const { startLine, endLine } = fn;\n const exceedsCognitiveComplexity = exceeded.some(({ metric }) => metric === cognitiveComplexityMetric);\n return {\n ...toViolation({ file, startLine, endLine }, 'function', exceeded),\n name: qualifyFunctionName(fn),\n ...(exceedsCognitiveComplexity && fn.cognitiveBlocks.length > 0 && { largestBlocks: fn.cognitiveBlocks }),\n };\n}\n\n/** How reports name a function: with the type or module it is a member of, so that it can be found. */\nexport function qualifyFunctionName({ name = '<anonymous>', containerName }: FunctionMetrics): string {\n return containerName ? `${containerName}.${name}` : name;\n}\n\nfunction toViolation(location: BlockLocation, kind: Violation['kind'], exceeded: ExceededLimit[]): Violation {\n const level = exceeded.some((limit) => limit.level === 'error') ? 'error' : 'warning';\n return { kind, level, ...location, exceeded };\n}\n\nfunction collectExceeded<Subject>(\n thresholds: readonly Threshold<Subject>[],\n subject: Subject,\n limits: LimitsByLevel\n): ExceededLimit[] {\n return thresholds.flatMap((threshold) => findExceeded(threshold, subject, limits, (value, limit) => value > limit));\n}\n\n/** The most severe violated limit of the threshold, if any. */\nfunction findExceeded<Subject>(\n threshold: Threshold<Subject>,\n subject: Subject,\n limits: LimitsByLevel,\n violates: (value: number, limit: number) => boolean\n): ExceededLimit[] {\n const value = threshold.measure(subject);\n const level = levels.findLast((candidate) => violates(value, limits[candidate][threshold.key] as number));\n return level\n ? [\n {\n metric: metricNameOf(threshold),\n label: threshold.label,\n value,\n level,\n limit: limits[level][threshold.key] as number,\n },\n ]\n : [];\n}\n\n/**\n * Whether the hunk changes the head lines [startLine, endLine]. A hunk without head lines (a pure\n * deletion) sits between its headStart line and the next one. Where indentation ends a span, its\n * last line is a statement rather than a closing delimiter, so a deletion right after that line may\n * have removed the span's tail; `endsByIndentation` counts it as touching, since the head alone\n * cannot tell it from a deletion after the span.\n */\nfunction touchesSpan(hunk: LineHunk, startLine: number, endLine: number, endsByIndentation = false): boolean {\n return hunk.headCount === 0\n ? hunk.headStart >= startLine && (hunk.headStart < endLine || (endsByIndentation && hunk.headStart === endLine))\n : hunk.headStart <= endLine && hunk.headStart + hunk.headCount > startLine;\n}\n\n/** The cross-file groups with an occurrence in each of the given files. */\nfunction indexGroupsByFile(\n crossFileDuplication: CrossFileDuplicationMetrics | undefined,\n files: ReadonlySet<string>\n): Map<string, CrossFileDuplicateBlockGroup[]> {\n const groupsByFile = new Map<string, CrossFileDuplicateBlockGroup[]>();\n for (const group of crossFileDuplication?.groups ?? []) {\n for (const file of group.files) {\n if (files.has(file)) {\n const groups = groupsByFile.get(file) ?? [];\n groups.push(group);\n groupsByFile.set(file, groups);\n }\n }\n }\n return groupsByFile;\n}\n\n/**\n * One violation per duplicated region of the file: its clone occurrences, within-file and\n * cross-file, that hold at least the duplicated lines of a level (with `hunks`, only those\n * overlapping added lines), with overlapping occurrences merged into one region unless they are\n * copies of each other.\n */\nfunction collectDuplicationViolations(\n { file, metrics, hunks }: CheckedFile,\n crossFileGroups: readonly CrossFileDuplicateBlockGroup[],\n limits: LimitsByLevel,\n { minSimilarityPercent }: CheckedDuplication\n): Violation[] {\n const findExceededLines = (duplicatedLineCount: number): ExceededLimit[] =>\n findExceeded(duplicationThreshold, { duplicatedLineCount }, limits, (value, limit) => value >= limit);\n const groups: CloneOccurrence[][] = [\n ...metrics.duplication.duplicateBlockGroups.map((group) => group.map((occurrence) => ({ ...occurrence, file }))),\n ...crossFileGroups.map((group) => group.occurrences.map((occurrence) => toCloneOccurrence(occurrence))),\n ];\n const isReported = (block: CloneOccurrence): boolean =>\n block.file === file &&\n findExceededLines(block.lineNumbers.length).length > 0 &&\n (!hunks || hunks.some((hunk) => hunk.headCount > 0 && touchesSpan(hunk, block.startLine, block.endLine)));\n const groupOf = new Map(groups.flatMap((group) => group.map((occurrence) => [occurrence, group] as const)));\n // Copies of one group that share a line (the branches of an `else if` chain, the entries of a\n // list) stay apart: merged, they would be one block without a copy. One lying within another is\n // a repetition inside that block, not a block beside it.\n const areCopies = (left: CloneOccurrence, right: CloneOccurrence): boolean =>\n groupOf.get(left) === groupOf.get(right) && !contains(left, right) && !contains(right, left);\n const reportedBlocks = groups.flat().filter((occurrence) => isReported(occurrence));\n return mergeOverlapping(reportedBlocks, areCopies).map(({ merged, sources }) => {\n const listCopies = (minTokenPercent: number): BlockLocation[] =>\n mergeOverlapping(\n sources.flatMap((source) => {\n // The region merges every clone that overlaps it, so a copy of an occurrence lying\n // within a larger one is measured against the larger one.\n const tokenCount = Math.max(\n ...sources.filter((outer) => contains(outer, source)).map((outer) => outer.tokenCount)\n );\n return (groupOf.get(source) ?? []).filter(\n (copy) =>\n copy.tokenCount * 100 >= tokenCount * minTokenPercent &&\n // The region's own occurrences are among the copies of the groups it belongs to, and\n // so may be an occurrence within one of them that is not reported itself: a\n // repetition inside the block, not another place.\n !sources.some((outer) => contains(outer, copy))\n );\n }),\n areCopies\n )\n // The largest say the most about what to share.\n .toSorted((left, right) => sumTokens(right.sources) - sumTokens(left.sources))\n .map((partner) => partner.merged);\n // A group joins a block with every place repeating a part of it: a function another file\n // copies whole is grouped with each file holding a few of its lines. Such a fragment is not a\n // copy of the block: sharing it would not remove the block, and a report listing it reads as\n // one more copy. Sizes are compared in tokens, since the same code may be laid out over any\n // number of lines.\n const copies = listCopies(minSimilarityPercent);\n return {\n ...toViolation(\n merged,\n 'duplication',\n // Occurrences that overlap share lines, which count once.\n findExceededLines(new Set(sources.flatMap((source) => source.lineNumbers)).size)\n ),\n // A block whose parts were matched with different places has no copy of its size. The\n // places then are all that locates what it repeats.\n partners: copies.length > 0 ? copies : listCopies(0),\n };\n });\n}\n\nfunction toCloneOccurrence(occurrence: CrossFileDuplicateOccurrence): CloneOccurrence {\n const { lineNumbers } = occurrence;\n assert.ok(lineNumbers, `No matched lines for ${occurrence.file}, a scanned file.`);\n return { ...occurrence, lineNumbers };\n}\n\nfunction sumTokens(occurrences: readonly CloneOccurrence[]): number {\n return occurrences.reduce((sum, occurrence) => sum + occurrence.tokenCount, 0);\n}\n\n/**\n * The locations with those overlapping in the same file merged, except those `areCopies` holds\n * for, ordered by file, then line.\n */\nfunction mergeOverlapping(\n locations: readonly CloneOccurrence[],\n areCopies: (left: CloneOccurrence, right: CloneOccurrence) => boolean\n): { merged: BlockLocation; sources: CloneOccurrence[] }[] {\n const regions: { merged: BlockLocation; sources: CloneOccurrence[] }[] = [];\n const ordered = locations.toSorted(\n (left, right) => compareStrings(left.file, right.file) || left.startLine - right.startLine\n );\n for (const location of ordered) {\n const last = regions.at(-1);\n // A block containing the location holds it whatever it is a copy of.\n const joins =\n last &&\n overlaps(last.merged, location) &&\n (last.sources.some((source) => contains(source, location)) ||\n !last.sources.some((source) => areCopies(source, location)));\n if (joins) {\n last.merged.endLine = Math.max(last.merged.endLine, location.endLine);\n last.sources.push(location);\n } else {\n const { file, startLine, endLine } = location;\n regions.push({ merged: { file, startLine, endLine }, sources: [location] });\n }\n }\n return regions;\n}\n\n/** Whether `outer` spans every line of `inner`; a block contains itself. */\nfunction contains(outer: BlockLocation, inner: BlockLocation): boolean {\n return outer.file === inner.file && outer.startLine <= inner.startLine && inner.endLine <= outer.endLine;\n}\n\nfunction overlaps(left: BlockLocation, right: BlockLocation): boolean {\n return left.file === right.file && left.startLine <= right.endLine && right.startLine <= left.endLine;\n}\n\n/** Code-unit order, so reports do not depend on the locale. */\nfunction compareStrings(left: string, right: string): number {\n return left < right ? -1 : left > right ? 1 : 0;\n}\n"],"mappings":"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"}
|