code-gauge 5.0.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +204 -72
  2. package/THIRD-PARTY-NOTICES.txt +1152 -798
  3. package/dist/checkCommand.cjs +5 -0
  4. package/dist/checkCommand.cjs.map +1 -0
  5. package/dist/checkCommand.d.ts +12 -0
  6. package/dist/checkCommand.js +5 -0
  7. package/dist/checkCommand.js.map +1 -0
  8. package/dist/cli.cjs +3 -3
  9. package/dist/cli.cjs.map +1 -1
  10. package/dist/cli.js +3 -3
  11. package/dist/cli.js.map +1 -1
  12. package/dist/cliConfig.cjs +1 -1
  13. package/dist/cliConfig.cjs.map +1 -1
  14. package/dist/cliConfig.d.ts +3 -11
  15. package/dist/cliConfig.js +1 -1
  16. package/dist/cliConfig.js.map +1 -1
  17. package/dist/duplication.cjs +1 -1
  18. package/dist/duplication.cjs.map +1 -1
  19. package/dist/duplication.js +1 -1
  20. package/dist/duplication.js.map +1 -1
  21. package/dist/exclusion.cjs +1 -1
  22. package/dist/exclusion.cjs.map +1 -1
  23. package/dist/exclusion.d.ts +2 -2
  24. package/dist/exclusion.js +1 -1
  25. package/dist/exclusion.js.map +1 -1
  26. package/dist/git.cjs +1 -1
  27. package/dist/git.cjs.map +1 -1
  28. package/dist/git.d.ts +13 -12
  29. package/dist/git.js +1 -1
  30. package/dist/git.js.map +1 -1
  31. package/dist/index.cjs +1 -1
  32. package/dist/index.d.ts +0 -4
  33. package/dist/index.js +1 -1
  34. package/dist/metrics.cjs +1 -1
  35. package/dist/metrics.cjs.map +1 -1
  36. package/dist/metrics.d.ts +1 -2
  37. package/dist/metrics.js +1 -1
  38. package/dist/metrics.js.map +1 -1
  39. package/dist/nativeMetrics.cjs +3 -3
  40. package/dist/nativeMetrics.cjs.map +1 -1
  41. package/dist/nativeMetrics.js +3 -3
  42. package/dist/nativeMetrics.js.map +1 -1
  43. package/dist/scan.cjs +1 -1
  44. package/dist/scan.cjs.map +1 -1
  45. package/dist/scan.d.ts +10 -26
  46. package/dist/scan.js +1 -1
  47. package/dist/scan.js.map +1 -1
  48. package/dist/thresholdCheck.cjs +2 -0
  49. package/dist/thresholdCheck.cjs.map +1 -0
  50. package/dist/thresholdCheck.d.ts +36 -0
  51. package/dist/thresholdCheck.js +2 -0
  52. package/dist/thresholdCheck.js.map +1 -0
  53. package/dist/thresholds.cjs +2 -0
  54. package/dist/thresholds.cjs.map +1 -0
  55. package/dist/thresholds.d.ts +38 -0
  56. package/dist/thresholds.js +2 -0
  57. package/dist/thresholds.js.map +1 -0
  58. package/dist/types.d.ts +8 -4
  59. package/dist/worker.cjs +1 -1
  60. package/dist/worker.cjs.map +1 -1
  61. package/dist/worker.js +1 -1
  62. package/dist/worker.js.map +1 -1
  63. package/native/Cargo.lock +73 -73
  64. package/native/Cargo.toml +16 -21
  65. package/native/code-gauge.wasm +0 -0
  66. package/native/src/dep_degree.rs +6 -6
  67. package/native/src/duplication.rs +38 -12
  68. package/native/src/functions.rs +10 -7
  69. package/native/src/languages.rs +14 -13
  70. package/native/src/measure.rs +16 -8
  71. package/native/src/tree_index.rs +4 -4
  72. package/native/src/util.rs +9 -2
  73. package/package.json +10 -9
  74. package/dist/diffCommand.cjs +0 -5
  75. package/dist/diffCommand.cjs.map +0 -1
  76. package/dist/diffCommand.d.ts +0 -17
  77. package/dist/diffCommand.js +0 -5
  78. package/dist/diffCommand.js.map +0 -1
  79. package/dist/newDuplication.cjs +0 -2
  80. package/dist/newDuplication.cjs.map +0 -1
  81. package/dist/newDuplication.d.ts +0 -40
  82. package/dist/newDuplication.js +0 -2
  83. package/dist/newDuplication.js.map +0 -1
  84. package/dist/regressionGate.cjs +0 -2
  85. package/dist/regressionGate.cjs.map +0 -1
  86. package/dist/regressionGate.d.ts +0 -108
  87. package/dist/regressionGate.js +0 -2
  88. package/dist/regressionGate.js.map +0 -1
@@ -0,0 +1,5 @@
1
+ "use strict";const e=require("./_virtual/_rolldown/runtime.cjs"),t=require("./thresholds.cjs"),n=require("./cliConfig.cjs"),r=require("./git.cjs"),i=require("./exclusion.cjs"),a=require("./scan.cjs"),o=require("./thresholdCheck.cjs");let s=require("@willbooster/shared-lib"),c=require("node:fs/promises"),l=require("node:path");l=e.__toESM(l,1);let u=require("node:os");u=e.__toESM(u,1);async function d(e,r,i){try{let s=a.resolveTarget(e),c=await n.loadConfig(r.config,await a.configSearchDirectory(s)),l=n.resolveOptions(r,c),u=await p(s,r.base,l),d=o.checkThresholds(u.files,u.crossFileDuplication,t.resolveLimits(i,c.config.thresholds));await f(d.violations,u.root),l.json?E(r,u,d):b(r,u,d),u.errors.length>0?process.exitCode=2:d.violations.length>0&&(process.exitCode=1)}catch(e){a.writeStderr(`Error: ${(0,s.getErrorMessage)(e)}\n`),process.exitCode=2}}async function f(e,t){for(let n of e.filter(({kind:e})=>e===`file`)){let e=await(0,c.readFile)(l.default.join(t,n.file),`utf8`);/[\n\r]$/u.test(e)&&(n.endLine=Math.max(n.endLine-1,1))}}async function p(e,t,n){let i=await(0,c.realpath)(e),o=(await(0,c.stat)(i)).isFile()?i:void 0;if(o&&!a.getLanguage(o,n,!0))throw Error(`${l.default.basename(o)}: unsupported file type`);let s;try{s=await(0,c.realpath)(await r.resolveRepoRoot(o?l.default.dirname(i):i))}catch(e){if(t!==void 0)throw e;return await m(i,n)}return await h({repoRoot:s,canonicalTarget:i,targetFile:o},t,n)}async function m(e,t){let n=await a.configSearchDirectory(e),r=await a.scanTarget(e,{...t,loadExclusion:e=>i.loadExclusion(n,t.exclude,e)}),o=y(r,t);return{files:r.files.map(({file:e,metrics:t})=>({file:a.formatPath(e,r.displayRoot),metrics:t})),crossFileDuplication:o,errors:r.errors,warnings:r.warnings,root:r.displayRoot}}async function h({repoRoot:e,canonicalTarget:t,targetFile:n},o,s){if(n===void 0&&t!==e&&await r.isIgnored(e,t))throw Error(`${a.formatPath(t,e)}: git ignores this directory, so no file in it is checked; target a file to check it regardless`);let c=await r.listRepositoryFiles(e),u=new Set(n===void 0?[]:[n]),d=n!==void 0&&!c.has(a.formatPath(n,e))?a.formatPath(n,e):void 0;d!==void 0&&c.add(d);let f=i.keepPaths(await i.loadRepositoryExclusion(e,c,s.exclude),u),p=a.formatPath(t,e),m=await a.scanListedFiles(e,c,{...s,loadExclusion:()=>Promise.resolve(f)},u,n===void 0&&t!==e?p:``),h=y(m,s),v=n=>a.isWithinDirectory(l.default.join(e,n),t),b=m.files.map(({file:t,metrics:n})=>({file:a.formatPath(t,e),metrics:n})),x=b.filter(({file:e})=>v(e)),S=n=>t===e||n.startsWith(`${p}/`)||n.startsWith(`${p}:`),C;if(o!==void 0){C=await r.resolveMergeBase(e,o);let t=await r.listChangedFiles(e,C);d!==void 0&&t.push({status:`added`,headPath:d});let n=t.filter(({status:e,headPath:t})=>e!==`deleted`&&v(t));x=await _(b,n,e,C),S=e=>n.some(({headPath:t})=>e.startsWith(`${t}:`))}return{files:x,crossFileDuplication:h,...g(m,S),root:e,mergeBase:C}}function g(e,t){return{errors:e.errors.filter(e=>t(e)),warnings:[...e.warnings,...e.errors.filter(e=>!t(e))]}}async function _(e,t,n,i){let a=new Map(e.map(({file:e,metrics:t})=>[e,t]));return(await(0,s.mapConcurrently)(t,u.default.availableParallelism()*2,async e=>{let t=a.get(e.headPath);if(t===void 0)return[];let o={headStart:1,headCount:t.lines.total},s=e.basePath===void 0?[o]:await r.listLineHunks(n,i,e.basePath,e.headPath);return t.language===`python`&&s.some(({headCount:e})=>e===0)&&v(s,await(0,c.readFile)(l.default.join(n,e.headPath),`utf8`)),[{file:e.headPath,metrics:t,hunks:s}]})).flat()}function v(e,t){let n=t.split(/\r\n|\n|\r/u);for(let t of e)if(!(t.headCount>0))for(;t.headStart>0&&/^\s*(?:#.*)?$/u.test(n[t.headStart-1]??``);)--t.headStart}function y(e,t){if(e.fatalError)throw Error(e.fatalError);return a.addCrossFileDuplication(e,t),e.crossFileDuplication}function b(e,n,r){let{violations:i}=r,o=`${n.files.length} ${e.base===void 0?``:`changed `}files, ${r.checkedFunctionCount} functions checked${x(e,n)}`;n.errors.length>0?a.writeStdout(`Check could not complete: ${n.errors.length} measurement failures (details on stderr); ${S(i)} in the measured files (${o}).\n`):i.length===0?a.writeStdout(`No threshold violations: ${o}.\n`):a.writeStdout(`Threshold violations: ${S(i)} (${o}).\n`);for(let e of i)a.writeStdout(`${w(e)}\n`);let s=new Set(i.flatMap(({exceeded:e})=>e.map(({metric:e})=>e))),c=t.thresholds.filter(e=>s.has(t.metricNameOf(e)));if(c.length>0){a.writeStdout(`
2
+ How to fix:
3
+ `);for(let{label:e,hint:t}of c)a.writeStdout(`- ${e}: ${t}\n`)}for(let e of n.warnings)a.writeStderr(`Warning: ${e}\n`);for(let e of n.errors)a.writeStderr(`Error: ${e}\n`)}function x(e,t){return t.mergeBase===void 0?``:`; base ${e.base}, merge-base ${t.mergeBase.slice(0,12)}`}function S(e){let t=C(e);return`${t.violationCount} violations (${t.functionViolationCount} functions, ${t.fileViolationCount} files, ${t.duplicationViolationCount} duplicated blocks)`}function C(e){let t=t=>e.filter(e=>e.kind===t).length;return{violationCount:e.length,functionViolationCount:t(`function`),fileViolationCount:t(`file`),duplicationViolationCount:t(`duplication`)}}function w(e){let n=new Map(t.thresholds.map(e=>[t.metricNameOf(e),e.label])),r=e.exceeded.map(({metric:t,value:r,limit:i})=>`${n.get(t)} ${Math.ceil(r*10)/10} (${e.kind===`duplication`?`<`:`<=`} ${i})`).join(`, `);switch(e.kind){case`file`:return`${e.file}: ${r}`;case`function`:return`${T(e)} ${e.name?.replaceAll(/\s*[\n\r]\s*/gu,` `)}: ${r}`;case`duplication`:{let t=(e.partners??[]).map(e=>T(e)),n=[...t.slice(0,3),...t.length>3?[`...`]:[]];return`${T(e)}: ${r}${n.length>0?`, also at ${n.join(`, `)}`:``}`}}}function T({file:e,startLine:t,endLine:n}){return`${e}:${t}-${n}`}function E(e,t,n){a.writeStdout(JSON.stringify({passed:n.violations.length===0&&t.errors.length===0,base:e.base,mergeBase:t.mergeBase,summary:{...C(n.violations),checkedFileCount:t.files.length,checkedFunctionCount:n.checkedFunctionCount},violations:n.violations,errors:t.errors,warnings:t.warnings},void 0,2)+`
4
+ `)}exports.runCheckCommand=d;
5
+ //# sourceMappingURL=checkCommand.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkCommand.cjs","names":["resolveTarget","loadConfig","configSearchDirectory","resolveOptions","checkThresholds","resolveLimits","getErrorMessage","readFile","path","realpath","stat","getLanguage","resolveRepoRoot","scanTarget","loadExclusion","formatPath","isIgnored","listRepositoryFiles","keepPaths","loadRepositoryExclusion","scanListedFiles","isWithinDirectory","resolveMergeBase","listChangedFiles","mapConcurrently","os","listLineHunks","thresholds","metricNameOf"],"sources":["../src/checkCommand.ts"],"sourcesContent":["import { readFile, realpath, stat } from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { getErrorMessage, mapConcurrently } from '@willbooster/shared-lib';\nimport { loadConfig, resolveOptions, type CliOptions, type ResolvedOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { keepPaths, loadExclusion, loadRepositoryExclusion } from './exclusion.js';\nimport {\n isIgnored,\n listChangedFiles,\n listLineHunks,\n listRepositoryFiles,\n resolveMergeBase,\n resolveRepoRoot,\n type ChangedFile,\n type LineHunk,\n} from './git.js';\nimport {\n addCrossFileDuplication,\n configSearchDirectory,\n formatPath,\n getLanguage,\n isWithinDirectory,\n resolveTarget,\n scanListedFiles,\n scanTarget,\n writeStderr,\n writeStdout,\n type ScanResult,\n} from './scan.js';\nimport {\n checkThresholds,\n type BlockLocation,\n type CheckedFile,\n type CheckResult,\n type Violation,\n} from './thresholdCheck.js';\nimport { metricNameOf, resolveLimits, thresholds, type Limits } from './thresholds.js';\n\n/** Raw options of the `check` subcommand; every field is undefined unless the flag was passed. */\nexport interface CheckCliOptions extends CliOptions {\n base?: string;\n}\n\n/** The measured files a check covers and the duplication they take part in. */\ninterface CheckScope {\n files: CheckedFile[];\n crossFileDuplication?: CrossFileDuplicationMetrics;\n /** Measurement failures of files the check covers. */\n errors: string[];\n warnings: string[];\n /** The directory the files' paths are relative to. */\n root: string;\n mergeBase?: string;\n}\n\n/** Caps how many partner locations a duplicated block lists so the report stays scannable. */\nconst maxListedPartners = 3;\n\n/**\n * Reports every exceeded threshold of the target or, with `base`, of what the working tree changed\n * under it since the merge-base with that ref. Exit codes: 0 no violations, 1 violations, 2 files the check\n * covers could not be measured.\n */\nexport async function runCheckCommand(target: string, cliOptions: CheckCliOptions, cliLimits: Limits): Promise<void> {\n try {\n const resolvedTarget = resolveTarget(target);\n const config = await loadConfig(cliOptions.config, await configSearchDirectory(resolvedTarget));\n const options = resolveOptions(cliOptions, config);\n const scope = await scanScope(resolvedTarget, cliOptions.base, options);\n const result = checkThresholds(\n scope.files,\n scope.crossFileDuplication,\n resolveLimits(cliLimits, config.config.thresholds)\n );\n await endFileViolationsAtLastLine(result.violations, scope.root);\n\n if (options.json) {\n printJsonReport(cliOptions, scope, result);\n } else {\n printTextReport(cliOptions, scope, result);\n }\n if (scope.errors.length > 0) {\n process.exitCode = 2;\n } else if (result.violations.length > 0) {\n process.exitCode = 1;\n }\n } catch (error) {\n writeStderr(`Error: ${getErrorMessage(error)}\\n`);\n process.exitCode = 2;\n }\n}\n\n/**\n * A file's measured line count includes the empty line after a final line terminator, which is\n * not a line of the file, so file-level violations end at the last line that exists.\n */\nasync function endFileViolationsAtLastLine(violations: readonly Violation[], root: string): Promise<void> {\n for (const violation of violations.filter(({ kind }) => kind === 'file')) {\n const content = await readFile(path.join(root, violation.file), 'utf8');\n if (/[\\n\\r]$/u.test(content)) {\n violation.endLine = Math.max(violation.endLine - 1, 1);\n }\n }\n}\n\n/**\n * Inside a git repository, the scope is the repository's git-visible files under the target, and\n * duplication is detected against all of them; `base` only narrows the scope to what changed.\n * Outside a repository, where there is no change to narrow to, the target directory is walked.\n */\nasync function scanScope(\n resolvedTarget: string,\n base: string | undefined,\n options: ResolvedOptions\n): Promise<CheckScope> {\n const canonicalTarget = await realpath(resolvedTarget);\n const targetStat = await stat(canonicalTarget);\n const targetFile = targetStat.isFile() ? canonicalTarget : undefined;\n if (targetFile && !getLanguage(targetFile, options, true)) {\n throw new Error(`${path.basename(targetFile)}: unsupported file type`);\n }\n let repoRoot;\n try {\n repoRoot = await realpath(await resolveRepoRoot(targetFile ? path.dirname(canonicalTarget) : canonicalTarget));\n } catch (error) {\n if (base !== undefined) {\n throw error;\n }\n return await scanDirectoryWalk(canonicalTarget, options);\n }\n return await scanRepository({ repoRoot, canonicalTarget, targetFile }, base, options);\n}\n\nasync function scanDirectoryWalk(canonicalTarget: string, options: ResolvedOptions): Promise<CheckScope> {\n const searchDirectory = await configSearchDirectory(canonicalTarget);\n const scan = await scanTarget(canonicalTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n const crossFileDuplication = measureDuplication(scan, options);\n return {\n files: scan.files.map(({ file, metrics }) => ({ file: formatPath(file, scan.displayRoot), metrics })),\n crossFileDuplication,\n errors: scan.errors,\n warnings: scan.warnings,\n root: scan.displayRoot,\n };\n}\n\ninterface RepositoryTarget {\n repoRoot: string;\n canonicalTarget: string;\n /** Set when the target is a file, which is measured even when git ignores it or it is excluded. */\n targetFile?: string;\n}\n\n/**\n * Measures every git-visible file (tracked or untracked non-ignored) of the repository, so that\n * code copied from outside the target is detected as duplication, and keeps the files under the\n * target: all of them or, with `base`, the changed ones with their line hunks.\n */\nasync function scanRepository(\n { repoRoot, canonicalTarget, targetFile }: RepositoryTarget,\n base: string | undefined,\n options: ResolvedOptions\n): Promise<CheckScope> {\n // Only git-visible files are measured, so a directory git ignores would pass as a check of no\n // files. A directory git lists but whose sources it ignores is not told apart from a repository\n // or package whose only sources are ignored build output, which must pass.\n if (targetFile === undefined && canonicalTarget !== repoRoot && (await isIgnored(repoRoot, canonicalTarget))) {\n throw new Error(\n `${formatPath(canonicalTarget, repoRoot)}: git ignores this directory, so no file in it is checked; target a file to check it regardless`\n );\n }\n const repositoryFiles = await listRepositoryFiles(repoRoot);\n const explicitFiles = new Set(targetFile === undefined ? [] : [targetFile]);\n const unlistedTarget =\n targetFile !== undefined && !repositoryFiles.has(formatPath(targetFile, repoRoot))\n ? formatPath(targetFile, repoRoot)\n : undefined;\n if (unlistedTarget !== undefined) {\n repositoryFiles.add(unlistedTarget);\n }\n const exclusion = keepPaths(await loadRepositoryExclusion(repoRoot, repositoryFiles, options.exclude), explicitFiles);\n // Scan errors start with the file or directory they concern.\n const targetPath = formatPath(canonicalTarget, repoRoot);\n const scan = await scanListedFiles(\n repoRoot,\n repositoryFiles,\n { ...options, loadExclusion: () => Promise.resolve(exclusion) },\n explicitFiles,\n targetFile === undefined && canonicalTarget !== repoRoot ? targetPath : ''\n );\n const crossFileDuplication = measureDuplication(scan, options);\n const isInTarget = (relativePath: string): boolean =>\n isWithinDirectory(path.join(repoRoot, relativePath), canonicalTarget);\n const measuredFiles = scan.files.map(({ file, metrics }) => ({ file: formatPath(file, repoRoot), metrics }));\n let files = measuredFiles.filter(({ file }) => isInTarget(file));\n let isCovered = (error: string): boolean =>\n canonicalTarget === repoRoot || error.startsWith(`${targetPath}/`) || error.startsWith(`${targetPath}:`);\n let mergeBase;\n if (base !== undefined) {\n mergeBase = await resolveMergeBase(repoRoot, base);\n const changedFiles = await listChangedFiles(repoRoot, mergeBase);\n // A targeted file git does not list has no diff; all of it counts as added.\n if (unlistedTarget !== undefined) {\n changedFiles.push({ status: 'added', headPath: unlistedTarget });\n }\n const changesInTarget = changedFiles.filter(({ status, headPath }) => status !== 'deleted' && isInTarget(headPath));\n files = await attachHunks(measuredFiles, changesInTarget, repoRoot, mergeBase);\n isCovered = (error) => changesInTarget.some(({ headPath }) => error.startsWith(`${headPath}:`));\n }\n return { files, crossFileDuplication, ...partitionErrors(scan, isCovered), root: repoRoot, mergeBase };\n}\n\n/** Only a failure on a file the check covers leaves it incomplete; the others are warnings. */\nfunction partitionErrors(\n scan: ScanResult,\n isCovered: (error: string) => boolean\n): Pick<CheckScope, 'errors' | 'warnings'> {\n return {\n errors: scan.errors.filter((error) => isCovered(error)),\n warnings: [...scan.warnings, ...scan.errors.filter((error) => !isCovered(error))],\n };\n}\n\n/** The measured changed files with their line hunks against the merge-base. */\nasync function attachHunks(\n measuredFiles: readonly CheckedFile[],\n changedFiles: readonly ChangedFile[],\n repoRoot: string,\n mergeBase: string\n): Promise<CheckedFile[]> {\n const metricsByPath = new Map(measuredFiles.map(({ file, metrics }) => [file, metrics]));\n const files = await mapConcurrently(changedFiles, os.availableParallelism() * 2, async (changed) => {\n const metrics = metricsByPath.get(changed.headPath);\n // A changed file the scan left out (unsupported, excluded, or failed) has nothing to check.\n if (metrics === undefined) {\n return [];\n }\n const wholeFile: LineHunk = { headStart: 1, headCount: metrics.lines.total };\n const hunks =\n changed.basePath === undefined\n ? [wholeFile]\n : await listLineHunks(repoRoot, mergeBase, changed.basePath, changed.headPath);\n if (metrics.language === 'python' && hunks.some(({ headCount }) => headCount === 0)) {\n anchorDeletionsToCode(hunks, await readFile(path.join(repoRoot, changed.headPath), 'utf8'));\n }\n return [{ file: changed.headPath, metrics, hunks }];\n });\n return files.flat();\n}\n\n/**\n * Moves each pure deletion up to the last code line before it. A Python function ends at its last\n * statement, so the blank and comment-only lines left above a deleted tail lie outside the span the\n * deletion shortened; anchored to that statement, the deletion counts as touching the function.\n */\nfunction anchorDeletionsToCode(hunks: LineHunk[], content: string): void {\n const lines = content.split(/\\r\\n|\\n|\\r/u);\n for (const hunk of hunks) {\n if (hunk.headCount > 0) continue;\n while (hunk.headStart > 0 && /^\\s*(?:#.*)?$/u.test(lines[hunk.headStart - 1] ?? '')) {\n hunk.headStart -= 1;\n }\n }\n}\n\n/** A run-wide failure (a missing target or native addon) leaves nothing to check. */\nfunction measureDuplication(scan: ScanResult, options: ResolvedOptions): CrossFileDuplicationMetrics | undefined {\n if (scan.fatalError) {\n throw new Error(scan.fatalError);\n }\n addCrossFileDuplication(scan, options);\n return scan.crossFileDuplication;\n}\n\nfunction printTextReport(cliOptions: CheckCliOptions, scope: CheckScope, result: CheckResult): void {\n const { violations } = result;\n const checked = `${scope.files.length} ${cliOptions.base === undefined ? '' : 'changed '}files, ${result.checkedFunctionCount} functions checked${describeBase(cliOptions, scope)}`;\n if (scope.errors.length > 0) {\n // Unmeasured files were not checked, so \"no violations\" would be vacuous; never claim a pass.\n writeStdout(\n `Check could not complete: ${scope.errors.length} measurement failures (details on stderr); ${describeCounts(violations)} in the measured files (${checked}).\\n`\n );\n } else if (violations.length === 0) {\n writeStdout(`No threshold violations: ${checked}.\\n`);\n } else {\n writeStdout(`Threshold violations: ${describeCounts(violations)} (${checked}).\\n`);\n }\n for (const violation of violations) {\n writeStdout(`${formatViolation(violation)}\\n`);\n }\n\n const violatedMetrics = new Set(violations.flatMap(({ exceeded }) => exceeded.map(({ metric }) => metric)));\n const violatedThresholds = thresholds.filter((threshold) => violatedMetrics.has(metricNameOf(threshold)));\n if (violatedThresholds.length > 0) {\n writeStdout('\\nHow to fix:\\n');\n for (const { label, hint } of violatedThresholds) {\n writeStdout(`- ${label}: ${hint}\\n`);\n }\n }\n\n for (const warning of scope.warnings) {\n writeStderr(`Warning: ${warning}\\n`);\n }\n for (const error of scope.errors) {\n writeStderr(`Error: ${error}\\n`);\n }\n}\n\nfunction describeBase(cliOptions: CheckCliOptions, scope: CheckScope): string {\n return scope.mergeBase === undefined ? '' : `; base ${cliOptions.base}, merge-base ${scope.mergeBase.slice(0, 12)}`;\n}\n\nfunction describeCounts(violations: readonly Violation[]): string {\n const counts = countViolations(violations);\n return `${counts.violationCount} violations (${counts.functionViolationCount} functions, ${counts.fileViolationCount} files, ${counts.duplicationViolationCount} duplicated blocks)`;\n}\n\nfunction countViolations(violations: readonly Violation[]): {\n violationCount: number;\n functionViolationCount: number;\n fileViolationCount: number;\n duplicationViolationCount: number;\n} {\n const count = (kind: Violation['kind']): number => violations.filter((violation) => violation.kind === kind).length;\n return {\n violationCount: violations.length,\n functionViolationCount: count('function'),\n fileViolationCount: count('file'),\n duplicationViolationCount: count('duplication'),\n };\n}\n\n/** One violation as a single line: the location, then every limit it exceeds. */\nfunction formatViolation(violation: Violation): string {\n const labelByMetric = new Map(thresholds.map((threshold) => [metricNameOf(threshold), threshold.label]));\n const exceeded = violation.exceeded\n .map(\n ({ metric, value, limit }) =>\n // Halstead values are fractional; rounding up keeps a violating value above the printed limit.\n `${labelByMetric.get(metric)} ${Math.ceil(value * 10) / 10} (${violation.kind === 'duplication' ? '<' : '<='} ${limit})`\n )\n .join(', ');\n switch (violation.kind) {\n case 'file': {\n return `${violation.file}: ${exceeded}`;\n }\n case 'function': {\n // A computed name can span lines in the source; the report keeps one line per function.\n return `${formatLocation(violation)} ${violation.name?.replaceAll(/\\s*[\\n\\r]\\s*/gu, ' ')}: ${exceeded}`;\n }\n case 'duplication': {\n const partners = (violation.partners ?? []).map((partner) => formatLocation(partner));\n const listed = [...partners.slice(0, maxListedPartners), ...(partners.length > maxListedPartners ? ['...'] : [])];\n return `${formatLocation(violation)}: ${exceeded}${listed.length > 0 ? `, also at ${listed.join(', ')}` : ''}`;\n }\n }\n}\n\nfunction formatLocation({ file, startLine, endLine }: BlockLocation): string {\n return `${file}:${startLine}-${endLine}`;\n}\n\nfunction printJsonReport(cliOptions: CheckCliOptions, scope: CheckScope, result: CheckResult): void {\n writeStdout(\n JSON.stringify(\n {\n passed: result.violations.length === 0 && scope.errors.length === 0,\n base: cliOptions.base,\n mergeBase: scope.mergeBase,\n summary: {\n ...countViolations(result.violations),\n checkedFileCount: scope.files.length,\n checkedFunctionCount: result.checkedFunctionCount,\n },\n violations: result.violations,\n errors: scope.errors,\n warnings: scope.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n"],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ import { type CliOptions } from './cliConfig.js';
2
+ import { type Limits } from './thresholds.js';
3
+ /** Raw options of the `check` subcommand; every field is undefined unless the flag was passed. */
4
+ export interface CheckCliOptions extends CliOptions {
5
+ base?: string;
6
+ }
7
+ /**
8
+ * Reports every exceeded threshold of the target or, with `base`, of what the working tree changed
9
+ * under it since the merge-base with that ref. Exit codes: 0 no violations, 1 violations, 2 files the check
10
+ * covers could not be measured.
11
+ */
12
+ export declare function runCheckCommand(target: string, cliOptions: CheckCliOptions, cliLimits: Limits): Promise<void>;
@@ -0,0 +1,5 @@
1
+ import{metricNameOf as e,resolveLimits as t,thresholds as n}from"./thresholds.js";import{loadConfig as r,resolveOptions as i}from"./cliConfig.js";import{isIgnored as a,listChangedFiles as o,listLineHunks as s,listRepositoryFiles as c,resolveMergeBase as l,resolveRepoRoot as u}from"./git.js";import{keepPaths as d,loadExclusion as f,loadRepositoryExclusion as p}from"./exclusion.js";import{addCrossFileDuplication as m,configSearchDirectory as h,formatPath as g,getLanguage as _,isWithinDirectory as v,resolveTarget as y,scanListedFiles as b,scanTarget as x,writeStderr as S,writeStdout as C}from"./scan.js";import{checkThresholds as w}from"./thresholdCheck.js";import{getErrorMessage as T,mapConcurrently as E}from"@willbooster/shared-lib";import{readFile as D,realpath as O,stat as k}from"node:fs/promises";import A from"node:path";import j from"node:os";async function M(e,n,a){try{let o=y(e),s=await r(n.config,await h(o)),c=i(n,s),l=await P(o,n.base,c),u=w(l.files,l.crossFileDuplication,t(a,s.config.thresholds));await N(u.violations,l.root),c.json?q(n,l,u):V(n,l,u),l.errors.length>0?process.exitCode=2:u.violations.length>0&&(process.exitCode=1)}catch(e){S(`Error: ${T(e)}\n`),process.exitCode=2}}async function N(e,t){for(let n of e.filter(({kind:e})=>e===`file`)){let e=await D(A.join(t,n.file),`utf8`);/[\n\r]$/u.test(e)&&(n.endLine=Math.max(n.endLine-1,1))}}async function P(e,t,n){let r=await O(e),i=(await k(r)).isFile()?r:void 0;if(i&&!_(i,n,!0))throw Error(`${A.basename(i)}: unsupported file type`);let a;try{a=await O(await u(i?A.dirname(r):r))}catch(e){if(t!==void 0)throw e;return await F(r,n)}return await I({repoRoot:a,canonicalTarget:r,targetFile:i},t,n)}async function F(e,t){let n=await h(e),r=await x(e,{...t,loadExclusion:e=>f(n,t.exclude,e)}),i=B(r,t);return{files:r.files.map(({file:e,metrics:t})=>({file:g(e,r.displayRoot),metrics:t})),crossFileDuplication:i,errors:r.errors,warnings:r.warnings,root:r.displayRoot}}async function I({repoRoot:e,canonicalTarget:t,targetFile:n},r,i){if(n===void 0&&t!==e&&await a(e,t))throw Error(`${g(t,e)}: git ignores this directory, so no file in it is checked; target a file to check it regardless`);let s=await c(e),u=new Set(n===void 0?[]:[n]),f=n!==void 0&&!s.has(g(n,e))?g(n,e):void 0;f!==void 0&&s.add(f);let m=d(await p(e,s,i.exclude),u),h=g(t,e),_=await b(e,s,{...i,loadExclusion:()=>Promise.resolve(m)},u,n===void 0&&t!==e?h:``),y=B(_,i),x=n=>v(A.join(e,n),t),S=_.files.map(({file:t,metrics:n})=>({file:g(t,e),metrics:n})),C=S.filter(({file:e})=>x(e)),w=n=>t===e||n.startsWith(`${h}/`)||n.startsWith(`${h}:`),T;if(r!==void 0){T=await l(e,r);let t=await o(e,T);f!==void 0&&t.push({status:`added`,headPath:f});let n=t.filter(({status:e,headPath:t})=>e!==`deleted`&&x(t));C=await R(S,n,e,T),w=e=>n.some(({headPath:t})=>e.startsWith(`${t}:`))}return{files:C,crossFileDuplication:y,...L(_,w),root:e,mergeBase:T}}function L(e,t){return{errors:e.errors.filter(e=>t(e)),warnings:[...e.warnings,...e.errors.filter(e=>!t(e))]}}async function R(e,t,n,r){let i=new Map(e.map(({file:e,metrics:t})=>[e,t]));return(await E(t,j.availableParallelism()*2,async e=>{let t=i.get(e.headPath);if(t===void 0)return[];let a={headStart:1,headCount:t.lines.total},o=e.basePath===void 0?[a]:await s(n,r,e.basePath,e.headPath);return t.language===`python`&&o.some(({headCount:e})=>e===0)&&z(o,await D(A.join(n,e.headPath),`utf8`)),[{file:e.headPath,metrics:t,hunks:o}]})).flat()}function z(e,t){let n=t.split(/\r\n|\n|\r/u);for(let t of e)if(!(t.headCount>0))for(;t.headStart>0&&/^\s*(?:#.*)?$/u.test(n[t.headStart-1]??``);)--t.headStart}function B(e,t){if(e.fatalError)throw Error(e.fatalError);return m(e,t),e.crossFileDuplication}function V(t,r,i){let{violations:a}=i,o=`${r.files.length} ${t.base===void 0?``:`changed `}files, ${i.checkedFunctionCount} functions checked${H(t,r)}`;r.errors.length>0?C(`Check could not complete: ${r.errors.length} measurement failures (details on stderr); ${U(a)} in the measured files (${o}).\n`):a.length===0?C(`No threshold violations: ${o}.\n`):C(`Threshold violations: ${U(a)} (${o}).\n`);for(let e of a)C(`${G(e)}\n`);let s=new Set(a.flatMap(({exceeded:e})=>e.map(({metric:e})=>e))),c=n.filter(t=>s.has(e(t)));if(c.length>0){C(`
2
+ How to fix:
3
+ `);for(let{label:e,hint:t}of c)C(`- ${e}: ${t}\n`)}for(let e of r.warnings)S(`Warning: ${e}\n`);for(let e of r.errors)S(`Error: ${e}\n`)}function H(e,t){return t.mergeBase===void 0?``:`; base ${e.base}, merge-base ${t.mergeBase.slice(0,12)}`}function U(e){let t=W(e);return`${t.violationCount} violations (${t.functionViolationCount} functions, ${t.fileViolationCount} files, ${t.duplicationViolationCount} duplicated blocks)`}function W(e){let t=t=>e.filter(e=>e.kind===t).length;return{violationCount:e.length,functionViolationCount:t(`function`),fileViolationCount:t(`file`),duplicationViolationCount:t(`duplication`)}}function G(t){let r=new Map(n.map(t=>[e(t),t.label])),i=t.exceeded.map(({metric:e,value:n,limit:i})=>`${r.get(e)} ${Math.ceil(n*10)/10} (${t.kind===`duplication`?`<`:`<=`} ${i})`).join(`, `);switch(t.kind){case`file`:return`${t.file}: ${i}`;case`function`:return`${K(t)} ${t.name?.replaceAll(/\s*[\n\r]\s*/gu,` `)}: ${i}`;case`duplication`:{let e=(t.partners??[]).map(e=>K(e)),n=[...e.slice(0,3),...e.length>3?[`...`]:[]];return`${K(t)}: ${i}${n.length>0?`, also at ${n.join(`, `)}`:``}`}}}function K({file:e,startLine:t,endLine:n}){return`${e}:${t}-${n}`}function q(e,t,n){C(JSON.stringify({passed:n.violations.length===0&&t.errors.length===0,base:e.base,mergeBase:t.mergeBase,summary:{...W(n.violations),checkedFileCount:t.files.length,checkedFunctionCount:n.checkedFunctionCount},violations:n.violations,errors:t.errors,warnings:t.warnings},void 0,2)+`
4
+ `)}export{M as runCheckCommand};
5
+ //# sourceMappingURL=checkCommand.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"checkCommand.js","names":[],"sources":["../src/checkCommand.ts"],"sourcesContent":["import { readFile, realpath, stat } from 'node:fs/promises';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { getErrorMessage, mapConcurrently } from '@willbooster/shared-lib';\nimport { loadConfig, resolveOptions, type CliOptions, type ResolvedOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { keepPaths, loadExclusion, loadRepositoryExclusion } from './exclusion.js';\nimport {\n isIgnored,\n listChangedFiles,\n listLineHunks,\n listRepositoryFiles,\n resolveMergeBase,\n resolveRepoRoot,\n type ChangedFile,\n type LineHunk,\n} from './git.js';\nimport {\n addCrossFileDuplication,\n configSearchDirectory,\n formatPath,\n getLanguage,\n isWithinDirectory,\n resolveTarget,\n scanListedFiles,\n scanTarget,\n writeStderr,\n writeStdout,\n type ScanResult,\n} from './scan.js';\nimport {\n checkThresholds,\n type BlockLocation,\n type CheckedFile,\n type CheckResult,\n type Violation,\n} from './thresholdCheck.js';\nimport { metricNameOf, resolveLimits, thresholds, type Limits } from './thresholds.js';\n\n/** Raw options of the `check` subcommand; every field is undefined unless the flag was passed. */\nexport interface CheckCliOptions extends CliOptions {\n base?: string;\n}\n\n/** The measured files a check covers and the duplication they take part in. */\ninterface CheckScope {\n files: CheckedFile[];\n crossFileDuplication?: CrossFileDuplicationMetrics;\n /** Measurement failures of files the check covers. */\n errors: string[];\n warnings: string[];\n /** The directory the files' paths are relative to. */\n root: string;\n mergeBase?: string;\n}\n\n/** Caps how many partner locations a duplicated block lists so the report stays scannable. */\nconst maxListedPartners = 3;\n\n/**\n * Reports every exceeded threshold of the target or, with `base`, of what the working tree changed\n * under it since the merge-base with that ref. Exit codes: 0 no violations, 1 violations, 2 files the check\n * covers could not be measured.\n */\nexport async function runCheckCommand(target: string, cliOptions: CheckCliOptions, cliLimits: Limits): Promise<void> {\n try {\n const resolvedTarget = resolveTarget(target);\n const config = await loadConfig(cliOptions.config, await configSearchDirectory(resolvedTarget));\n const options = resolveOptions(cliOptions, config);\n const scope = await scanScope(resolvedTarget, cliOptions.base, options);\n const result = checkThresholds(\n scope.files,\n scope.crossFileDuplication,\n resolveLimits(cliLimits, config.config.thresholds)\n );\n await endFileViolationsAtLastLine(result.violations, scope.root);\n\n if (options.json) {\n printJsonReport(cliOptions, scope, result);\n } else {\n printTextReport(cliOptions, scope, result);\n }\n if (scope.errors.length > 0) {\n process.exitCode = 2;\n } else if (result.violations.length > 0) {\n process.exitCode = 1;\n }\n } catch (error) {\n writeStderr(`Error: ${getErrorMessage(error)}\\n`);\n process.exitCode = 2;\n }\n}\n\n/**\n * A file's measured line count includes the empty line after a final line terminator, which is\n * not a line of the file, so file-level violations end at the last line that exists.\n */\nasync function endFileViolationsAtLastLine(violations: readonly Violation[], root: string): Promise<void> {\n for (const violation of violations.filter(({ kind }) => kind === 'file')) {\n const content = await readFile(path.join(root, violation.file), 'utf8');\n if (/[\\n\\r]$/u.test(content)) {\n violation.endLine = Math.max(violation.endLine - 1, 1);\n }\n }\n}\n\n/**\n * Inside a git repository, the scope is the repository's git-visible files under the target, and\n * duplication is detected against all of them; `base` only narrows the scope to what changed.\n * Outside a repository, where there is no change to narrow to, the target directory is walked.\n */\nasync function scanScope(\n resolvedTarget: string,\n base: string | undefined,\n options: ResolvedOptions\n): Promise<CheckScope> {\n const canonicalTarget = await realpath(resolvedTarget);\n const targetStat = await stat(canonicalTarget);\n const targetFile = targetStat.isFile() ? canonicalTarget : undefined;\n if (targetFile && !getLanguage(targetFile, options, true)) {\n throw new Error(`${path.basename(targetFile)}: unsupported file type`);\n }\n let repoRoot;\n try {\n repoRoot = await realpath(await resolveRepoRoot(targetFile ? path.dirname(canonicalTarget) : canonicalTarget));\n } catch (error) {\n if (base !== undefined) {\n throw error;\n }\n return await scanDirectoryWalk(canonicalTarget, options);\n }\n return await scanRepository({ repoRoot, canonicalTarget, targetFile }, base, options);\n}\n\nasync function scanDirectoryWalk(canonicalTarget: string, options: ResolvedOptions): Promise<CheckScope> {\n const searchDirectory = await configSearchDirectory(canonicalTarget);\n const scan = await scanTarget(canonicalTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n const crossFileDuplication = measureDuplication(scan, options);\n return {\n files: scan.files.map(({ file, metrics }) => ({ file: formatPath(file, scan.displayRoot), metrics })),\n crossFileDuplication,\n errors: scan.errors,\n warnings: scan.warnings,\n root: scan.displayRoot,\n };\n}\n\ninterface RepositoryTarget {\n repoRoot: string;\n canonicalTarget: string;\n /** Set when the target is a file, which is measured even when git ignores it or it is excluded. */\n targetFile?: string;\n}\n\n/**\n * Measures every git-visible file (tracked or untracked non-ignored) of the repository, so that\n * code copied from outside the target is detected as duplication, and keeps the files under the\n * target: all of them or, with `base`, the changed ones with their line hunks.\n */\nasync function scanRepository(\n { repoRoot, canonicalTarget, targetFile }: RepositoryTarget,\n base: string | undefined,\n options: ResolvedOptions\n): Promise<CheckScope> {\n // Only git-visible files are measured, so a directory git ignores would pass as a check of no\n // files. A directory git lists but whose sources it ignores is not told apart from a repository\n // or package whose only sources are ignored build output, which must pass.\n if (targetFile === undefined && canonicalTarget !== repoRoot && (await isIgnored(repoRoot, canonicalTarget))) {\n throw new Error(\n `${formatPath(canonicalTarget, repoRoot)}: git ignores this directory, so no file in it is checked; target a file to check it regardless`\n );\n }\n const repositoryFiles = await listRepositoryFiles(repoRoot);\n const explicitFiles = new Set(targetFile === undefined ? [] : [targetFile]);\n const unlistedTarget =\n targetFile !== undefined && !repositoryFiles.has(formatPath(targetFile, repoRoot))\n ? formatPath(targetFile, repoRoot)\n : undefined;\n if (unlistedTarget !== undefined) {\n repositoryFiles.add(unlistedTarget);\n }\n const exclusion = keepPaths(await loadRepositoryExclusion(repoRoot, repositoryFiles, options.exclude), explicitFiles);\n // Scan errors start with the file or directory they concern.\n const targetPath = formatPath(canonicalTarget, repoRoot);\n const scan = await scanListedFiles(\n repoRoot,\n repositoryFiles,\n { ...options, loadExclusion: () => Promise.resolve(exclusion) },\n explicitFiles,\n targetFile === undefined && canonicalTarget !== repoRoot ? targetPath : ''\n );\n const crossFileDuplication = measureDuplication(scan, options);\n const isInTarget = (relativePath: string): boolean =>\n isWithinDirectory(path.join(repoRoot, relativePath), canonicalTarget);\n const measuredFiles = scan.files.map(({ file, metrics }) => ({ file: formatPath(file, repoRoot), metrics }));\n let files = measuredFiles.filter(({ file }) => isInTarget(file));\n let isCovered = (error: string): boolean =>\n canonicalTarget === repoRoot || error.startsWith(`${targetPath}/`) || error.startsWith(`${targetPath}:`);\n let mergeBase;\n if (base !== undefined) {\n mergeBase = await resolveMergeBase(repoRoot, base);\n const changedFiles = await listChangedFiles(repoRoot, mergeBase);\n // A targeted file git does not list has no diff; all of it counts as added.\n if (unlistedTarget !== undefined) {\n changedFiles.push({ status: 'added', headPath: unlistedTarget });\n }\n const changesInTarget = changedFiles.filter(({ status, headPath }) => status !== 'deleted' && isInTarget(headPath));\n files = await attachHunks(measuredFiles, changesInTarget, repoRoot, mergeBase);\n isCovered = (error) => changesInTarget.some(({ headPath }) => error.startsWith(`${headPath}:`));\n }\n return { files, crossFileDuplication, ...partitionErrors(scan, isCovered), root: repoRoot, mergeBase };\n}\n\n/** Only a failure on a file the check covers leaves it incomplete; the others are warnings. */\nfunction partitionErrors(\n scan: ScanResult,\n isCovered: (error: string) => boolean\n): Pick<CheckScope, 'errors' | 'warnings'> {\n return {\n errors: scan.errors.filter((error) => isCovered(error)),\n warnings: [...scan.warnings, ...scan.errors.filter((error) => !isCovered(error))],\n };\n}\n\n/** The measured changed files with their line hunks against the merge-base. */\nasync function attachHunks(\n measuredFiles: readonly CheckedFile[],\n changedFiles: readonly ChangedFile[],\n repoRoot: string,\n mergeBase: string\n): Promise<CheckedFile[]> {\n const metricsByPath = new Map(measuredFiles.map(({ file, metrics }) => [file, metrics]));\n const files = await mapConcurrently(changedFiles, os.availableParallelism() * 2, async (changed) => {\n const metrics = metricsByPath.get(changed.headPath);\n // A changed file the scan left out (unsupported, excluded, or failed) has nothing to check.\n if (metrics === undefined) {\n return [];\n }\n const wholeFile: LineHunk = { headStart: 1, headCount: metrics.lines.total };\n const hunks =\n changed.basePath === undefined\n ? [wholeFile]\n : await listLineHunks(repoRoot, mergeBase, changed.basePath, changed.headPath);\n if (metrics.language === 'python' && hunks.some(({ headCount }) => headCount === 0)) {\n anchorDeletionsToCode(hunks, await readFile(path.join(repoRoot, changed.headPath), 'utf8'));\n }\n return [{ file: changed.headPath, metrics, hunks }];\n });\n return files.flat();\n}\n\n/**\n * Moves each pure deletion up to the last code line before it. A Python function ends at its last\n * statement, so the blank and comment-only lines left above a deleted tail lie outside the span the\n * deletion shortened; anchored to that statement, the deletion counts as touching the function.\n */\nfunction anchorDeletionsToCode(hunks: LineHunk[], content: string): void {\n const lines = content.split(/\\r\\n|\\n|\\r/u);\n for (const hunk of hunks) {\n if (hunk.headCount > 0) continue;\n while (hunk.headStart > 0 && /^\\s*(?:#.*)?$/u.test(lines[hunk.headStart - 1] ?? '')) {\n hunk.headStart -= 1;\n }\n }\n}\n\n/** A run-wide failure (a missing target or native addon) leaves nothing to check. */\nfunction measureDuplication(scan: ScanResult, options: ResolvedOptions): CrossFileDuplicationMetrics | undefined {\n if (scan.fatalError) {\n throw new Error(scan.fatalError);\n }\n addCrossFileDuplication(scan, options);\n return scan.crossFileDuplication;\n}\n\nfunction printTextReport(cliOptions: CheckCliOptions, scope: CheckScope, result: CheckResult): void {\n const { violations } = result;\n const checked = `${scope.files.length} ${cliOptions.base === undefined ? '' : 'changed '}files, ${result.checkedFunctionCount} functions checked${describeBase(cliOptions, scope)}`;\n if (scope.errors.length > 0) {\n // Unmeasured files were not checked, so \"no violations\" would be vacuous; never claim a pass.\n writeStdout(\n `Check could not complete: ${scope.errors.length} measurement failures (details on stderr); ${describeCounts(violations)} in the measured files (${checked}).\\n`\n );\n } else if (violations.length === 0) {\n writeStdout(`No threshold violations: ${checked}.\\n`);\n } else {\n writeStdout(`Threshold violations: ${describeCounts(violations)} (${checked}).\\n`);\n }\n for (const violation of violations) {\n writeStdout(`${formatViolation(violation)}\\n`);\n }\n\n const violatedMetrics = new Set(violations.flatMap(({ exceeded }) => exceeded.map(({ metric }) => metric)));\n const violatedThresholds = thresholds.filter((threshold) => violatedMetrics.has(metricNameOf(threshold)));\n if (violatedThresholds.length > 0) {\n writeStdout('\\nHow to fix:\\n');\n for (const { label, hint } of violatedThresholds) {\n writeStdout(`- ${label}: ${hint}\\n`);\n }\n }\n\n for (const warning of scope.warnings) {\n writeStderr(`Warning: ${warning}\\n`);\n }\n for (const error of scope.errors) {\n writeStderr(`Error: ${error}\\n`);\n }\n}\n\nfunction describeBase(cliOptions: CheckCliOptions, scope: CheckScope): string {\n return scope.mergeBase === undefined ? '' : `; base ${cliOptions.base}, merge-base ${scope.mergeBase.slice(0, 12)}`;\n}\n\nfunction describeCounts(violations: readonly Violation[]): string {\n const counts = countViolations(violations);\n return `${counts.violationCount} violations (${counts.functionViolationCount} functions, ${counts.fileViolationCount} files, ${counts.duplicationViolationCount} duplicated blocks)`;\n}\n\nfunction countViolations(violations: readonly Violation[]): {\n violationCount: number;\n functionViolationCount: number;\n fileViolationCount: number;\n duplicationViolationCount: number;\n} {\n const count = (kind: Violation['kind']): number => violations.filter((violation) => violation.kind === kind).length;\n return {\n violationCount: violations.length,\n functionViolationCount: count('function'),\n fileViolationCount: count('file'),\n duplicationViolationCount: count('duplication'),\n };\n}\n\n/** One violation as a single line: the location, then every limit it exceeds. */\nfunction formatViolation(violation: Violation): string {\n const labelByMetric = new Map(thresholds.map((threshold) => [metricNameOf(threshold), threshold.label]));\n const exceeded = violation.exceeded\n .map(\n ({ metric, value, limit }) =>\n // Halstead values are fractional; rounding up keeps a violating value above the printed limit.\n `${labelByMetric.get(metric)} ${Math.ceil(value * 10) / 10} (${violation.kind === 'duplication' ? '<' : '<='} ${limit})`\n )\n .join(', ');\n switch (violation.kind) {\n case 'file': {\n return `${violation.file}: ${exceeded}`;\n }\n case 'function': {\n // A computed name can span lines in the source; the report keeps one line per function.\n return `${formatLocation(violation)} ${violation.name?.replaceAll(/\\s*[\\n\\r]\\s*/gu, ' ')}: ${exceeded}`;\n }\n case 'duplication': {\n const partners = (violation.partners ?? []).map((partner) => formatLocation(partner));\n const listed = [...partners.slice(0, maxListedPartners), ...(partners.length > maxListedPartners ? ['...'] : [])];\n return `${formatLocation(violation)}: ${exceeded}${listed.length > 0 ? `, also at ${listed.join(', ')}` : ''}`;\n }\n }\n}\n\nfunction formatLocation({ file, startLine, endLine }: BlockLocation): string {\n return `${file}:${startLine}-${endLine}`;\n}\n\nfunction printJsonReport(cliOptions: CheckCliOptions, scope: CheckScope, result: CheckResult): void {\n writeStdout(\n JSON.stringify(\n {\n passed: result.violations.length === 0 && scope.errors.length === 0,\n base: cliOptions.base,\n mergeBase: scope.mergeBase,\n summary: {\n ...countViolations(result.violations),\n checkedFileCount: scope.files.length,\n checkedFunctionCount: result.checkedFunctionCount,\n },\n violations: result.violations,\n errors: scope.errors,\n warnings: scope.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n"],"mappings":"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"}
package/dist/cli.cjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- "use strict";const e=require("./cliConfig.cjs"),t=require("./exclusion.cjs"),n=require("./scan.cjs"),r=require("./diffCommand.cjs");let i=require("commander");o().catch(e=>{n.writeStderr(`Error: ${n.formatError(e)}\n`),process.exitCode=1});function a(t){return t.option(`--config <path>`,`config file to use instead of the auto-detected ${e.configFileName}`).option(`--duplication-min-tokens <number>`,`minimum normalized token count for a duplicate region (default 40)`,_).option(`--duplication-max-gap-tokens <number>`,`maximum token gap merged into one gapped clone group; 0 disables merging (default 30)`,g).option(`--duplication-min-similarity-percent <number>`,`minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)`,h).option(`--include-tests`,`include test files and test directories`).option(`--json`,`print JSON output`)}async function o(){let o=a(new i.Command().name(`code-gauge`).description(`Rank the files of a project by refactoring priority.`).argument(`[target]`,`file or directory to measure`,`.`)).option(`--top <number>`,`number of top-ranked files to report (default: 10)`,_).option(`--fail-on-error`,`exit with code 1 when files or directories cannot be scanned`);o.action(async(r,i)=>{let a=n.resolveTarget(r),o=await e.loadConfig(i.config,await n.configSearchDirectory(a)),c=e.resolveOptions(i,o),l=await n.configSearchDirectory(a),u=await n.scanTarget(a,{...c,loadExclusion:e=>t.loadExclusion(l,c.exclude,e)});n.addCrossFileDuplication(u,c);let p=s(u,c.top);c.json?d(u,p,c):f(a,u,p,c),(u.fatalError||c.failOnError&&u.errors.length>0)&&(process.exitCode=1)}),a(o.command(`diff`).description(`Gate the working tree against a base ref: report only metric regressions in changed files (exit 1 on violations)`).argument(`[target]`,`directory whose changed files are gated`,`.`).requiredOption(`--base <ref>`,`base git ref; changes are measured against its merge-base with HEAD`)).option(`--full`,`also print the passing gate values of every checked function and file`).action(async(e,t,n)=>{await r.runDiffCommand(e,n.optsWithGlobals())}),await o.parseAsync()}function s(e,t){let r=e.files.map(({file:t,metrics:r})=>{let i=n.formatPath(t,e.displayRoot),a=n.collectDuplicatedLineNumbers(r,e.crossFileDuplication,i);return{file:i,worstFunction:u(r.functions),duplicatedLineCount:a.size,duplicatedLineRatio:r.lines.code===0?0:a.size/r.lines.code,crossFilePartners:[],ncss:r.ncssCount,codeLines:r.lines.code}}),i=l(r.map(e=>e.worstFunction?.cognitiveComplexity??0)),a=l(r.map(e=>e.duplicatedLineCount)),o=l(r.map(e=>e.ncss)),s=r.map(e=>({...e,score:i(e.worstFunction?.cognitiveComplexity??0)+a(e.duplicatedLineCount)+o(e.ncss)})).toSorted((e,t)=>t.score-e.score||t.ncss-e.ncss||e.file.localeCompare(t.file));return c(s.slice(0,t),e.crossFileDuplication),s}function c(e,t){if(!t)return;let n=new Map(e.map(e=>[e.file,/* @__PURE__ */ new Set]));for(let e of t.groups)for(let t of e.files){let r=n.get(t);if(r)for(let n of e.files)n!==t&&r.add(n)}for(let t of e)t.crossFilePartners=[...n.get(t.file)??[]].toSorted()}function l(e){let t=e.toSorted((e,t)=>e-t);return e=>{let n=0,r=t.length;for(;n<r;){let i=n+r>>1;t[i]<e?n=i+1:r=i}return t.length===0?0:n/t.length}}function u(e){let t;for(let n of e)(!t||n.cognitiveComplexity>t.cognitiveComplexity||n.cognitiveComplexity===t.cognitiveComplexity&&n.ncss>t.ncss)&&(t=n);if(t)return{name:t.name??`<anonymous>`,startLine:t.startLine,endLine:t.endLine,cognitiveComplexity:t.cognitiveComplexity,ncss:t.ncss,nestingDepth:t.nestingDepth}}function d(e,t,r){let i=t.slice(0,r.top);n.writeStdout(JSON.stringify({summary:m(e.files),totalRankedFiles:t.length,truncated:i.length<t.length,files:i,errors:e.errors,warnings:e.warnings},void 0,2)+`
3
- `)}function f(e,t,r,i){if(t.fatalError){n.writeStderr(`Error: ${t.fatalError}\n`);return}let a=m(t.files);if(n.writeStdout(`Measured ${a.fileCount} files under ${e} (code LOC ${a.linesOfCode}, NCSS ${a.ncssCount}, functions ${a.functionCount})\n`),r.length===0)n.writeStdout(`No measurable files found.
4
- `);else{let e=r.slice(0,i.top),t=r.length>e.length?` of ${r.length}`:``;n.writeStdout(`\nRefactoring candidates (top ${e.length}${t}):\n`);for(let[t,r]of e.entries())n.writeStdout(`${t+1}. ${p(r)}\n`)}if(t.warnings.length>0){n.writeStderr(`\nDegraded ${t.warnings.length} files (measured, but excluded from cross-file matching):\n`);for(let e of t.warnings.slice(0,10))n.writeStderr(`- ${e}\n`);t.warnings.length>10&&n.writeStderr(`- ... ${t.warnings.length-10} more\n`)}if(t.errors.length>0){n.writeStderr(`\nSkipped ${t.errors.length} files or directories:\n`);for(let e of t.errors.slice(0,10))n.writeStderr(`- ${e}\n`);t.errors.length>10&&n.writeStderr(`- ... ${t.errors.length-10} more\n`)}}function p(e){let t=[];if(e.worstFunction){let n=e.worstFunction;t.push(`worst function ${n.name} (L${n.startLine}-${n.endLine}) cognitive ${n.cognitiveComplexity}, NCSS ${n.ncss}, nesting ${n.nestingDepth}`)}if(e.duplicatedLineCount>0){let n=e.crossFilePartners.length>0?`, shared with ${e.crossFilePartners.slice(0,3).join(`, `)}${e.crossFilePartners.length>3?`, ...`:``}`:``;t.push(`duplicated lines ${e.duplicatedLineCount} (${Math.round(e.duplicatedLineRatio*100)}%${n})`)}return t.push(`file NCSS ${e.ncss}`),`${e.file} (score ${e.score.toFixed(2)}): ${t.join(`; `)}`}function m(e){let t=0,n=0,r=0,i=0;for(let a of e)t+=a.metrics.functions.length,n+=a.metrics.lines.code,r=Math.max(r,a.metrics.maxCognitiveComplexity),i+=a.metrics.ncssCount;return{fileCount:e.length,functionCount:t,linesOfCode:n,maxCognitiveComplexity:r,ncssCount:i}}function h(e){let t=_(e);if(t>100)throw new i.InvalidArgumentError(`Expected an integer between 1 and 100.`);return t}function g(e){if(!/^\d+$/u.test(e))throw new i.InvalidArgumentError(`Expected a non-negative integer.`);let t=Number(e);if(!Number.isSafeInteger(t)||t<0)throw new i.InvalidArgumentError(`Expected a non-negative integer.`);return t}function _(e){if(!/^[1-9]\d*$/u.test(e))throw new i.InvalidArgumentError(`Expected a positive integer.`);let t=Number(e);if(!Number.isSafeInteger(t)||t<1)throw new i.InvalidArgumentError(`Expected a positive integer.`);return t}
2
+ "use strict";const e=require("./thresholds.cjs"),t=require("./cliConfig.cjs"),n=require("./exclusion.cjs"),r=require("./scan.cjs"),i=require("./checkCommand.cjs");let a=require("@willbooster/shared-lib"),o=require("commander");s().catch(e=>{r.writeStderr(`Error: ${(0,a.getErrorMessage)(e)}\n`),process.exitCode=1});async function s(){let t=new o.Command().name(`code-gauge`).description(`Measure code metrics: rank refactoring candidates (the default command) or check thresholds.`);c(t.command(`rank`,{isDefault:!0}).description(`Rank the files of a project by refactoring priority`).argument(`[target]`,`file or directory to measure`,`.`)).option(`--top <number>`,`number of top-ranked files to report (default: 10)`,C).option(`--fail-on-error`,`exit with code 1 when files or directories cannot be scanned`).action(d);let n=c(t.command(`check`).description(`Report every exceeded threshold (exit 1 on violations, 2 when files cannot be measured); with --base, only in what the change touches`).argument(`[target]`,`file or directory to check`,`.`)).option(`--base <ref>`,`check only what the working tree changed since the merge-base of this git ref and HEAD`);for(let{key:t,defaultLimit:r}of e.thresholds)n.option(`--${u(t)} <number>`,`${l(t)} (default ${r}); "off" disables it`,b);n.exitOverride(e=>process.exit(e.exitCode===0?0:2)),n.action(async(t,n)=>{let r=Object.fromEntries(e.thresholds.flatMap(({key:e})=>n[e]===void 0?[]:[[e,n[e]]]));await i.runCheckCommand(t,n,r)}),await t.parseAsync()}function c(e){return e.option(`--config <path>`,`config file to use instead of the auto-detected ${t.configFileName}`).option(`--duplication-min-tokens <number>`,`minimum normalized token count for a duplicate region (default 40)`,C).option(`--duplication-max-gap-tokens <number>`,`maximum token gap merged into one gapped clone group; 0 disables merging (default 30)`,S).option(`--duplication-min-similarity-percent <number>`,`minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)`,x).option(`--include-tests`,`include test files and test directories`).option(`--json`,`print JSON output`)}function l(t){if(t===e.duplicationThreshold.key)return`line count from which a duplicated block is a violation`;let n=e.fileThresholds.find(e=>e.key===t);return n?`maximum ${n.label}`:`maximum ${e.functionThresholds.find(e=>e.key===t)?.label} of a function`}function u(e){return e.replaceAll(/[A-Z]/gu,e=>`-${e.toLowerCase()}`)}async function d(e,i){let a=r.resolveTarget(e),o=await r.configSearchDirectory(a),s=await t.loadConfig(i.config,o),c=t.resolveOptions(i,s),l=await r.scanTarget(a,{...c,loadExclusion:e=>n.loadExclusion(o,c.exclude,e)});r.addCrossFileDuplication(l,c);let u=f(l,c.top);c.json?g(l,u,c):_(a,l,u,c),(l.fatalError||c.failOnError&&l.errors.length>0)&&(process.exitCode=1)}function f(e,t){let n=e.files.map(({file:t,metrics:n})=>{let i=r.formatPath(t,e.displayRoot),a=r.collectDuplicatedLineNumbers(n,e.crossFileDuplication,i);return{file:i,worstFunction:h(n.functions),duplicatedLineCount:a.size,duplicatedLineRatio:n.lines.code===0?0:a.size/n.lines.code,crossFilePartners:[],ncss:n.ncssCount,codeLines:n.lines.code}}),i=m(n.map(e=>e.worstFunction?.cognitiveComplexity??0)),a=m(n.map(e=>e.duplicatedLineCount)),o=m(n.map(e=>e.ncss)),s=n.map(e=>({...e,score:i(e.worstFunction?.cognitiveComplexity??0)+a(e.duplicatedLineCount)+o(e.ncss)})).toSorted((e,t)=>t.score-e.score||t.ncss-e.ncss||e.file.localeCompare(t.file));return p(s.slice(0,t),e.crossFileDuplication),s}function p(e,t){if(!t)return;let n=new Map(e.map(e=>[e.file,/* @__PURE__ */ new Set]));for(let e of t.groups)for(let t of e.files){let r=n.get(t);if(r)for(let n of e.files)n!==t&&r.add(n)}for(let t of e)t.crossFilePartners=[...n.get(t.file)??[]].toSorted()}function m(e){let t=e.toSorted((e,t)=>e-t);return e=>{let n=0,r=t.length;for(;n<r;){let i=n+r>>1;t[i]<e?n=i+1:r=i}return t.length===0?0:n/t.length}}function h(e){let t;for(let n of e)(!t||n.cognitiveComplexity>t.cognitiveComplexity||n.cognitiveComplexity===t.cognitiveComplexity&&n.ncss>t.ncss)&&(t=n);if(t)return{name:t.name??`<anonymous>`,startLine:t.startLine,endLine:t.endLine,cognitiveComplexity:t.cognitiveComplexity,ncss:t.ncss,nestingDepth:t.nestingDepth}}function g(e,t,n){let i=t.slice(0,n.top);r.writeStdout(JSON.stringify({summary:y(e.files),totalRankedFiles:t.length,truncated:i.length<t.length,files:i,errors:e.errors,warnings:e.warnings},void 0,2)+`
3
+ `)}function _(e,t,n,i){if(t.fatalError){r.writeStderr(`Error: ${t.fatalError}\n`);return}let a=y(t.files);if(r.writeStdout(`Measured ${a.fileCount} files under ${e} (code LOC ${a.linesOfCode}, NCSS ${a.ncssCount}, functions ${a.functionCount})\n`),n.length===0)r.writeStdout(`No measurable files found.
4
+ `);else{let e=n.slice(0,i.top),t=n.length>e.length?` of ${n.length}`:``;r.writeStdout(`\nRefactoring candidates (top ${e.length}${t}):\n`);for(let[t,n]of e.entries())r.writeStdout(`${t+1}. ${v(n)}\n`)}if(t.warnings.length>0){r.writeStderr(`\nDegraded ${t.warnings.length} files (measured, but excluded from cross-file matching):\n`);for(let e of t.warnings.slice(0,10))r.writeStderr(`- ${e}\n`);t.warnings.length>10&&r.writeStderr(`- ... ${t.warnings.length-10} more\n`)}if(t.errors.length>0){r.writeStderr(`\nSkipped ${t.errors.length} files or directories:\n`);for(let e of t.errors.slice(0,10))r.writeStderr(`- ${e}\n`);t.errors.length>10&&r.writeStderr(`- ... ${t.errors.length-10} more\n`)}}function v(e){let t=[];if(e.worstFunction){let n=e.worstFunction;t.push(`worst function ${n.name} (L${n.startLine}-${n.endLine}) cognitive ${n.cognitiveComplexity}, NCSS ${n.ncss}, nesting ${n.nestingDepth}`)}if(e.duplicatedLineCount>0){let n=e.crossFilePartners.length>0?`, shared with ${e.crossFilePartners.slice(0,3).join(`, `)}${e.crossFilePartners.length>3?`, ...`:``}`:``;t.push(`duplicated lines ${e.duplicatedLineCount} (${Math.round(e.duplicatedLineRatio*100)}%${n})`)}return t.push(`file NCSS ${e.ncss}`),`${e.file} (score ${e.score.toFixed(2)}): ${t.join(`; `)}`}function y(e){let t=0,n=0,r=0,i=0;for(let a of e)t+=a.metrics.functions.length,n+=a.metrics.lines.code,r=Math.max(r,a.metrics.maxCognitiveComplexity),i+=a.metrics.ncssCount;return{fileCount:e.length,functionCount:t,linesOfCode:n,maxCognitiveComplexity:r,ncssCount:i}}function b(e){if(e===`off`)return 1/0;let t=e.trim()===``?NaN:Number(e);if(!Number.isFinite(t)||t<0)throw new o.InvalidArgumentError(`Expected a non-negative number or "off".`);return t}function x(e){let t=C(e);if(t>100)throw new o.InvalidArgumentError(`Expected an integer between 1 and 100.`);return t}function S(e){if(!/^\d+$/u.test(e))throw new o.InvalidArgumentError(`Expected a non-negative integer.`);let t=Number(e);if(!Number.isSafeInteger(t)||t<0)throw new o.InvalidArgumentError(`Expected a non-negative integer.`);return t}function C(e){let t=(0,a.parsePositiveInteger)(e);if(t===void 0)throw new o.InvalidArgumentError(`Expected a positive integer.`);return t}
5
5
  //# sourceMappingURL=cli.cjs.map
package/dist/cli.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.cjs","names":["formatError","configFileName","Command","resolveTarget","loadConfig","configSearchDirectory","resolveOptions","scanTarget","loadExclusion","runDiffCommand","formatPath","collectDuplicatedLineNumbers","InvalidArgumentError"],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { Command, InvalidArgumentError } from 'commander';\nimport { type CliOptions, configFileName, loadConfig, type ResolvedOptions, resolveOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { runDiffCommand, type DiffCliOptions } from './diffCommand.js';\nimport { loadExclusion } from './exclusion.js';\nimport {\n addCrossFileDuplication,\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatError,\n formatPath,\n resolveTarget,\n scanTarget,\n writeStderr,\n writeStdout,\n type FileMetrics,\n type ScanResult,\n} from './scan.js';\nimport type { FunctionMetrics } from './types.js';\n\n/** The worst (highest-cognitive-complexity) function of a file, reported as the ranking evidence. */\ninterface WorstFunction {\n name: string;\n startLine: number;\n endLine: number;\n cognitiveComplexity: number;\n ncss: number;\n nestingDepth: number;\n}\n\n/**\n * One ranked refactoring candidate. The score is the sum of the file's repo-relative percentile\n * ranks (each in [0, 1)) over three dimensions: worst-function cognitive complexity, duplicated\n * lines (within-file and cross-file combined), and file NCSS. Ranking is relative to the scanned\n * project, so no absolute threshold is involved.\n */\ninterface RankedFile {\n file: string;\n score: number;\n worstFunction?: WorstFunction;\n /** Distinct lines covered by within-file duplicate blocks or cross-file duplicate occurrences. */\n duplicatedLineCount: number;\n /** duplicatedLineCount / code lines (0 when the file has no code). */\n duplicatedLineRatio: number;\n /** Other files sharing cross-file duplicate blocks; filled only for the reported top files. */\n crossFilePartners: string[];\n ncss: number;\n codeLines: number;\n}\n\n/** Caps how many cross-file partners a ranked file lists so the report stays scannable. */\nconst maxCrossFilePartners = 3;\n\n// oxlint-disable-next-line unicorn/prefer-top-level-await -- CommonJS build output cannot preserve top-level await.\nvoid main().catch((error: unknown) => {\n writeStderr(`Error: ${formatError(error)}\\n`);\n process.exitCode = 1;\n});\n\n/** Registers the options shared by the ranking command and the diff gate. */\nfunction addSharedOptions(command: Command): Command {\n return command\n .option('--config <path>', `config file to use instead of the auto-detected ${configFileName}`)\n .option(\n '--duplication-min-tokens <number>',\n 'minimum normalized token count for a duplicate region (default 40)',\n parsePositiveInteger\n )\n .option(\n '--duplication-max-gap-tokens <number>',\n 'maximum token gap merged into one gapped clone group; 0 disables merging (default 30)',\n parseNonNegativeInteger\n )\n .option(\n '--duplication-min-similarity-percent <number>',\n 'minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)',\n parsePercentInteger\n )\n .option('--include-tests', 'include test files and test directories')\n .option('--json', 'print JSON output');\n}\n\nasync function main(): Promise<void> {\n const program = addSharedOptions(\n new Command()\n .name('code-gauge')\n .description('Rank the files of a project by refactoring priority.')\n .argument('[target]', 'file or directory to measure', '.')\n )\n .option('--top <number>', 'number of top-ranked files to report (default: 10)', parsePositiveInteger)\n .option('--fail-on-error', 'exit with code 1 when files or directories cannot be scanned');\n\n program.action(async (target: string, cliOptions: CliOptions) => {\n const resolvedTarget = resolveTarget(target);\n const config = await loadConfig(cliOptions.config, await configSearchDirectory(resolvedTarget));\n const options = resolveOptions(cliOptions, config);\n const searchDirectory = await configSearchDirectory(resolvedTarget);\n const result = await scanTarget(resolvedTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n addCrossFileDuplication(result, options);\n const rankedFiles = rankFiles(result, options.top);\n\n if (options.json) {\n printJson(result, rankedFiles, options);\n } else {\n printTextReport(resolvedTarget, result, rankedFiles, options);\n }\n\n if (result.fatalError || (options.failOnError && result.errors.length > 0)) {\n process.exitCode = 1;\n }\n });\n\n addSharedOptions(\n program\n .command('diff')\n .description(\n 'Gate the working tree against a base ref: report only metric regressions in changed files (exit 1 on violations)'\n )\n .argument('[target]', 'directory whose changed files are gated', '.')\n .requiredOption('--base <ref>', 'base git ref; changes are measured against its merge-base with HEAD')\n )\n .option('--full', 'also print the passing gate values of every checked function and file')\n .action(async (target: string, _cliOptions: DiffCliOptions, command: Command) => {\n // Options sharing a name with a root option (--json, --config, ...) land in the root's\n // option store, so the merged view is required to see them.\n await runDiffCommand(target, command.optsWithGlobals() as DiffCliOptions);\n });\n\n await program.parseAsync();\n}\n\nfunction rankFiles(result: ScanResult, top: number): RankedFile[] {\n const candidates = result.files.map(({ file, metrics }) => {\n const formattedFile = formatPath(file, result.displayRoot);\n const duplicatedLines = collectDuplicatedLineNumbers(metrics, result.crossFileDuplication, formattedFile);\n return {\n file: formattedFile,\n worstFunction: findWorstFunction(metrics.functions),\n duplicatedLineCount: duplicatedLines.size,\n duplicatedLineRatio: metrics.lines.code === 0 ? 0 : duplicatedLines.size / metrics.lines.code,\n crossFilePartners: [] as string[],\n ncss: metrics.ncssCount,\n codeLines: metrics.lines.code,\n };\n });\n\n const cognitivePercentile = makePercentile(candidates.map((c) => c.worstFunction?.cognitiveComplexity ?? 0));\n const duplicationPercentile = makePercentile(candidates.map((c) => c.duplicatedLineCount));\n const ncssPercentile = makePercentile(candidates.map((c) => c.ncss));\n\n const ranked = candidates\n .map((candidate) => ({\n ...candidate,\n score:\n cognitivePercentile(candidate.worstFunction?.cognitiveComplexity ?? 0) +\n duplicationPercentile(candidate.duplicatedLineCount) +\n ncssPercentile(candidate.ncss),\n }))\n .toSorted(\n (left, right) => right.score - left.score || right.ncss - left.ncss || left.file.localeCompare(right.file)\n );\n // Partner evidence is attached only to the files that will be reported: expanding partners for\n // every scanned file first would retain TH(F^2) strings when one clone group spans F files, and\n // rescanning all groups per file would be O(files x groups) on the post-scan ranking step.\n attachCrossFilePartners(ranked.slice(0, top), result.crossFileDuplication);\n return ranked;\n}\n\n/** One pass over the groups fills the reported files' partner lists (other files sharing a group). */\nfunction attachCrossFilePartners(\n reportedFiles: RankedFile[],\n crossFileDuplication: CrossFileDuplicationMetrics | undefined\n): void {\n if (!crossFileDuplication) {\n return;\n }\n const partnersByFile = new Map(reportedFiles.map((ranked) => [ranked.file, new Set<string>()]));\n for (const group of crossFileDuplication.groups) {\n for (const file of group.files) {\n const partners = partnersByFile.get(file);\n if (!partners) {\n continue;\n }\n for (const partner of group.files) {\n if (partner !== file) {\n partners.add(partner);\n }\n }\n }\n }\n for (const ranked of reportedFiles) {\n ranked.crossFilePartners = [...(partnersByFile.get(ranked.file) ?? [])].toSorted();\n }\n}\n\n/**\n * Percentile rank within the scanned project: the fraction of files with a strictly smaller\n * value, in [0, 1). Relative ranking needs no absolute threshold, which sidesteps the metric\n * calibration problem entirely — the top of the list is worth refactoring first regardless of\n * where any cutoff would sit.\n */\nfunction makePercentile(values: number[]): (value: number) => number {\n const sorted = values.toSorted((left, right) => left - right);\n return (value: number) => {\n let low = 0;\n let high = sorted.length;\n while (low < high) {\n const middle = (low + high) >> 1;\n if ((sorted[middle] as number) < value) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return sorted.length === 0 ? 0 : low / sorted.length;\n };\n}\n\n/** The highest-cognitive-complexity function; NCSS breaks ties so the larger body is reported. */\nfunction findWorstFunction(functions: FunctionMetrics[]): WorstFunction | undefined {\n let worst: FunctionMetrics | undefined;\n for (const fn of functions) {\n if (\n !worst ||\n fn.cognitiveComplexity > worst.cognitiveComplexity ||\n (fn.cognitiveComplexity === worst.cognitiveComplexity && fn.ncss > worst.ncss)\n ) {\n worst = fn;\n }\n }\n if (!worst) {\n return undefined;\n }\n return {\n name: worst.name ?? '<anonymous>',\n startLine: worst.startLine,\n endLine: worst.endLine,\n cognitiveComplexity: worst.cognitiveComplexity,\n ncss: worst.ncss,\n nestingDepth: worst.nestingDepth,\n };\n}\n\nfunction printJson(result: ScanResult, rankedFiles: RankedFile[], options: ResolvedOptions): void {\n const reportedFiles = rankedFiles.slice(0, options.top);\n writeStdout(\n JSON.stringify(\n {\n summary: summarize(result.files),\n totalRankedFiles: rankedFiles.length,\n truncated: reportedFiles.length < rankedFiles.length,\n files: reportedFiles,\n errors: result.errors,\n warnings: result.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n\nfunction printTextReport(\n target: string,\n result: ScanResult,\n rankedFiles: RankedFile[],\n options: ResolvedOptions\n): void {\n if (result.fatalError) {\n writeStderr(`Error: ${result.fatalError}\\n`);\n return;\n }\n\n const summary = summarize(result.files);\n writeStdout(\n `Measured ${summary.fileCount} files under ${target} (code LOC ${summary.linesOfCode}, NCSS ${summary.ncssCount}, functions ${summary.functionCount})\\n`\n );\n\n if (rankedFiles.length === 0) {\n writeStdout('No measurable files found.\\n');\n } else {\n const reportedFiles = rankedFiles.slice(0, options.top);\n const totalSuffix = rankedFiles.length > reportedFiles.length ? ` of ${rankedFiles.length}` : '';\n writeStdout(`\\nRefactoring candidates (top ${reportedFiles.length}${totalSuffix}):\\n`);\n for (const [index, ranked] of reportedFiles.entries()) {\n writeStdout(`${index + 1}. ${formatRankedFile(ranked)}\\n`);\n }\n }\n\n if (result.warnings.length > 0) {\n writeStderr(`\\nDegraded ${result.warnings.length} files (measured, but excluded from cross-file matching):\\n`);\n for (const warning of result.warnings.slice(0, 10)) {\n writeStderr(`- ${warning}\\n`);\n }\n if (result.warnings.length > 10) {\n writeStderr(`- ... ${result.warnings.length - 10} more\\n`);\n }\n }\n\n if (result.errors.length > 0) {\n writeStderr(`\\nSkipped ${result.errors.length} files or directories:\\n`);\n for (const error of result.errors.slice(0, 10)) {\n writeStderr(`- ${error}\\n`);\n }\n if (result.errors.length > 10) {\n writeStderr(`- ... ${result.errors.length - 10} more\\n`);\n }\n }\n}\n\n/** One ranked file as a single line: the location, the evidence, and where the duplication points. */\nfunction formatRankedFile(ranked: RankedFile): string {\n const reasons: string[] = [];\n if (ranked.worstFunction) {\n const fn = ranked.worstFunction;\n reasons.push(\n `worst function ${fn.name} (L${fn.startLine}-${fn.endLine}) cognitive ${fn.cognitiveComplexity}, NCSS ${fn.ncss}, nesting ${fn.nestingDepth}`\n );\n }\n if (ranked.duplicatedLineCount > 0) {\n const partnersSuffix =\n ranked.crossFilePartners.length > 0\n ? `, shared with ${ranked.crossFilePartners.slice(0, maxCrossFilePartners).join(', ')}${ranked.crossFilePartners.length > maxCrossFilePartners ? ', ...' : ''}`\n : '';\n reasons.push(\n `duplicated lines ${ranked.duplicatedLineCount} (${Math.round(ranked.duplicatedLineRatio * 100)}%${partnersSuffix})`\n );\n }\n reasons.push(`file NCSS ${ranked.ncss}`);\n return `${ranked.file} (score ${ranked.score.toFixed(2)}): ${reasons.join('; ')}`;\n}\n\nfunction summarize(files: FileMetrics[]): {\n fileCount: number;\n functionCount: number;\n linesOfCode: number;\n maxCognitiveComplexity: number;\n ncssCount: number;\n} {\n let functionCount = 0;\n let linesOfCode = 0;\n let maxCognitiveComplexity = 0;\n let ncssCount = 0;\n\n for (const file of files) {\n functionCount += file.metrics.functions.length;\n linesOfCode += file.metrics.lines.code;\n maxCognitiveComplexity = Math.max(maxCognitiveComplexity, file.metrics.maxCognitiveComplexity);\n ncssCount += file.metrics.ncssCount;\n }\n\n return { fileCount: files.length, functionCount, linesOfCode, maxCognitiveComplexity, ncssCount };\n}\n\nfunction parsePercentInteger(value: string): number {\n const parsed = parsePositiveInteger(value);\n if (parsed > 100) {\n throw new InvalidArgumentError('Expected an integer between 1 and 100.');\n }\n return parsed;\n}\n\nfunction parseNonNegativeInteger(value: string): number {\n if (!/^\\d+$/u.test(value)) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n return parsed;\n}\n\nfunction parsePositiveInteger(value: string): number {\n if (!/^[1-9]\\d*$/u.test(value)) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 1) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n return parsed;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.cjs","names":["getErrorMessage","Command","thresholds","runCheckCommand","configFileName","duplicationThreshold","fileThresholds","functionThresholds","resolveTarget","configSearchDirectory","loadConfig","resolveOptions","scanTarget","loadExclusion","formatPath","collectDuplicatedLineNumbers","InvalidArgumentError","parsePositiveInteger"],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { Command, InvalidArgumentError } from 'commander';\nimport { getErrorMessage, parsePositiveInteger } from '@willbooster/shared-lib';\nimport { type CliOptions, configFileName, loadConfig, type ResolvedOptions, resolveOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { runCheckCommand, type CheckCliOptions } from './checkCommand.js';\nimport { loadExclusion } from './exclusion.js';\nimport {\n addCrossFileDuplication,\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatPath,\n resolveTarget,\n scanTarget,\n writeStderr,\n writeStdout,\n type FileMetrics,\n type ScanResult,\n} from './scan.js';\nimport { duplicationThreshold, fileThresholds, functionThresholds, thresholds } from './thresholds.js';\nimport type { FunctionMetrics } from './types.js';\n\n/** The worst (highest-cognitive-complexity) function of a file, reported as the ranking evidence. */\ninterface WorstFunction {\n name: string;\n startLine: number;\n endLine: number;\n cognitiveComplexity: number;\n ncss: number;\n nestingDepth: number;\n}\n\n/**\n * One ranked refactoring candidate. The score is the sum of the file's repo-relative percentile\n * ranks (each in [0, 1)) over three dimensions: worst-function cognitive complexity, duplicated\n * lines (within-file and cross-file combined), and file NCSS. Ranking is relative to the scanned\n * project, so no absolute threshold is involved.\n */\ninterface RankedFile {\n file: string;\n score: number;\n worstFunction?: WorstFunction;\n /** Distinct lines covered by within-file duplicate blocks or cross-file duplicate occurrences. */\n duplicatedLineCount: number;\n /** duplicatedLineCount / code lines (0 when the file has no code). */\n duplicatedLineRatio: number;\n /** Other files sharing cross-file duplicate blocks; filled only for the reported top files. */\n crossFilePartners: string[];\n ncss: number;\n codeLines: number;\n}\n\n/** Caps how many cross-file partners a ranked file lists so the report stays scannable. */\nconst maxCrossFilePartners = 3;\n\n// oxlint-disable-next-line unicorn/prefer-top-level-await -- CommonJS build output cannot preserve top-level await.\nvoid main().catch((error: unknown) => {\n writeStderr(`Error: ${getErrorMessage(error)}\\n`);\n process.exitCode = 1;\n});\n\nasync function main(): Promise<void> {\n const program = new Command()\n .name('code-gauge')\n .description('Measure code metrics: rank refactoring candidates (the default command) or check thresholds.');\n\n addSharedOptions(\n program\n .command('rank', { isDefault: true })\n .description('Rank the files of a project by refactoring priority')\n .argument('[target]', 'file or directory to measure', '.')\n )\n .option('--top <number>', 'number of top-ranked files to report (default: 10)', parsePositiveIntegerOption)\n .option('--fail-on-error', 'exit with code 1 when files or directories cannot be scanned')\n .action(runRankCommand);\n\n const check = addSharedOptions(\n program\n .command('check')\n .description(\n 'Report every exceeded threshold (exit 1 on violations, 2 when files cannot be measured); with --base, only in what the change touches'\n )\n .argument('[target]', 'file or directory to check', '.')\n ).option('--base <ref>', 'check only what the working tree changed since the merge-base of this git ref and HEAD');\n for (const { key, defaultLimit } of thresholds) {\n check.option(\n `--${toKebabCase(key)} <number>`,\n `${describeLimit(key)} (default ${defaultLimit}); \"off\" disables it`,\n parseLimit\n );\n }\n // Exit code 1 means violations, so a rejected command line must not use it.\n check.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : 2));\n check.action(async (target: string, cliOptions: CheckCliOptions & Record<string, unknown>) => {\n const cliLimits = Object.fromEntries(\n thresholds.flatMap(({ key }) => (cliOptions[key] === undefined ? [] : [[key, cliOptions[key] as number]]))\n );\n await runCheckCommand(target, cliOptions, cliLimits);\n });\n\n await program.parseAsync();\n}\n\n/** Registers the options shared by the ranking and check commands. */\nfunction addSharedOptions(command: Command): Command {\n return command\n .option('--config <path>', `config file to use instead of the auto-detected ${configFileName}`)\n .option(\n '--duplication-min-tokens <number>',\n 'minimum normalized token count for a duplicate region (default 40)',\n parsePositiveIntegerOption\n )\n .option(\n '--duplication-max-gap-tokens <number>',\n 'maximum token gap merged into one gapped clone group; 0 disables merging (default 30)',\n parseNonNegativeInteger\n )\n .option(\n '--duplication-min-similarity-percent <number>',\n 'minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)',\n parsePercentInteger\n )\n .option('--include-tests', 'include test files and test directories')\n .option('--json', 'print JSON output');\n}\n\nfunction describeLimit(key: string): string {\n if (key === duplicationThreshold.key) {\n return 'line count from which a duplicated block is a violation';\n }\n const fileThreshold = fileThresholds.find((threshold) => threshold.key === key);\n return fileThreshold\n ? `maximum ${fileThreshold.label}`\n : `maximum ${functionThresholds.find((threshold) => threshold.key === key)?.label} of a function`;\n}\n\nfunction toKebabCase(key: string): string {\n return key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);\n}\n\nasync function runRankCommand(target: string, cliOptions: CliOptions): Promise<void> {\n const resolvedTarget = resolveTarget(target);\n const searchDirectory = await configSearchDirectory(resolvedTarget);\n const config = await loadConfig(cliOptions.config, searchDirectory);\n const options = resolveOptions(cliOptions, config);\n const result = await scanTarget(resolvedTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n addCrossFileDuplication(result, options);\n const rankedFiles = rankFiles(result, options.top);\n\n if (options.json) {\n printJson(result, rankedFiles, options);\n } else {\n printTextReport(resolvedTarget, result, rankedFiles, options);\n }\n\n if (result.fatalError || (options.failOnError && result.errors.length > 0)) {\n process.exitCode = 1;\n }\n}\n\nfunction rankFiles(result: ScanResult, top: number): RankedFile[] {\n const candidates = result.files.map(({ file, metrics }) => {\n const formattedFile = formatPath(file, result.displayRoot);\n const duplicatedLines = collectDuplicatedLineNumbers(metrics, result.crossFileDuplication, formattedFile);\n return {\n file: formattedFile,\n worstFunction: findWorstFunction(metrics.functions),\n duplicatedLineCount: duplicatedLines.size,\n duplicatedLineRatio: metrics.lines.code === 0 ? 0 : duplicatedLines.size / metrics.lines.code,\n crossFilePartners: [] as string[],\n ncss: metrics.ncssCount,\n codeLines: metrics.lines.code,\n };\n });\n\n const cognitivePercentile = makePercentile(candidates.map((c) => c.worstFunction?.cognitiveComplexity ?? 0));\n const duplicationPercentile = makePercentile(candidates.map((c) => c.duplicatedLineCount));\n const ncssPercentile = makePercentile(candidates.map((c) => c.ncss));\n\n const ranked = candidates\n .map((candidate) => ({\n ...candidate,\n score:\n cognitivePercentile(candidate.worstFunction?.cognitiveComplexity ?? 0) +\n duplicationPercentile(candidate.duplicatedLineCount) +\n ncssPercentile(candidate.ncss),\n }))\n .toSorted(\n (left, right) => right.score - left.score || right.ncss - left.ncss || left.file.localeCompare(right.file)\n );\n // Partner evidence is attached only to the files that will be reported: expanding partners for\n // every scanned file first would retain TH(F^2) strings when one clone group spans F files, and\n // rescanning all groups per file would be O(files x groups) on the post-scan ranking step.\n attachCrossFilePartners(ranked.slice(0, top), result.crossFileDuplication);\n return ranked;\n}\n\n/** One pass over the groups fills the reported files' partner lists (other files sharing a group). */\nfunction attachCrossFilePartners(\n reportedFiles: RankedFile[],\n crossFileDuplication: CrossFileDuplicationMetrics | undefined\n): void {\n if (!crossFileDuplication) {\n return;\n }\n const partnersByFile = new Map(reportedFiles.map((ranked) => [ranked.file, new Set<string>()]));\n for (const group of crossFileDuplication.groups) {\n for (const file of group.files) {\n const partners = partnersByFile.get(file);\n if (!partners) {\n continue;\n }\n for (const partner of group.files) {\n if (partner !== file) {\n partners.add(partner);\n }\n }\n }\n }\n for (const ranked of reportedFiles) {\n ranked.crossFilePartners = [...(partnersByFile.get(ranked.file) ?? [])].toSorted();\n }\n}\n\n/**\n * Percentile rank within the scanned project: the fraction of files with a strictly smaller\n * value, in [0, 1). Relative ranking needs no absolute threshold, which sidesteps the metric\n * calibration problem entirely — the top of the list is worth refactoring first regardless of\n * where any cutoff would sit.\n */\nfunction makePercentile(values: number[]): (value: number) => number {\n const sorted = values.toSorted((left, right) => left - right);\n return (value: number) => {\n let low = 0;\n let high = sorted.length;\n while (low < high) {\n const middle = (low + high) >> 1;\n if ((sorted[middle] as number) < value) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return sorted.length === 0 ? 0 : low / sorted.length;\n };\n}\n\n/** The highest-cognitive-complexity function; NCSS breaks ties so the larger body is reported. */\nfunction findWorstFunction(functions: FunctionMetrics[]): WorstFunction | undefined {\n let worst: FunctionMetrics | undefined;\n for (const fn of functions) {\n if (\n !worst ||\n fn.cognitiveComplexity > worst.cognitiveComplexity ||\n (fn.cognitiveComplexity === worst.cognitiveComplexity && fn.ncss > worst.ncss)\n ) {\n worst = fn;\n }\n }\n if (!worst) {\n return undefined;\n }\n return {\n name: worst.name ?? '<anonymous>',\n startLine: worst.startLine,\n endLine: worst.endLine,\n cognitiveComplexity: worst.cognitiveComplexity,\n ncss: worst.ncss,\n nestingDepth: worst.nestingDepth,\n };\n}\n\nfunction printJson(result: ScanResult, rankedFiles: RankedFile[], options: ResolvedOptions): void {\n const reportedFiles = rankedFiles.slice(0, options.top);\n writeStdout(\n JSON.stringify(\n {\n summary: summarize(result.files),\n totalRankedFiles: rankedFiles.length,\n truncated: reportedFiles.length < rankedFiles.length,\n files: reportedFiles,\n errors: result.errors,\n warnings: result.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n\nfunction printTextReport(\n target: string,\n result: ScanResult,\n rankedFiles: RankedFile[],\n options: ResolvedOptions\n): void {\n if (result.fatalError) {\n writeStderr(`Error: ${result.fatalError}\\n`);\n return;\n }\n\n const summary = summarize(result.files);\n writeStdout(\n `Measured ${summary.fileCount} files under ${target} (code LOC ${summary.linesOfCode}, NCSS ${summary.ncssCount}, functions ${summary.functionCount})\\n`\n );\n\n if (rankedFiles.length === 0) {\n writeStdout('No measurable files found.\\n');\n } else {\n const reportedFiles = rankedFiles.slice(0, options.top);\n const totalSuffix = rankedFiles.length > reportedFiles.length ? ` of ${rankedFiles.length}` : '';\n writeStdout(`\\nRefactoring candidates (top ${reportedFiles.length}${totalSuffix}):\\n`);\n for (const [index, ranked] of reportedFiles.entries()) {\n writeStdout(`${index + 1}. ${formatRankedFile(ranked)}\\n`);\n }\n }\n\n if (result.warnings.length > 0) {\n writeStderr(`\\nDegraded ${result.warnings.length} files (measured, but excluded from cross-file matching):\\n`);\n for (const warning of result.warnings.slice(0, 10)) {\n writeStderr(`- ${warning}\\n`);\n }\n if (result.warnings.length > 10) {\n writeStderr(`- ... ${result.warnings.length - 10} more\\n`);\n }\n }\n\n if (result.errors.length > 0) {\n writeStderr(`\\nSkipped ${result.errors.length} files or directories:\\n`);\n for (const error of result.errors.slice(0, 10)) {\n writeStderr(`- ${error}\\n`);\n }\n if (result.errors.length > 10) {\n writeStderr(`- ... ${result.errors.length - 10} more\\n`);\n }\n }\n}\n\n/** One ranked file as a single line: the location, the evidence, and where the duplication points. */\nfunction formatRankedFile(ranked: RankedFile): string {\n const reasons: string[] = [];\n if (ranked.worstFunction) {\n const fn = ranked.worstFunction;\n reasons.push(\n `worst function ${fn.name} (L${fn.startLine}-${fn.endLine}) cognitive ${fn.cognitiveComplexity}, NCSS ${fn.ncss}, nesting ${fn.nestingDepth}`\n );\n }\n if (ranked.duplicatedLineCount > 0) {\n const partnersSuffix =\n ranked.crossFilePartners.length > 0\n ? `, shared with ${ranked.crossFilePartners.slice(0, maxCrossFilePartners).join(', ')}${ranked.crossFilePartners.length > maxCrossFilePartners ? ', ...' : ''}`\n : '';\n reasons.push(\n `duplicated lines ${ranked.duplicatedLineCount} (${Math.round(ranked.duplicatedLineRatio * 100)}%${partnersSuffix})`\n );\n }\n reasons.push(`file NCSS ${ranked.ncss}`);\n return `${ranked.file} (score ${ranked.score.toFixed(2)}): ${reasons.join('; ')}`;\n}\n\nfunction summarize(files: FileMetrics[]): {\n fileCount: number;\n functionCount: number;\n linesOfCode: number;\n maxCognitiveComplexity: number;\n ncssCount: number;\n} {\n let functionCount = 0;\n let linesOfCode = 0;\n let maxCognitiveComplexity = 0;\n let ncssCount = 0;\n\n for (const file of files) {\n functionCount += file.metrics.functions.length;\n linesOfCode += file.metrics.lines.code;\n maxCognitiveComplexity = Math.max(maxCognitiveComplexity, file.metrics.maxCognitiveComplexity);\n ncssCount += file.metrics.ncssCount;\n }\n\n return { fileCount: files.length, functionCount, linesOfCode, maxCognitiveComplexity, ncssCount };\n}\n\nfunction parseLimit(value: string): number {\n if (value === 'off') {\n return Infinity;\n }\n const parsed = value.trim() === '' ? Number.NaN : Number(value);\n if (!Number.isFinite(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative number or \"off\".');\n }\n return parsed;\n}\n\nfunction parsePercentInteger(value: string): number {\n const parsed = parsePositiveIntegerOption(value);\n if (parsed > 100) {\n throw new InvalidArgumentError('Expected an integer between 1 and 100.');\n }\n return parsed;\n}\n\nfunction parseNonNegativeInteger(value: string): number {\n if (!/^\\d+$/u.test(value)) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n return parsed;\n}\n\nfunction parsePositiveIntegerOption(value: string): number {\n const parsed = parsePositiveInteger(value);\n if (parsed === undefined) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n return parsed;\n}\n"],"mappings":"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"}
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import{configFileName as e,loadConfig as t,resolveOptions as n}from"./cliConfig.js";import{loadExclusion as r}from"./exclusion.js";import{addCrossFileDuplication as i,collectDuplicatedLineNumbers as a,configSearchDirectory as o,formatError as s,formatPath as c,resolveTarget as l,scanTarget as u,writeStderr as d,writeStdout as f}from"./scan.js";import{runDiffCommand as p}from"./diffCommand.js";import{Command as m,InvalidArgumentError as h}from"commander";_().catch(e=>{d(`Error: ${s(e)}\n`),process.exitCode=1});function g(t){return t.option(`--config <path>`,`config file to use instead of the auto-detected ${e}`).option(`--duplication-min-tokens <number>`,`minimum normalized token count for a duplicate region (default 40)`,O).option(`--duplication-max-gap-tokens <number>`,`maximum token gap merged into one gapped clone group; 0 disables merging (default 30)`,D).option(`--duplication-min-similarity-percent <number>`,`minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)`,E).option(`--include-tests`,`include test files and test directories`).option(`--json`,`print JSON output`)}async function _(){let e=g(new m().name(`code-gauge`).description(`Rank the files of a project by refactoring priority.`).argument(`[target]`,`file or directory to measure`,`.`)).option(`--top <number>`,`number of top-ranked files to report (default: 10)`,O).option(`--fail-on-error`,`exit with code 1 when files or directories cannot be scanned`);e.action(async(e,a)=>{let s=l(e),c=await t(a.config,await o(s)),d=n(a,c),f=await o(s),p=await u(s,{...d,loadExclusion:e=>r(f,d.exclude,e)});i(p,d);let m=v(p,d.top);d.json?S(p,m,d):C(s,p,m,d),(p.fatalError||d.failOnError&&p.errors.length>0)&&(process.exitCode=1)}),g(e.command(`diff`).description(`Gate the working tree against a base ref: report only metric regressions in changed files (exit 1 on violations)`).argument(`[target]`,`directory whose changed files are gated`,`.`).requiredOption(`--base <ref>`,`base git ref; changes are measured against its merge-base with HEAD`)).option(`--full`,`also print the passing gate values of every checked function and file`).action(async(e,t,n)=>{await p(e,n.optsWithGlobals())}),await e.parseAsync()}function v(e,t){let n=e.files.map(({file:t,metrics:n})=>{let r=c(t,e.displayRoot),i=a(n,e.crossFileDuplication,r);return{file:r,worstFunction:x(n.functions),duplicatedLineCount:i.size,duplicatedLineRatio:n.lines.code===0?0:i.size/n.lines.code,crossFilePartners:[],ncss:n.ncssCount,codeLines:n.lines.code}}),r=b(n.map(e=>e.worstFunction?.cognitiveComplexity??0)),i=b(n.map(e=>e.duplicatedLineCount)),o=b(n.map(e=>e.ncss)),s=n.map(e=>({...e,score:r(e.worstFunction?.cognitiveComplexity??0)+i(e.duplicatedLineCount)+o(e.ncss)})).toSorted((e,t)=>t.score-e.score||t.ncss-e.ncss||e.file.localeCompare(t.file));return y(s.slice(0,t),e.crossFileDuplication),s}function y(e,t){if(!t)return;let n=new Map(e.map(e=>[e.file,/* @__PURE__ */ new Set]));for(let e of t.groups)for(let t of e.files){let r=n.get(t);if(r)for(let n of e.files)n!==t&&r.add(n)}for(let t of e)t.crossFilePartners=[...n.get(t.file)??[]].toSorted()}function b(e){let t=e.toSorted((e,t)=>e-t);return e=>{let n=0,r=t.length;for(;n<r;){let i=n+r>>1;t[i]<e?n=i+1:r=i}return t.length===0?0:n/t.length}}function x(e){let t;for(let n of e)(!t||n.cognitiveComplexity>t.cognitiveComplexity||n.cognitiveComplexity===t.cognitiveComplexity&&n.ncss>t.ncss)&&(t=n);if(t)return{name:t.name??`<anonymous>`,startLine:t.startLine,endLine:t.endLine,cognitiveComplexity:t.cognitiveComplexity,ncss:t.ncss,nestingDepth:t.nestingDepth}}function S(e,t,n){let r=t.slice(0,n.top);f(JSON.stringify({summary:T(e.files),totalRankedFiles:t.length,truncated:r.length<t.length,files:r,errors:e.errors,warnings:e.warnings},void 0,2)+`
3
- `)}function C(e,t,n,r){if(t.fatalError){d(`Error: ${t.fatalError}\n`);return}let i=T(t.files);if(f(`Measured ${i.fileCount} files under ${e} (code LOC ${i.linesOfCode}, NCSS ${i.ncssCount}, functions ${i.functionCount})\n`),n.length===0)f(`No measurable files found.
4
- `);else{let e=n.slice(0,r.top),t=n.length>e.length?` of ${n.length}`:``;f(`\nRefactoring candidates (top ${e.length}${t}):\n`);for(let[t,n]of e.entries())f(`${t+1}. ${w(n)}\n`)}if(t.warnings.length>0){d(`\nDegraded ${t.warnings.length} files (measured, but excluded from cross-file matching):\n`);for(let e of t.warnings.slice(0,10))d(`- ${e}\n`);t.warnings.length>10&&d(`- ... ${t.warnings.length-10} more\n`)}if(t.errors.length>0){d(`\nSkipped ${t.errors.length} files or directories:\n`);for(let e of t.errors.slice(0,10))d(`- ${e}\n`);t.errors.length>10&&d(`- ... ${t.errors.length-10} more\n`)}}function w(e){let t=[];if(e.worstFunction){let n=e.worstFunction;t.push(`worst function ${n.name} (L${n.startLine}-${n.endLine}) cognitive ${n.cognitiveComplexity}, NCSS ${n.ncss}, nesting ${n.nestingDepth}`)}if(e.duplicatedLineCount>0){let n=e.crossFilePartners.length>0?`, shared with ${e.crossFilePartners.slice(0,3).join(`, `)}${e.crossFilePartners.length>3?`, ...`:``}`:``;t.push(`duplicated lines ${e.duplicatedLineCount} (${Math.round(e.duplicatedLineRatio*100)}%${n})`)}return t.push(`file NCSS ${e.ncss}`),`${e.file} (score ${e.score.toFixed(2)}): ${t.join(`; `)}`}function T(e){let t=0,n=0,r=0,i=0;for(let a of e)t+=a.metrics.functions.length,n+=a.metrics.lines.code,r=Math.max(r,a.metrics.maxCognitiveComplexity),i+=a.metrics.ncssCount;return{fileCount:e.length,functionCount:t,linesOfCode:n,maxCognitiveComplexity:r,ncssCount:i}}function E(e){let t=O(e);if(t>100)throw new h(`Expected an integer between 1 and 100.`);return t}function D(e){if(!/^\d+$/u.test(e))throw new h(`Expected a non-negative integer.`);let t=Number(e);if(!Number.isSafeInteger(t)||t<0)throw new h(`Expected a non-negative integer.`);return t}function O(e){if(!/^[1-9]\d*$/u.test(e))throw new h(`Expected a positive integer.`);let t=Number(e);if(!Number.isSafeInteger(t)||t<1)throw new h(`Expected a positive integer.`);return t}
2
+ import{duplicationThreshold as e,fileThresholds as t,functionThresholds as n,thresholds as r}from"./thresholds.js";import{configFileName as i,loadConfig as a,resolveOptions as o}from"./cliConfig.js";import{loadExclusion as s}from"./exclusion.js";import{addCrossFileDuplication as c,collectDuplicatedLineNumbers as l,configSearchDirectory as u,formatPath as d,resolveTarget as f,scanTarget as p,writeStderr as m,writeStdout as h}from"./scan.js";import{runCheckCommand as g}from"./checkCommand.js";import{getErrorMessage as _,parsePositiveInteger as v}from"@willbooster/shared-lib";import{Command as y,InvalidArgumentError as b}from"commander";x().catch(e=>{m(`Error: ${_(e)}\n`),process.exitCode=1});async function x(){let e=new y().name(`code-gauge`).description(`Measure code metrics: rank refactoring candidates (the default command) or check thresholds.`);S(e.command(`rank`,{isDefault:!0}).description(`Rank the files of a project by refactoring priority`).argument(`[target]`,`file or directory to measure`,`.`)).option(`--top <number>`,`number of top-ranked files to report (default: 10)`,L).option(`--fail-on-error`,`exit with code 1 when files or directories cannot be scanned`).action(T);let t=S(e.command(`check`).description(`Report every exceeded threshold (exit 1 on violations, 2 when files cannot be measured); with --base, only in what the change touches`).argument(`[target]`,`file or directory to check`,`.`)).option(`--base <ref>`,`check only what the working tree changed since the merge-base of this git ref and HEAD`);for(let{key:e,defaultLimit:n}of r)t.option(`--${w(e)} <number>`,`${C(e)} (default ${n}); "off" disables it`,P);t.exitOverride(e=>process.exit(e.exitCode===0?0:2)),t.action(async(e,t)=>{let n=Object.fromEntries(r.flatMap(({key:e})=>t[e]===void 0?[]:[[e,t[e]]]));await g(e,t,n)}),await e.parseAsync()}function S(e){return e.option(`--config <path>`,`config file to use instead of the auto-detected ${i}`).option(`--duplication-min-tokens <number>`,`minimum normalized token count for a duplicate region (default 40)`,L).option(`--duplication-max-gap-tokens <number>`,`maximum token gap merged into one gapped clone group; 0 disables merging (default 30)`,I).option(`--duplication-min-similarity-percent <number>`,`minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)`,F).option(`--include-tests`,`include test files and test directories`).option(`--json`,`print JSON output`)}function C(r){if(r===e.key)return`line count from which a duplicated block is a violation`;let i=t.find(e=>e.key===r);return i?`maximum ${i.label}`:`maximum ${n.find(e=>e.key===r)?.label} of a function`}function w(e){return e.replaceAll(/[A-Z]/gu,e=>`-${e.toLowerCase()}`)}async function T(e,t){let n=f(e),r=await u(n),i=await a(t.config,r),l=o(t,i),d=await p(n,{...l,loadExclusion:e=>s(r,l.exclude,e)});c(d,l);let m=E(d,l.top);l.json?A(d,m,l):j(n,d,m,l),(d.fatalError||l.failOnError&&d.errors.length>0)&&(process.exitCode=1)}function E(e,t){let n=e.files.map(({file:t,metrics:n})=>{let r=d(t,e.displayRoot),i=l(n,e.crossFileDuplication,r);return{file:r,worstFunction:k(n.functions),duplicatedLineCount:i.size,duplicatedLineRatio:n.lines.code===0?0:i.size/n.lines.code,crossFilePartners:[],ncss:n.ncssCount,codeLines:n.lines.code}}),r=O(n.map(e=>e.worstFunction?.cognitiveComplexity??0)),i=O(n.map(e=>e.duplicatedLineCount)),a=O(n.map(e=>e.ncss)),o=n.map(e=>({...e,score:r(e.worstFunction?.cognitiveComplexity??0)+i(e.duplicatedLineCount)+a(e.ncss)})).toSorted((e,t)=>t.score-e.score||t.ncss-e.ncss||e.file.localeCompare(t.file));return D(o.slice(0,t),e.crossFileDuplication),o}function D(e,t){if(!t)return;let n=new Map(e.map(e=>[e.file,/* @__PURE__ */ new Set]));for(let e of t.groups)for(let t of e.files){let r=n.get(t);if(r)for(let n of e.files)n!==t&&r.add(n)}for(let t of e)t.crossFilePartners=[...n.get(t.file)??[]].toSorted()}function O(e){let t=e.toSorted((e,t)=>e-t);return e=>{let n=0,r=t.length;for(;n<r;){let i=n+r>>1;t[i]<e?n=i+1:r=i}return t.length===0?0:n/t.length}}function k(e){let t;for(let n of e)(!t||n.cognitiveComplexity>t.cognitiveComplexity||n.cognitiveComplexity===t.cognitiveComplexity&&n.ncss>t.ncss)&&(t=n);if(t)return{name:t.name??`<anonymous>`,startLine:t.startLine,endLine:t.endLine,cognitiveComplexity:t.cognitiveComplexity,ncss:t.ncss,nestingDepth:t.nestingDepth}}function A(e,t,n){let r=t.slice(0,n.top);h(JSON.stringify({summary:N(e.files),totalRankedFiles:t.length,truncated:r.length<t.length,files:r,errors:e.errors,warnings:e.warnings},void 0,2)+`
3
+ `)}function j(e,t,n,r){if(t.fatalError){m(`Error: ${t.fatalError}\n`);return}let i=N(t.files);if(h(`Measured ${i.fileCount} files under ${e} (code LOC ${i.linesOfCode}, NCSS ${i.ncssCount}, functions ${i.functionCount})\n`),n.length===0)h(`No measurable files found.
4
+ `);else{let e=n.slice(0,r.top),t=n.length>e.length?` of ${n.length}`:``;h(`\nRefactoring candidates (top ${e.length}${t}):\n`);for(let[t,n]of e.entries())h(`${t+1}. ${M(n)}\n`)}if(t.warnings.length>0){m(`\nDegraded ${t.warnings.length} files (measured, but excluded from cross-file matching):\n`);for(let e of t.warnings.slice(0,10))m(`- ${e}\n`);t.warnings.length>10&&m(`- ... ${t.warnings.length-10} more\n`)}if(t.errors.length>0){m(`\nSkipped ${t.errors.length} files or directories:\n`);for(let e of t.errors.slice(0,10))m(`- ${e}\n`);t.errors.length>10&&m(`- ... ${t.errors.length-10} more\n`)}}function M(e){let t=[];if(e.worstFunction){let n=e.worstFunction;t.push(`worst function ${n.name} (L${n.startLine}-${n.endLine}) cognitive ${n.cognitiveComplexity}, NCSS ${n.ncss}, nesting ${n.nestingDepth}`)}if(e.duplicatedLineCount>0){let n=e.crossFilePartners.length>0?`, shared with ${e.crossFilePartners.slice(0,3).join(`, `)}${e.crossFilePartners.length>3?`, ...`:``}`:``;t.push(`duplicated lines ${e.duplicatedLineCount} (${Math.round(e.duplicatedLineRatio*100)}%${n})`)}return t.push(`file NCSS ${e.ncss}`),`${e.file} (score ${e.score.toFixed(2)}): ${t.join(`; `)}`}function N(e){let t=0,n=0,r=0,i=0;for(let a of e)t+=a.metrics.functions.length,n+=a.metrics.lines.code,r=Math.max(r,a.metrics.maxCognitiveComplexity),i+=a.metrics.ncssCount;return{fileCount:e.length,functionCount:t,linesOfCode:n,maxCognitiveComplexity:r,ncssCount:i}}function P(e){if(e===`off`)return 1/0;let t=e.trim()===``?NaN:Number(e);if(!Number.isFinite(t)||t<0)throw new b(`Expected a non-negative number or "off".`);return t}function F(e){let t=L(e);if(t>100)throw new b(`Expected an integer between 1 and 100.`);return t}function I(e){if(!/^\d+$/u.test(e))throw new b(`Expected a non-negative integer.`);let t=Number(e);if(!Number.isSafeInteger(t)||t<0)throw new b(`Expected a non-negative integer.`);return t}function L(e){let t=v(e);if(t===void 0)throw new b(`Expected a positive integer.`);return t}
5
5
  //# sourceMappingURL=cli.js.map
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { Command, InvalidArgumentError } from 'commander';\nimport { type CliOptions, configFileName, loadConfig, type ResolvedOptions, resolveOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { runDiffCommand, type DiffCliOptions } from './diffCommand.js';\nimport { loadExclusion } from './exclusion.js';\nimport {\n addCrossFileDuplication,\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatError,\n formatPath,\n resolveTarget,\n scanTarget,\n writeStderr,\n writeStdout,\n type FileMetrics,\n type ScanResult,\n} from './scan.js';\nimport type { FunctionMetrics } from './types.js';\n\n/** The worst (highest-cognitive-complexity) function of a file, reported as the ranking evidence. */\ninterface WorstFunction {\n name: string;\n startLine: number;\n endLine: number;\n cognitiveComplexity: number;\n ncss: number;\n nestingDepth: number;\n}\n\n/**\n * One ranked refactoring candidate. The score is the sum of the file's repo-relative percentile\n * ranks (each in [0, 1)) over three dimensions: worst-function cognitive complexity, duplicated\n * lines (within-file and cross-file combined), and file NCSS. Ranking is relative to the scanned\n * project, so no absolute threshold is involved.\n */\ninterface RankedFile {\n file: string;\n score: number;\n worstFunction?: WorstFunction;\n /** Distinct lines covered by within-file duplicate blocks or cross-file duplicate occurrences. */\n duplicatedLineCount: number;\n /** duplicatedLineCount / code lines (0 when the file has no code). */\n duplicatedLineRatio: number;\n /** Other files sharing cross-file duplicate blocks; filled only for the reported top files. */\n crossFilePartners: string[];\n ncss: number;\n codeLines: number;\n}\n\n/** Caps how many cross-file partners a ranked file lists so the report stays scannable. */\nconst maxCrossFilePartners = 3;\n\n// oxlint-disable-next-line unicorn/prefer-top-level-await -- CommonJS build output cannot preserve top-level await.\nvoid main().catch((error: unknown) => {\n writeStderr(`Error: ${formatError(error)}\\n`);\n process.exitCode = 1;\n});\n\n/** Registers the options shared by the ranking command and the diff gate. */\nfunction addSharedOptions(command: Command): Command {\n return command\n .option('--config <path>', `config file to use instead of the auto-detected ${configFileName}`)\n .option(\n '--duplication-min-tokens <number>',\n 'minimum normalized token count for a duplicate region (default 40)',\n parsePositiveInteger\n )\n .option(\n '--duplication-max-gap-tokens <number>',\n 'maximum token gap merged into one gapped clone group; 0 disables merging (default 30)',\n parseNonNegativeInteger\n )\n .option(\n '--duplication-min-similarity-percent <number>',\n 'minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)',\n parsePercentInteger\n )\n .option('--include-tests', 'include test files and test directories')\n .option('--json', 'print JSON output');\n}\n\nasync function main(): Promise<void> {\n const program = addSharedOptions(\n new Command()\n .name('code-gauge')\n .description('Rank the files of a project by refactoring priority.')\n .argument('[target]', 'file or directory to measure', '.')\n )\n .option('--top <number>', 'number of top-ranked files to report (default: 10)', parsePositiveInteger)\n .option('--fail-on-error', 'exit with code 1 when files or directories cannot be scanned');\n\n program.action(async (target: string, cliOptions: CliOptions) => {\n const resolvedTarget = resolveTarget(target);\n const config = await loadConfig(cliOptions.config, await configSearchDirectory(resolvedTarget));\n const options = resolveOptions(cliOptions, config);\n const searchDirectory = await configSearchDirectory(resolvedTarget);\n const result = await scanTarget(resolvedTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n addCrossFileDuplication(result, options);\n const rankedFiles = rankFiles(result, options.top);\n\n if (options.json) {\n printJson(result, rankedFiles, options);\n } else {\n printTextReport(resolvedTarget, result, rankedFiles, options);\n }\n\n if (result.fatalError || (options.failOnError && result.errors.length > 0)) {\n process.exitCode = 1;\n }\n });\n\n addSharedOptions(\n program\n .command('diff')\n .description(\n 'Gate the working tree against a base ref: report only metric regressions in changed files (exit 1 on violations)'\n )\n .argument('[target]', 'directory whose changed files are gated', '.')\n .requiredOption('--base <ref>', 'base git ref; changes are measured against its merge-base with HEAD')\n )\n .option('--full', 'also print the passing gate values of every checked function and file')\n .action(async (target: string, _cliOptions: DiffCliOptions, command: Command) => {\n // Options sharing a name with a root option (--json, --config, ...) land in the root's\n // option store, so the merged view is required to see them.\n await runDiffCommand(target, command.optsWithGlobals() as DiffCliOptions);\n });\n\n await program.parseAsync();\n}\n\nfunction rankFiles(result: ScanResult, top: number): RankedFile[] {\n const candidates = result.files.map(({ file, metrics }) => {\n const formattedFile = formatPath(file, result.displayRoot);\n const duplicatedLines = collectDuplicatedLineNumbers(metrics, result.crossFileDuplication, formattedFile);\n return {\n file: formattedFile,\n worstFunction: findWorstFunction(metrics.functions),\n duplicatedLineCount: duplicatedLines.size,\n duplicatedLineRatio: metrics.lines.code === 0 ? 0 : duplicatedLines.size / metrics.lines.code,\n crossFilePartners: [] as string[],\n ncss: metrics.ncssCount,\n codeLines: metrics.lines.code,\n };\n });\n\n const cognitivePercentile = makePercentile(candidates.map((c) => c.worstFunction?.cognitiveComplexity ?? 0));\n const duplicationPercentile = makePercentile(candidates.map((c) => c.duplicatedLineCount));\n const ncssPercentile = makePercentile(candidates.map((c) => c.ncss));\n\n const ranked = candidates\n .map((candidate) => ({\n ...candidate,\n score:\n cognitivePercentile(candidate.worstFunction?.cognitiveComplexity ?? 0) +\n duplicationPercentile(candidate.duplicatedLineCount) +\n ncssPercentile(candidate.ncss),\n }))\n .toSorted(\n (left, right) => right.score - left.score || right.ncss - left.ncss || left.file.localeCompare(right.file)\n );\n // Partner evidence is attached only to the files that will be reported: expanding partners for\n // every scanned file first would retain TH(F^2) strings when one clone group spans F files, and\n // rescanning all groups per file would be O(files x groups) on the post-scan ranking step.\n attachCrossFilePartners(ranked.slice(0, top), result.crossFileDuplication);\n return ranked;\n}\n\n/** One pass over the groups fills the reported files' partner lists (other files sharing a group). */\nfunction attachCrossFilePartners(\n reportedFiles: RankedFile[],\n crossFileDuplication: CrossFileDuplicationMetrics | undefined\n): void {\n if (!crossFileDuplication) {\n return;\n }\n const partnersByFile = new Map(reportedFiles.map((ranked) => [ranked.file, new Set<string>()]));\n for (const group of crossFileDuplication.groups) {\n for (const file of group.files) {\n const partners = partnersByFile.get(file);\n if (!partners) {\n continue;\n }\n for (const partner of group.files) {\n if (partner !== file) {\n partners.add(partner);\n }\n }\n }\n }\n for (const ranked of reportedFiles) {\n ranked.crossFilePartners = [...(partnersByFile.get(ranked.file) ?? [])].toSorted();\n }\n}\n\n/**\n * Percentile rank within the scanned project: the fraction of files with a strictly smaller\n * value, in [0, 1). Relative ranking needs no absolute threshold, which sidesteps the metric\n * calibration problem entirely — the top of the list is worth refactoring first regardless of\n * where any cutoff would sit.\n */\nfunction makePercentile(values: number[]): (value: number) => number {\n const sorted = values.toSorted((left, right) => left - right);\n return (value: number) => {\n let low = 0;\n let high = sorted.length;\n while (low < high) {\n const middle = (low + high) >> 1;\n if ((sorted[middle] as number) < value) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return sorted.length === 0 ? 0 : low / sorted.length;\n };\n}\n\n/** The highest-cognitive-complexity function; NCSS breaks ties so the larger body is reported. */\nfunction findWorstFunction(functions: FunctionMetrics[]): WorstFunction | undefined {\n let worst: FunctionMetrics | undefined;\n for (const fn of functions) {\n if (\n !worst ||\n fn.cognitiveComplexity > worst.cognitiveComplexity ||\n (fn.cognitiveComplexity === worst.cognitiveComplexity && fn.ncss > worst.ncss)\n ) {\n worst = fn;\n }\n }\n if (!worst) {\n return undefined;\n }\n return {\n name: worst.name ?? '<anonymous>',\n startLine: worst.startLine,\n endLine: worst.endLine,\n cognitiveComplexity: worst.cognitiveComplexity,\n ncss: worst.ncss,\n nestingDepth: worst.nestingDepth,\n };\n}\n\nfunction printJson(result: ScanResult, rankedFiles: RankedFile[], options: ResolvedOptions): void {\n const reportedFiles = rankedFiles.slice(0, options.top);\n writeStdout(\n JSON.stringify(\n {\n summary: summarize(result.files),\n totalRankedFiles: rankedFiles.length,\n truncated: reportedFiles.length < rankedFiles.length,\n files: reportedFiles,\n errors: result.errors,\n warnings: result.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n\nfunction printTextReport(\n target: string,\n result: ScanResult,\n rankedFiles: RankedFile[],\n options: ResolvedOptions\n): void {\n if (result.fatalError) {\n writeStderr(`Error: ${result.fatalError}\\n`);\n return;\n }\n\n const summary = summarize(result.files);\n writeStdout(\n `Measured ${summary.fileCount} files under ${target} (code LOC ${summary.linesOfCode}, NCSS ${summary.ncssCount}, functions ${summary.functionCount})\\n`\n );\n\n if (rankedFiles.length === 0) {\n writeStdout('No measurable files found.\\n');\n } else {\n const reportedFiles = rankedFiles.slice(0, options.top);\n const totalSuffix = rankedFiles.length > reportedFiles.length ? ` of ${rankedFiles.length}` : '';\n writeStdout(`\\nRefactoring candidates (top ${reportedFiles.length}${totalSuffix}):\\n`);\n for (const [index, ranked] of reportedFiles.entries()) {\n writeStdout(`${index + 1}. ${formatRankedFile(ranked)}\\n`);\n }\n }\n\n if (result.warnings.length > 0) {\n writeStderr(`\\nDegraded ${result.warnings.length} files (measured, but excluded from cross-file matching):\\n`);\n for (const warning of result.warnings.slice(0, 10)) {\n writeStderr(`- ${warning}\\n`);\n }\n if (result.warnings.length > 10) {\n writeStderr(`- ... ${result.warnings.length - 10} more\\n`);\n }\n }\n\n if (result.errors.length > 0) {\n writeStderr(`\\nSkipped ${result.errors.length} files or directories:\\n`);\n for (const error of result.errors.slice(0, 10)) {\n writeStderr(`- ${error}\\n`);\n }\n if (result.errors.length > 10) {\n writeStderr(`- ... ${result.errors.length - 10} more\\n`);\n }\n }\n}\n\n/** One ranked file as a single line: the location, the evidence, and where the duplication points. */\nfunction formatRankedFile(ranked: RankedFile): string {\n const reasons: string[] = [];\n if (ranked.worstFunction) {\n const fn = ranked.worstFunction;\n reasons.push(\n `worst function ${fn.name} (L${fn.startLine}-${fn.endLine}) cognitive ${fn.cognitiveComplexity}, NCSS ${fn.ncss}, nesting ${fn.nestingDepth}`\n );\n }\n if (ranked.duplicatedLineCount > 0) {\n const partnersSuffix =\n ranked.crossFilePartners.length > 0\n ? `, shared with ${ranked.crossFilePartners.slice(0, maxCrossFilePartners).join(', ')}${ranked.crossFilePartners.length > maxCrossFilePartners ? ', ...' : ''}`\n : '';\n reasons.push(\n `duplicated lines ${ranked.duplicatedLineCount} (${Math.round(ranked.duplicatedLineRatio * 100)}%${partnersSuffix})`\n );\n }\n reasons.push(`file NCSS ${ranked.ncss}`);\n return `${ranked.file} (score ${ranked.score.toFixed(2)}): ${reasons.join('; ')}`;\n}\n\nfunction summarize(files: FileMetrics[]): {\n fileCount: number;\n functionCount: number;\n linesOfCode: number;\n maxCognitiveComplexity: number;\n ncssCount: number;\n} {\n let functionCount = 0;\n let linesOfCode = 0;\n let maxCognitiveComplexity = 0;\n let ncssCount = 0;\n\n for (const file of files) {\n functionCount += file.metrics.functions.length;\n linesOfCode += file.metrics.lines.code;\n maxCognitiveComplexity = Math.max(maxCognitiveComplexity, file.metrics.maxCognitiveComplexity);\n ncssCount += file.metrics.ncssCount;\n }\n\n return { fileCount: files.length, functionCount, linesOfCode, maxCognitiveComplexity, ncssCount };\n}\n\nfunction parsePercentInteger(value: string): number {\n const parsed = parsePositiveInteger(value);\n if (parsed > 100) {\n throw new InvalidArgumentError('Expected an integer between 1 and 100.');\n }\n return parsed;\n}\n\nfunction parseNonNegativeInteger(value: string): number {\n if (!/^\\d+$/u.test(value)) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n return parsed;\n}\n\nfunction parsePositiveInteger(value: string): number {\n if (!/^[1-9]\\d*$/u.test(value)) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 1) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n return parsed;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\n\nimport { Command, InvalidArgumentError } from 'commander';\nimport { getErrorMessage, parsePositiveInteger } from '@willbooster/shared-lib';\nimport { type CliOptions, configFileName, loadConfig, type ResolvedOptions, resolveOptions } from './cliConfig.js';\nimport type { CrossFileDuplicationMetrics } from './crossFileDuplication.js';\nimport { runCheckCommand, type CheckCliOptions } from './checkCommand.js';\nimport { loadExclusion } from './exclusion.js';\nimport {\n addCrossFileDuplication,\n collectDuplicatedLineNumbers,\n configSearchDirectory,\n formatPath,\n resolveTarget,\n scanTarget,\n writeStderr,\n writeStdout,\n type FileMetrics,\n type ScanResult,\n} from './scan.js';\nimport { duplicationThreshold, fileThresholds, functionThresholds, thresholds } from './thresholds.js';\nimport type { FunctionMetrics } from './types.js';\n\n/** The worst (highest-cognitive-complexity) function of a file, reported as the ranking evidence. */\ninterface WorstFunction {\n name: string;\n startLine: number;\n endLine: number;\n cognitiveComplexity: number;\n ncss: number;\n nestingDepth: number;\n}\n\n/**\n * One ranked refactoring candidate. The score is the sum of the file's repo-relative percentile\n * ranks (each in [0, 1)) over three dimensions: worst-function cognitive complexity, duplicated\n * lines (within-file and cross-file combined), and file NCSS. Ranking is relative to the scanned\n * project, so no absolute threshold is involved.\n */\ninterface RankedFile {\n file: string;\n score: number;\n worstFunction?: WorstFunction;\n /** Distinct lines covered by within-file duplicate blocks or cross-file duplicate occurrences. */\n duplicatedLineCount: number;\n /** duplicatedLineCount / code lines (0 when the file has no code). */\n duplicatedLineRatio: number;\n /** Other files sharing cross-file duplicate blocks; filled only for the reported top files. */\n crossFilePartners: string[];\n ncss: number;\n codeLines: number;\n}\n\n/** Caps how many cross-file partners a ranked file lists so the report stays scannable. */\nconst maxCrossFilePartners = 3;\n\n// oxlint-disable-next-line unicorn/prefer-top-level-await -- CommonJS build output cannot preserve top-level await.\nvoid main().catch((error: unknown) => {\n writeStderr(`Error: ${getErrorMessage(error)}\\n`);\n process.exitCode = 1;\n});\n\nasync function main(): Promise<void> {\n const program = new Command()\n .name('code-gauge')\n .description('Measure code metrics: rank refactoring candidates (the default command) or check thresholds.');\n\n addSharedOptions(\n program\n .command('rank', { isDefault: true })\n .description('Rank the files of a project by refactoring priority')\n .argument('[target]', 'file or directory to measure', '.')\n )\n .option('--top <number>', 'number of top-ranked files to report (default: 10)', parsePositiveIntegerOption)\n .option('--fail-on-error', 'exit with code 1 when files or directories cannot be scanned')\n .action(runRankCommand);\n\n const check = addSharedOptions(\n program\n .command('check')\n .description(\n 'Report every exceeded threshold (exit 1 on violations, 2 when files cannot be measured); with --base, only in what the change touches'\n )\n .argument('[target]', 'file or directory to check', '.')\n ).option('--base <ref>', 'check only what the working tree changed since the merge-base of this git ref and HEAD');\n for (const { key, defaultLimit } of thresholds) {\n check.option(\n `--${toKebabCase(key)} <number>`,\n `${describeLimit(key)} (default ${defaultLimit}); \"off\" disables it`,\n parseLimit\n );\n }\n // Exit code 1 means violations, so a rejected command line must not use it.\n check.exitOverride((error) => process.exit(error.exitCode === 0 ? 0 : 2));\n check.action(async (target: string, cliOptions: CheckCliOptions & Record<string, unknown>) => {\n const cliLimits = Object.fromEntries(\n thresholds.flatMap(({ key }) => (cliOptions[key] === undefined ? [] : [[key, cliOptions[key] as number]]))\n );\n await runCheckCommand(target, cliOptions, cliLimits);\n });\n\n await program.parseAsync();\n}\n\n/** Registers the options shared by the ranking and check commands. */\nfunction addSharedOptions(command: Command): Command {\n return command\n .option('--config <path>', `config file to use instead of the auto-detected ${configFileName}`)\n .option(\n '--duplication-min-tokens <number>',\n 'minimum normalized token count for a duplicate region (default 40)',\n parsePositiveIntegerOption\n )\n .option(\n '--duplication-max-gap-tokens <number>',\n 'maximum token gap merged into one gapped clone group; 0 disables merging (default 30)',\n parseNonNegativeInteger\n )\n .option(\n '--duplication-min-similarity-percent <number>',\n 'minimum similarity percent (1-100) for near-miss (Type-3) clone blocks; 100 reports exact matches only (default 70)',\n parsePercentInteger\n )\n .option('--include-tests', 'include test files and test directories')\n .option('--json', 'print JSON output');\n}\n\nfunction describeLimit(key: string): string {\n if (key === duplicationThreshold.key) {\n return 'line count from which a duplicated block is a violation';\n }\n const fileThreshold = fileThresholds.find((threshold) => threshold.key === key);\n return fileThreshold\n ? `maximum ${fileThreshold.label}`\n : `maximum ${functionThresholds.find((threshold) => threshold.key === key)?.label} of a function`;\n}\n\nfunction toKebabCase(key: string): string {\n return key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`);\n}\n\nasync function runRankCommand(target: string, cliOptions: CliOptions): Promise<void> {\n const resolvedTarget = resolveTarget(target);\n const searchDirectory = await configSearchDirectory(resolvedTarget);\n const config = await loadConfig(cliOptions.config, searchDirectory);\n const options = resolveOptions(cliOptions, config);\n const result = await scanTarget(resolvedTarget, {\n ...options,\n loadExclusion: (absolutePaths) => loadExclusion(searchDirectory, options.exclude, absolutePaths),\n });\n addCrossFileDuplication(result, options);\n const rankedFiles = rankFiles(result, options.top);\n\n if (options.json) {\n printJson(result, rankedFiles, options);\n } else {\n printTextReport(resolvedTarget, result, rankedFiles, options);\n }\n\n if (result.fatalError || (options.failOnError && result.errors.length > 0)) {\n process.exitCode = 1;\n }\n}\n\nfunction rankFiles(result: ScanResult, top: number): RankedFile[] {\n const candidates = result.files.map(({ file, metrics }) => {\n const formattedFile = formatPath(file, result.displayRoot);\n const duplicatedLines = collectDuplicatedLineNumbers(metrics, result.crossFileDuplication, formattedFile);\n return {\n file: formattedFile,\n worstFunction: findWorstFunction(metrics.functions),\n duplicatedLineCount: duplicatedLines.size,\n duplicatedLineRatio: metrics.lines.code === 0 ? 0 : duplicatedLines.size / metrics.lines.code,\n crossFilePartners: [] as string[],\n ncss: metrics.ncssCount,\n codeLines: metrics.lines.code,\n };\n });\n\n const cognitivePercentile = makePercentile(candidates.map((c) => c.worstFunction?.cognitiveComplexity ?? 0));\n const duplicationPercentile = makePercentile(candidates.map((c) => c.duplicatedLineCount));\n const ncssPercentile = makePercentile(candidates.map((c) => c.ncss));\n\n const ranked = candidates\n .map((candidate) => ({\n ...candidate,\n score:\n cognitivePercentile(candidate.worstFunction?.cognitiveComplexity ?? 0) +\n duplicationPercentile(candidate.duplicatedLineCount) +\n ncssPercentile(candidate.ncss),\n }))\n .toSorted(\n (left, right) => right.score - left.score || right.ncss - left.ncss || left.file.localeCompare(right.file)\n );\n // Partner evidence is attached only to the files that will be reported: expanding partners for\n // every scanned file first would retain TH(F^2) strings when one clone group spans F files, and\n // rescanning all groups per file would be O(files x groups) on the post-scan ranking step.\n attachCrossFilePartners(ranked.slice(0, top), result.crossFileDuplication);\n return ranked;\n}\n\n/** One pass over the groups fills the reported files' partner lists (other files sharing a group). */\nfunction attachCrossFilePartners(\n reportedFiles: RankedFile[],\n crossFileDuplication: CrossFileDuplicationMetrics | undefined\n): void {\n if (!crossFileDuplication) {\n return;\n }\n const partnersByFile = new Map(reportedFiles.map((ranked) => [ranked.file, new Set<string>()]));\n for (const group of crossFileDuplication.groups) {\n for (const file of group.files) {\n const partners = partnersByFile.get(file);\n if (!partners) {\n continue;\n }\n for (const partner of group.files) {\n if (partner !== file) {\n partners.add(partner);\n }\n }\n }\n }\n for (const ranked of reportedFiles) {\n ranked.crossFilePartners = [...(partnersByFile.get(ranked.file) ?? [])].toSorted();\n }\n}\n\n/**\n * Percentile rank within the scanned project: the fraction of files with a strictly smaller\n * value, in [0, 1). Relative ranking needs no absolute threshold, which sidesteps the metric\n * calibration problem entirely — the top of the list is worth refactoring first regardless of\n * where any cutoff would sit.\n */\nfunction makePercentile(values: number[]): (value: number) => number {\n const sorted = values.toSorted((left, right) => left - right);\n return (value: number) => {\n let low = 0;\n let high = sorted.length;\n while (low < high) {\n const middle = (low + high) >> 1;\n if ((sorted[middle] as number) < value) {\n low = middle + 1;\n } else {\n high = middle;\n }\n }\n return sorted.length === 0 ? 0 : low / sorted.length;\n };\n}\n\n/** The highest-cognitive-complexity function; NCSS breaks ties so the larger body is reported. */\nfunction findWorstFunction(functions: FunctionMetrics[]): WorstFunction | undefined {\n let worst: FunctionMetrics | undefined;\n for (const fn of functions) {\n if (\n !worst ||\n fn.cognitiveComplexity > worst.cognitiveComplexity ||\n (fn.cognitiveComplexity === worst.cognitiveComplexity && fn.ncss > worst.ncss)\n ) {\n worst = fn;\n }\n }\n if (!worst) {\n return undefined;\n }\n return {\n name: worst.name ?? '<anonymous>',\n startLine: worst.startLine,\n endLine: worst.endLine,\n cognitiveComplexity: worst.cognitiveComplexity,\n ncss: worst.ncss,\n nestingDepth: worst.nestingDepth,\n };\n}\n\nfunction printJson(result: ScanResult, rankedFiles: RankedFile[], options: ResolvedOptions): void {\n const reportedFiles = rankedFiles.slice(0, options.top);\n writeStdout(\n JSON.stringify(\n {\n summary: summarize(result.files),\n totalRankedFiles: rankedFiles.length,\n truncated: reportedFiles.length < rankedFiles.length,\n files: reportedFiles,\n errors: result.errors,\n warnings: result.warnings,\n },\n undefined,\n 2\n ) + '\\n'\n );\n}\n\nfunction printTextReport(\n target: string,\n result: ScanResult,\n rankedFiles: RankedFile[],\n options: ResolvedOptions\n): void {\n if (result.fatalError) {\n writeStderr(`Error: ${result.fatalError}\\n`);\n return;\n }\n\n const summary = summarize(result.files);\n writeStdout(\n `Measured ${summary.fileCount} files under ${target} (code LOC ${summary.linesOfCode}, NCSS ${summary.ncssCount}, functions ${summary.functionCount})\\n`\n );\n\n if (rankedFiles.length === 0) {\n writeStdout('No measurable files found.\\n');\n } else {\n const reportedFiles = rankedFiles.slice(0, options.top);\n const totalSuffix = rankedFiles.length > reportedFiles.length ? ` of ${rankedFiles.length}` : '';\n writeStdout(`\\nRefactoring candidates (top ${reportedFiles.length}${totalSuffix}):\\n`);\n for (const [index, ranked] of reportedFiles.entries()) {\n writeStdout(`${index + 1}. ${formatRankedFile(ranked)}\\n`);\n }\n }\n\n if (result.warnings.length > 0) {\n writeStderr(`\\nDegraded ${result.warnings.length} files (measured, but excluded from cross-file matching):\\n`);\n for (const warning of result.warnings.slice(0, 10)) {\n writeStderr(`- ${warning}\\n`);\n }\n if (result.warnings.length > 10) {\n writeStderr(`- ... ${result.warnings.length - 10} more\\n`);\n }\n }\n\n if (result.errors.length > 0) {\n writeStderr(`\\nSkipped ${result.errors.length} files or directories:\\n`);\n for (const error of result.errors.slice(0, 10)) {\n writeStderr(`- ${error}\\n`);\n }\n if (result.errors.length > 10) {\n writeStderr(`- ... ${result.errors.length - 10} more\\n`);\n }\n }\n}\n\n/** One ranked file as a single line: the location, the evidence, and where the duplication points. */\nfunction formatRankedFile(ranked: RankedFile): string {\n const reasons: string[] = [];\n if (ranked.worstFunction) {\n const fn = ranked.worstFunction;\n reasons.push(\n `worst function ${fn.name} (L${fn.startLine}-${fn.endLine}) cognitive ${fn.cognitiveComplexity}, NCSS ${fn.ncss}, nesting ${fn.nestingDepth}`\n );\n }\n if (ranked.duplicatedLineCount > 0) {\n const partnersSuffix =\n ranked.crossFilePartners.length > 0\n ? `, shared with ${ranked.crossFilePartners.slice(0, maxCrossFilePartners).join(', ')}${ranked.crossFilePartners.length > maxCrossFilePartners ? ', ...' : ''}`\n : '';\n reasons.push(\n `duplicated lines ${ranked.duplicatedLineCount} (${Math.round(ranked.duplicatedLineRatio * 100)}%${partnersSuffix})`\n );\n }\n reasons.push(`file NCSS ${ranked.ncss}`);\n return `${ranked.file} (score ${ranked.score.toFixed(2)}): ${reasons.join('; ')}`;\n}\n\nfunction summarize(files: FileMetrics[]): {\n fileCount: number;\n functionCount: number;\n linesOfCode: number;\n maxCognitiveComplexity: number;\n ncssCount: number;\n} {\n let functionCount = 0;\n let linesOfCode = 0;\n let maxCognitiveComplexity = 0;\n let ncssCount = 0;\n\n for (const file of files) {\n functionCount += file.metrics.functions.length;\n linesOfCode += file.metrics.lines.code;\n maxCognitiveComplexity = Math.max(maxCognitiveComplexity, file.metrics.maxCognitiveComplexity);\n ncssCount += file.metrics.ncssCount;\n }\n\n return { fileCount: files.length, functionCount, linesOfCode, maxCognitiveComplexity, ncssCount };\n}\n\nfunction parseLimit(value: string): number {\n if (value === 'off') {\n return Infinity;\n }\n const parsed = value.trim() === '' ? Number.NaN : Number(value);\n if (!Number.isFinite(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative number or \"off\".');\n }\n return parsed;\n}\n\nfunction parsePercentInteger(value: string): number {\n const parsed = parsePositiveIntegerOption(value);\n if (parsed > 100) {\n throw new InvalidArgumentError('Expected an integer between 1 and 100.');\n }\n return parsed;\n}\n\nfunction parseNonNegativeInteger(value: string): number {\n if (!/^\\d+$/u.test(value)) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n\n const parsed = Number(value);\n if (!Number.isSafeInteger(parsed) || parsed < 0) {\n throw new InvalidArgumentError('Expected a non-negative integer.');\n }\n return parsed;\n}\n\nfunction parsePositiveIntegerOption(value: string): number {\n const parsed = parsePositiveInteger(value);\n if (parsed === undefined) {\n throw new InvalidArgumentError('Expected a positive integer.');\n }\n return parsed;\n}\n"],"mappings":"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"}
@@ -1,2 +1,2 @@
1
- "use strict";const e=require("./_virtual/_rolldown/runtime.cjs"),t=require("./duplication.cjs"),n=require("./regressionGate.cjs");let r=require("node:fs/promises"),i=require("node:path");i=e.__toESM(i,1);const a=`code-gauge.config.json`;function o(e,{config:n,directory:r}){return{duplication:{minTokens:e.duplicationMinTokens??n.duplication?.minTokens??t.defaultDuplicationOptions.minTokens,maxGapTokens:e.duplicationMaxGapTokens??n.duplication?.maxGapTokens??t.defaultDuplicationOptions.maxGapTokens,minSimilarityPercent:e.duplicationMinSimilarityPercent??n.duplication?.minSimilarityPercent??t.defaultDuplicationOptions.minSimilarityPercent},exclude:{patterns:n.exclude??[],root:r},top:e.top??n.rank?.top??10,includeTests:e.includeTests??n.includeTests??!1,failOnError:e.failOnError??n.failOnError??!1,json:e.json??!1}}function s({config:e}){return{newFunction:{...n.defaultGateOptions.newFunction,...e.gate?.newFunction},tolerance:{...n.defaultGateOptions.tolerance,...e.gate?.tolerance},matchSimilarityPercent:e.gate?.matchSimilarityPercent??n.defaultGateOptions.matchSimilarityPercent}}async function c(e,t){let n=e??await l(t);if(!n)return{config:{},directory:t};let a;try{a=await(0,r.readFile)(n,`utf8`)}catch(r){if(e)throw Error(`Cannot read config file "${n}": ${S(r)}`);return{config:{},directory:t}}let o;try{o=JSON.parse(a)}catch(e){throw Error(`Invalid JSON in config file "${n}": ${S(e)}`)}return{config:d(o,n),directory:i.default.dirname(i.default.resolve(n))}}async function l(e){let t=e;for(;;){let e=i.default.join(t,a);if(await u(e))return e;let n=i.default.dirname(t);if(n===t)return;t=n}}async function u(e){try{return(await(0,r.stat)(e)).isFile()}catch{return!1}}function d(e,t){if(typeof e!=`object`||!e||Array.isArray(e))throw Error(`Config file "${t}" must contain a JSON object.`);let n=e,r=/* @__PURE__ */ new Set([`duplication`,`rank`,`gate`,`exclude`,`includeTests`,`failOnError`]);for(let e of Object.keys(n))if(!r.has(e))throw Error(`Config file "${t}": unknown setting "${e}" (expected ${[...r].join(`, `)}).`);let i={};n.duplication!==void 0&&(i.duplication=g(n.duplication,t)),n.rank!==void 0&&(i.rank=p(n.rank,t)),n.gate!==void 0&&(i.gate=m(n.gate,t)),n.exclude!==void 0&&(i.exclude=f(n.exclude,t));for(let e of[`includeTests`,`failOnError`])n[e]!==void 0&&(i[e]=x(n[e],e,t));return i}function f(e,t){if(!Array.isArray(e)||!e.every(e=>typeof e==`string`&&e!==``))throw TypeError(`Config file "${t}": "exclude" must be an array of non-empty glob patterns.`);return e}function p(e,t){if(typeof e!=`object`||!e||Array.isArray(e))throw Error(`Config file "${t}": "rank" must be an object.`);let n={};for(let[r,i]of Object.entries(e)){if(r!==`top`)throw Error(`Config file "${t}": unknown setting "${r}" in "rank" (expected top).`);n.top=y(i,`rank.top`,t)}return n}function m(e,t){if(typeof e!=`object`||!e||Array.isArray(e))throw Error(`Config file "${t}": "gate" must be an object.`);let r={};for(let[i,a]of Object.entries(e))if(i===`newFunction`)r.newFunction=h(a,`gate.newFunction`,Object.keys(n.defaultGateOptions.newFunction),t,v);else if(i===`tolerance`)r.tolerance=h(a,`gate.tolerance`,Object.keys(n.defaultGateOptions.tolerance),t,_);else if(i===`matchSimilarityPercent`){let e=y(a,`gate.matchSimilarityPercent`,t);if(e>100)throw Error(`Config file "${t}": "gate.matchSimilarityPercent" must be between 1 and 100.`);r.matchSimilarityPercent=e}else throw Error(`Config file "${t}": unknown setting "${i}" in "gate" (expected newFunction, tolerance, or matchSimilarityPercent).`);return r}function h(e,t,n,r,i){if(typeof e!=`object`||!e||Array.isArray(e))throw Error(`Config file "${r}": "${t}" must be an object.`);let a={};for(let[o,s]of Object.entries(e)){if(!n.includes(o))throw Error(`Config file "${r}": unknown setting "${o}" in "${t}" (expected ${n.join(`, `)}).`);a[o]=i(s,`${t}.${o}`,r)}return a}function g(e,t){if(typeof e!=`object`||!e||Array.isArray(e))throw Error(`Config file "${t}": "duplication" must be an object.`);let n={};for(let[r,i]of Object.entries(e))if(r===`minTokens`)n.minTokens=y(i,`duplication.minTokens`,t);else if(r===`maxGapTokens`)n.maxGapTokens=v(i,`duplication.maxGapTokens`,t);else if(r===`minSimilarityPercent`){let e=y(i,`duplication.minSimilarityPercent`,t);if(e>100)throw Error(`Config file "${t}": "duplication.minSimilarityPercent" must be between 1 and 100.`);n.minSimilarityPercent=e}else throw Error(`Config file "${t}": unknown setting "${r}" in "duplication" (expected minTokens, maxGapTokens, or minSimilarityPercent).`);return n}function _(e,t,n){return b(e,t,n,Number.isFinite,`a non-negative number`)}function v(e,t,n){return b(e,t,n,Number.isSafeInteger,`a non-negative integer`)}function y(e,t,n){let r=b(e,t,n,Number.isSafeInteger,`a positive integer`);if(r<1)throw Error(`Config file "${n}": "${t}" must be a positive integer.`);return r}function b(e,t,n,r,i){if(typeof e!=`number`||!r(e)||e<0)throw Error(`Config file "${n}": "${t}" must be ${i}.`);return e}function x(e,t,n){if(typeof e!=`boolean`)throw TypeError(`Config file "${n}": "${t}" must be a boolean.`);return e}function S(e){return e instanceof Error?e.message:String(e)}exports.configFileName=a,exports.loadConfig=c,exports.resolveGateOptions=s,exports.resolveOptions=o;
1
+ "use strict";const e=require("./_virtual/_rolldown/runtime.cjs"),t=require("./duplication.cjs"),n=require("./languages.cjs"),r=require("./thresholds.cjs");let i=require("@willbooster/shared-lib"),a=require("node:fs/promises"),o=require("node:path");o=e.__toESM(o,1);const s=`code-gauge.config.json`;function c(e,{config:n,directory:r}){return{duplication:{minTokens:e.duplicationMinTokens??n.duplication?.minTokens??t.defaultDuplicationOptions.minTokens,maxGapTokens:e.duplicationMaxGapTokens??n.duplication?.maxGapTokens??t.defaultDuplicationOptions.maxGapTokens,minSimilarityPercent:e.duplicationMinSimilarityPercent??n.duplication?.minSimilarityPercent??t.defaultDuplicationOptions.minSimilarityPercent},exclude:{patterns:n.exclude??[],root:r},top:e.top??n.rank?.top??10,includeTests:e.includeTests??n.includeTests??!1,failOnError:e.failOnError??n.failOnError??!1,json:e.json??!1}}async function l(e,t){let n=e??await u(t);if(!n)return{config:{},directory:t};let r;try{r=await(0,a.readFile)(n,`utf8`)}catch(r){if(e)throw Error(`Cannot read config file "${n}": ${(0,i.getErrorMessage)(r)}`);return{config:{},directory:t}}let s;try{s=JSON.parse(r)}catch(e){throw Error(`Invalid JSON in config file "${n}": ${(0,i.getErrorMessage)(e)}`)}return{config:f(s,n),directory:o.default.dirname(o.default.resolve(n))}}async function u(e){let t=e;for(;;){let e=o.default.join(t,s);if(await d(e))return e;let n=o.default.dirname(t);if(n===t)return;t=n}}async function d(e){try{return(await(0,a.stat)(e)).isFile()}catch{return!1}}function f(e,t){if(!(0,i.isRecord)(e))throw Error(`Config file "${t}" must contain a JSON object.`);let n=/* @__PURE__ */ new Set([`duplication`,`rank`,`thresholds`,`exclude`,`includeTests`,`failOnError`]);for(let r of Object.keys(e))if(!n.has(r))throw Error(`Config file "${t}": unknown setting "${r}" (expected ${[...n].join(`, `)}).`);let r={};e.duplication!==void 0&&(r.duplication=v(e.duplication,t)),e.rank!==void 0&&(r.rank=m(e.rank,t)),e.thresholds!==void 0&&(r.thresholds=h(e.thresholds,t)),e.exclude!==void 0&&(r.exclude=p(e.exclude,t));for(let n of[`includeTests`,`failOnError`])e[n]!==void 0&&(r[n]=S(e[n],n,t));return r}function p(e,t){if(!Array.isArray(e)||!e.every(e=>typeof e==`string`&&e!==``))throw TypeError(`Config file "${t}": "exclude" must be an array of non-empty glob patterns.`);return e}function m(e,t){if(!(0,i.isRecord)(e))throw Error(`Config file "${t}": "rank" must be an object.`);let n={};for(let[r,i]of Object.entries(e)){if(r!==`top`)throw Error(`Config file "${t}": unknown setting "${r}" in "rank" (expected top).`);n.top=b(i,`rank.top`,t)}return n}function h(e,t){let{languages:r,...i}=_(e,`thresholds`,t);return{limits:g(i,`thresholds`,[`languages`],t),languages:Object.fromEntries(Object.entries(r===void 0?{}:_(r,`thresholds.languages`,t)).map(([e,r])=>{if(!n.supportedLanguages.includes(e))throw Error(`Config file "${t}": unknown language "${e}" in "thresholds.languages" (expected ${n.supportedLanguages.join(`, `)}).`);let i=`thresholds.languages.${e}`;return[e,g(_(r,i,t),i,[],t)]}))}}function g(e,t,n,i){let a=r.thresholds.map(({key:e})=>e);return Object.fromEntries(Object.entries(e).map(([e,r])=>{if(!a.includes(e))throw Error(`Config file "${i}": unknown setting "${e}" in "${t}" (expected ${[...a,...n].join(`, `)}).`);if(r!==null&&(typeof r!=`number`||!Number.isFinite(r)||r<0))throw Error(`Config file "${i}": "${t}.${e}" must be a non-negative number or null.`);return[e,r??1/0]}))}function _(e,t,n){if(!(0,i.isRecord)(e))throw Error(`Config file "${n}": "${t}" must be an object.`);return e}function v(e,t){if(!(0,i.isRecord)(e))throw Error(`Config file "${t}": "duplication" must be an object.`);let n={};for(let[r,i]of Object.entries(e))if(r===`minTokens`)n.minTokens=b(i,`duplication.minTokens`,t);else if(r===`maxGapTokens`)n.maxGapTokens=y(i,`duplication.maxGapTokens`,t);else if(r===`minSimilarityPercent`){let e=b(i,`duplication.minSimilarityPercent`,t);if(e>100)throw Error(`Config file "${t}": "duplication.minSimilarityPercent" must be between 1 and 100.`);n.minSimilarityPercent=e}else throw Error(`Config file "${t}": unknown setting "${r}" in "duplication" (expected minTokens, maxGapTokens, or minSimilarityPercent).`);return n}function y(e,t,n){return x(e,t,n,Number.isSafeInteger,`a non-negative integer`)}function b(e,t,n){let r=x(e,t,n,Number.isSafeInteger,`a positive integer`);if(r<1)throw Error(`Config file "${n}": "${t}" must be a positive integer.`);return r}function x(e,t,n,r,i){if(typeof e!=`number`||!r(e)||e<0)throw Error(`Config file "${n}": "${t}" must be ${i}.`);return e}function S(e,t,n){if(typeof e!=`boolean`)throw TypeError(`Config file "${n}": "${t}" must be a boolean.`);return e}exports.configFileName=s,exports.loadConfig=l,exports.resolveOptions=c;
2
2
  //# sourceMappingURL=cliConfig.cjs.map