From 3cfb4597579eb0e736f6a160bdf47a4f85518ca9 Mon Sep 17 00:00:00 2001 From: jdalton Date: Tue, 5 May 2026 17:14:00 -0700 Subject: [PATCH] feat(scan): add --exclude-paths flag for full Tier 1 exclusion (port of #1298) Port of #1298 (originally targeted v1.x by @simonhj) to main. Adds a --exclude-paths flag to socket scan create and socket scan reach that excludes the listed glob patterns from BOTH SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are matched relative to the project root; bare directory names are auto-extended to recursive globs (tests -> tests/**); trailing slashes are stripped; gitignore-style negation patterns (!path) are rejected up front. Internally, --exclude-paths is wired into projectIgnorePaths for SCA manifest discovery and into Coana's --exclude-dirs for reachability, preserving existing --reach-exclude-paths semantics for users who only need the Coana-side exclusion. Translation notes for v1.x -> main: - @socketsecurity/registry/lib/* -> @socketsecurity/lib/* - ../../utils/errors.mts -> ../../utils/error/errors.mts - co-located tests live under packages/cli/test/{integration,unit}/... - preserved existing test snapshots; only the new --exclude-paths line was added to help-text snapshots. DISABLE_PRECOMMIT_TEST=1 used for this commit because pre-existing unrelated analytics tests are broken on origin/main (verified against a pristine checkout). Type checks and the new exclude-paths unit tests all pass. --- packages/cli/data/socket-completion.bash | 4 +- packages/cli/src/commands/ci/handle-ci.mts | 1 + .../cli/src/commands/scan/cmd-scan-create.mts | 10 +- .../cli/src/commands/scan/cmd-scan-reach.mts | 9 +- .../commands/scan/create-scan-from-github.mts | 1 + .../cli/src/commands/scan/exclude-paths.mts | 194 ++++++++++++++++++ .../commands/scan/handle-create-new-scan.mts | 15 +- .../src/commands/scan/handle-scan-reach.mts | 13 +- .../scan/perform-reachability-analysis.mts | 1 + .../src/commands/scan/reachability-flags.mts | 9 + .../integration/cli/cmd-scan-create.test.mts | 55 +++++ .../cli/cmd-scan-reach-dry-run.test.mts | 88 ++++++++ .../unit/commands/scan/exclude-paths.test.mts | 101 +++++++++ .../scan/handle-create-new-scan.test.mts | 14 +- .../commands/scan/handle-scan-reach.test.mts | 6 +- 15 files changed, 506 insertions(+), 15 deletions(-) create mode 100644 packages/cli/src/commands/scan/exclude-paths.mts create mode 100644 packages/cli/test/unit/commands/scan/exclude-paths.test.mts diff --git a/packages/cli/data/socket-completion.bash b/packages/cli/data/socket-completion.bash index 4619cc7d8..5a486e6ef 100755 --- a/packages/cli/data/socket-completion.bash +++ b/packages/cli/data/socket-completion.bash @@ -125,12 +125,12 @@ FLAGS=( [repos update]="--default-branch --homepage --interactive --org --repo-description --repo-name --visibility" [repos view]="--interactive --org --repo-name" [scan]="" - [scan create]="--auto-manifest --branch --commit-hash --commit-message --committers --cwd --default-branch --interactive --json --markdown --org --pull-request --reach --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths --read-only --repo --report --set-as-alerts-page --tmp" + [scan create]="--auto-manifest --branch --commit-hash --commit-message --committers --cwd --default-branch --exclude-paths --interactive --json --markdown --org --pull-request --reach --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths --read-only --repo --report --set-as-alerts-page --tmp" [scan del]="--interactive --org" [scan diff]="--depth --file --interactive --org" [scan list]="--branch --direction --from-time --interactive --json --markdown --org --page --per-page --repo --sort --until-time" [scan metadata]="--interactive --org" - [scan reach]="--reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths" + [scan reach]="--exclude-paths --reach-analysis-memory-limit --reach-analysis-timeout --reach-disable-analytics --reach-ecosystems --reach-exclude-paths" [scan report]="--fold --interactive --license --org --report-level --short" [scan view]="--interactive --org --stream" [threat-feed]="--direction --eco --filter --interactive --json --markdown --org --page --per-page" diff --git a/packages/cli/src/commands/ci/handle-ci.mts b/packages/cli/src/commands/ci/handle-ci.mts index 3555ff77b..575282e7c 100644 --- a/packages/cli/src/commands/ci/handle-ci.mts +++ b/packages/cli/src/commands/ci/handle-ci.mts @@ -51,6 +51,7 @@ export async function handleCi(autoManifest: boolean): Promise { pendingHead: true, pullRequest: 0, reach: { + excludePaths: [], reachAnalysisMemoryLimit: 0, reachAnalysisTimeout: 0, reachConcurrency: 1, diff --git a/packages/cli/src/commands/scan/cmd-scan-create.mts b/packages/cli/src/commands/scan/cmd-scan-create.mts index 43fffeb85..ecf121364 100644 --- a/packages/cli/src/commands/scan/cmd-scan-create.mts +++ b/packages/cli/src/commands/scan/cmd-scan-create.mts @@ -5,9 +5,10 @@ import { getDefaultLogger } from '@socketsecurity/lib/logger' const logger = getDefaultLogger() +import { assertNoNegationPatterns } from './exclude-paths.mts' import { handleCreateNewScan } from './handle-create-new-scan.mts' import { outputCreateNewScan } from './output-create-new-scan.mts' -import { reachabilityFlags } from './reachability-flags.mts' +import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' import { suggestOrgSlug } from './suggest-org-slug.mts' import { suggestTarget } from './suggest_target.mts' import { validateReachabilityTarget } from './validate-reachability-target.mts' @@ -307,6 +308,7 @@ async function run( hidden, flags: { ...generalFlags, + ...excludePathsFlag, ...reachabilityFlags, }, help: command => ` @@ -317,7 +319,7 @@ async function run( ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} Options - ${getFlagListOutput(generalFlags)} + ${getFlagListOutput({ ...generalFlags, ...excludePathsFlag })} Reachability Options (when --reach is used) ${getFlagListOutput(reachabilityFlags)} @@ -587,6 +589,9 @@ async function run( logger.error('') } + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) + assertNoNegationPatterns(excludePaths) + const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) // Validation helpers for better readability. @@ -785,6 +790,7 @@ async function run( pendingHead: Boolean(pendingHead), pullRequest: validatedPullRequest, reach: { + excludePaths, runReachabilityAnalysis: Boolean(reach), reachAnalysisMemoryLimit: validatedReachAnalysisMemoryLimit, reachAnalysisTimeout: validatedReachAnalysisTimeout, diff --git a/packages/cli/src/commands/scan/cmd-scan-reach.mts b/packages/cli/src/commands/scan/cmd-scan-reach.mts index 8722a7769..a22f02260 100644 --- a/packages/cli/src/commands/scan/cmd-scan-reach.mts +++ b/packages/cli/src/commands/scan/cmd-scan-reach.mts @@ -2,8 +2,9 @@ import path from 'node:path' import { joinAnd } from '@socketsecurity/lib/arrays' +import { assertNoNegationPatterns } from './exclude-paths.mts' import { handleScanReach } from './handle-scan-reach.mts' -import { reachabilityFlags } from './reachability-flags.mts' +import { excludePathsFlag, reachabilityFlags } from './reachability-flags.mts' import { suggestTarget } from './suggest_target.mts' import { validateReachabilityTarget } from './validate-reachability-target.mts' import { outputDryRunExecute } from '../../utils/dry-run/output.mts' @@ -98,6 +99,7 @@ async function run( hidden, flags: { ...generalFlags, + ...excludePathsFlag, ...reachabilityFlags, }, help: command => @@ -112,7 +114,7 @@ async function run( ${getFlagListOutput(generalFlags)} Reachability Options - ${getFlagListOutput(reachabilityFlags)} + ${getFlagListOutput({ ...excludePathsFlag, ...reachabilityFlags })} Runs the Socket reachability analysis without creating a scan in Socket. The output is written to .socket.facts.json in the current working directory @@ -164,8 +166,10 @@ async function run( const dryRun = !!cli.flags['dryRun'] // Process comma-separated values for isMultiple flags. + const excludePaths = cmdFlagValueToArray(cli.flags['excludePaths']) const reachEcosystemsRaw = cmdFlagValueToArray(cli.flags['reachEcosystems']) const reachExcludePaths = cmdFlagValueToArray(cli.flags['reachExcludePaths']) + assertNoNegationPatterns(excludePaths) // Validate ecosystem values. const reachEcosystems: PURL_Type[] = [] @@ -313,6 +317,7 @@ async function run( outputPath: outputPath || '', targets, reachabilityOptions: { + excludePaths, reachAnalysisMemoryLimit: validatedReachAnalysisMemoryLimit, reachAnalysisTimeout: validatedReachAnalysisTimeout, reachConcurrency: validatedReachConcurrency, diff --git a/packages/cli/src/commands/scan/create-scan-from-github.mts b/packages/cli/src/commands/scan/create-scan-from-github.mts index f02556bd5..2a0dfdba3 100644 --- a/packages/cli/src/commands/scan/create-scan-from-github.mts +++ b/packages/cli/src/commands/scan/create-scan-from-github.mts @@ -299,6 +299,7 @@ async function scanOneRepo( pendingHead: true, pullRequest: 0, reach: { + excludePaths: [], runReachabilityAnalysis: false, reachAnalysisMemoryLimit: 0, reachAnalysisTimeout: 0, diff --git a/packages/cli/src/commands/scan/exclude-paths.mts b/packages/cli/src/commands/scan/exclude-paths.mts new file mode 100644 index 000000000..237db4293 --- /dev/null +++ b/packages/cli/src/commands/scan/exclude-paths.mts @@ -0,0 +1,194 @@ +import path from 'node:path' + +import { InputError } from '../../utils/error/errors.mts' + +import type { ReachabilityOptions } from './perform-reachability-analysis.mts' +import type { SocketYml } from '@socketsecurity/config' + +type ApplyFullExcludePathsOptions = { + cwd: string + reachabilityOptions: ReachabilityOptions + socketConfig: SocketYml | undefined + target: string +} + +type ApplyFullExcludePathsResult = { + effectiveSocketConfig: SocketYml | undefined + mergedReachabilityOptions: ReachabilityOptions +} + +/** + * Converts a user-facing full-scan exclude path into the socket.yml + * projectIgnorePaths shape used by SCA manifest discovery. + */ +export function excludePathToProjectIgnorePath(path: string): string { + const stripped = stripTrailingSlash(path) + return stripped.endsWith('/**') ? stripped : `${stripped}/**` +} + +/** + * Rejects gitignore-style negation patterns for --exclude-paths because the + * flag is a positive full-exclusion list, not a complete ignore language. + */ +export function assertNoNegationPatterns(paths: readonly string[]): void { + for (const path of paths) { + if (path.startsWith('!')) { + throw new InputError( + `--exclude-paths does not support negation patterns. Got: '${path}'.`, + ) + } + } +} + +/** + * Normalizes a reachability exclude path to a recursive directory glob without + * changing explicit one-level or recursive glob suffixes. + */ +export function normalizeExcludePath(path: string): string { + const stripped = stripTrailingSlash(path) + return stripped.endsWith('/*') || stripped.endsWith('/**') + ? stripped + : `${stripped}/**` +} + +/** + * Applies --exclude-paths consistently to SCA manifest discovery and Coana. + * SCA exclusion always applies when paths are provided. The reachability + * options are merged unconditionally; callers decide whether to actually run + * reachability and consume them. + */ +export function applyFullExcludePaths({ + cwd, + reachabilityOptions, + socketConfig, + target, +}: ApplyFullExcludePathsOptions): ApplyFullExcludePathsResult { + const { excludePaths } = reachabilityOptions + const scaExcludeGlobs = excludePaths.map(excludePathToProjectIgnorePath) + const coanaExcludeGlobs = projectIgnorePathsToReachExcludePaths( + scaExcludeGlobs, + { + cwd, + target, + }, + ) + const socketConfigReachExcludeGlobs = excludePaths.length + ? projectIgnorePathsToReachExcludePaths(socketConfig?.projectIgnorePaths, { + cwd, + target, + }) + : [] + const effectiveSocketConfig = scaExcludeGlobs.length + ? { + ...socketConfig, + version: socketConfig?.version ?? 2, + issueRules: socketConfig?.issueRules ?? {}, + githubApp: socketConfig?.githubApp ?? {}, + projectIgnorePaths: [ + ...(socketConfig?.projectIgnorePaths ?? []), + ...scaExcludeGlobs, + ], + } + : socketConfig + const mergedReachabilityOptions = excludePaths.length + ? { + ...reachabilityOptions, + reachExcludePaths: [ + ...socketConfigReachExcludeGlobs, + ...reachabilityOptions.reachExcludePaths, + ...coanaExcludeGlobs, + ], + } + : reachabilityOptions + + return { effectiveSocketConfig, mergedReachabilityOptions } +} + +/** + * Translates project-root projectIgnorePaths into Coana --exclude-dirs values, + * which are interpreted relative to the current reachability analysis target. + */ +export function projectIgnorePathsToReachExcludePaths( + paths: readonly string[] | undefined, + options: { cwd: string; target: string }, +): string[] { + // GitHub App-style projectIgnorePaths support negation. Coana's + // --exclude-dirs does not, so keep the existing Coana behavior and let it + // infer config ignores itself when any negation is present. + if (!Array.isArray(paths) || paths.some(path => path.includes('!'))) { + return [] + } + + // projectIgnorePaths are rooted at the project cwd. Coana receives excludes + // relative to its analysis target, so nested target scans need translation. + const targetPath = path.isAbsolute(options.target) + ? path.relative(options.cwd, options.target) + : options.target + const targetPattern = toPosixPath(stripTrailingSlash(targetPath)) + return paths.flatMap(path => + projectIgnorePathToReachExcludePaths(path, targetPattern), + ) +} + +function projectIgnorePathToReachExcludePaths( + path: string, + targetPattern: string, +): string[] { + const reachPath = pathRelativeToTarget(path, targetPattern) + if (!reachPath) { + return [] + } + return expandReachExcludePath(reachPath) +} + +function expandReachExcludePath(path: string): string[] { + if (path === '**') { + return ['**'] + } + const firstSlash = path.indexOf('/') + const prefix = firstSlash === -1 || firstSlash === path.length - 1 ? '**/' : '' + const normalized = stripTrailingSlash( + path.startsWith('/') ? path.slice(1) : path, + ) + const pattern = `${prefix}${normalized}` + return pattern.endsWith('/*') || pattern.endsWith('/**') + ? [pattern] + : [pattern, `${pattern}/**`] +} + +function pathRelativeToTarget(path: string, target: string): string | undefined { + const normalized = normalizeProjectIgnorePath(path) + if (target === '.' || target === '') { + return normalized + } + + // Ignore paths outside the analysis target. They still affect SCA manifest + // discovery through projectIgnorePaths, but Coana cannot exclude directories + // outside the target it is analyzing. + if (normalized === target) { + return '**' + } + const targetPrefix = `${target}/` + if (normalized.startsWith(targetPrefix)) { + return normalized.slice(targetPrefix.length) + } + const recursiveTargetPrefix = `${targetPrefix}**/` + if (normalized.startsWith(recursiveTargetPrefix)) { + return normalized.slice(targetPrefix.length) + } + return undefined +} + +function normalizeProjectIgnorePath(path: string): string { + return stripTrailingSlash( + toPosixPath(path.startsWith('/') ? path.slice(1) : path), + ) +} + +function toPosixPath(path: string): string { + return path.replaceAll('\\', '/') +} + +function stripTrailingSlash(path: string): string { + return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path +} diff --git a/packages/cli/src/commands/scan/handle-create-new-scan.mts b/packages/cli/src/commands/scan/handle-create-new-scan.mts index 51d7fa98b..62af9dd8b 100644 --- a/packages/cli/src/commands/scan/handle-create-new-scan.mts +++ b/packages/cli/src/commands/scan/handle-create-new-scan.mts @@ -8,6 +8,7 @@ import { pluralize } from '@socketsecurity/lib/words' const logger = getDefaultLogger() +import { applyFullExcludePaths } from './exclude-paths.mts' import { fetchCreateOrgFullScan } from './fetch-create-org-full-scan.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' import { finalizeTier1Scan } from './finalize-tier1-scan.mts' @@ -157,8 +158,16 @@ export async function handleCreateNewScan({ ? socketYmlResult.data?.parsed : undefined + const { effectiveSocketConfig, mergedReachabilityOptions } = + applyFullExcludePaths({ + cwd, + reachabilityOptions: reach, + socketConfig, + target: targets[0]!, + }) + const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { - config: socketConfig, + config: effectiveSocketConfig, cwd, }) @@ -209,7 +218,7 @@ export async function handleCreateNewScan({ logger.error('') logger.info('Starting reachability analysis...') debug('notice', 'Reachability analysis enabled') - debugDir('inspect', { reachabilityOptions: reach }) + debugDir('inspect', { reachabilityOptions: mergedReachabilityOptions }) spinner.start() @@ -218,7 +227,7 @@ export async function handleCreateNewScan({ cwd, orgSlug, packagePaths, - reachabilityOptions: reach, + reachabilityOptions: mergedReachabilityOptions, repoName, spinner, target: firstTarget, diff --git a/packages/cli/src/commands/scan/handle-scan-reach.mts b/packages/cli/src/commands/scan/handle-scan-reach.mts index 727c41ad5..05e2bf3f8 100644 --- a/packages/cli/src/commands/scan/handle-scan-reach.mts +++ b/packages/cli/src/commands/scan/handle-scan-reach.mts @@ -4,6 +4,7 @@ import { pluralize } from '@socketsecurity/lib/words' const logger = getDefaultLogger() +import { applyFullExcludePaths } from './exclude-paths.mts' import { fetchSupportedScanFileNames } from './fetch-supported-scan-file-names.mts' import { outputScanReach } from './output-scan-reach.mts' import { performReachabilityAnalysis } from './perform-reachability-analysis.mts' @@ -57,8 +58,16 @@ export async function handleScanReach({ ? socketYmlResult.data?.parsed : undefined + const { effectiveSocketConfig, mergedReachabilityOptions } = + applyFullExcludePaths({ + cwd, + reachabilityOptions, + socketConfig, + target: targets[0]!, + }) + const packagePaths = await getPackageFilesForScan(targets, supportedFiles, { - config: socketConfig, + config: effectiveSocketConfig, cwd, }) @@ -88,7 +97,7 @@ export async function handleScanReach({ orgSlug, outputPath, packagePaths, - reachabilityOptions, + reachabilityOptions: mergedReachabilityOptions, spinner, target: targets[0]!, uploadManifests: true, diff --git a/packages/cli/src/commands/scan/perform-reachability-analysis.mts b/packages/cli/src/commands/scan/perform-reachability-analysis.mts index 97652ef0f..bbeb5d643 100644 --- a/packages/cli/src/commands/scan/perform-reachability-analysis.mts +++ b/packages/cli/src/commands/scan/perform-reachability-analysis.mts @@ -22,6 +22,7 @@ import type { PURL_Type } from '../../utils/ecosystem/types.mjs' import type { Spinner } from '@socketsecurity/lib/spinner' export type ReachabilityOptions = { + excludePaths: string[] reachAnalysisMemoryLimit: number reachAnalysisTimeout: number reachConcurrency: number diff --git a/packages/cli/src/commands/scan/reachability-flags.mts b/packages/cli/src/commands/scan/reachability-flags.mts index 1d175f127..26840bf26 100644 --- a/packages/cli/src/commands/scan/reachability-flags.mts +++ b/packages/cli/src/commands/scan/reachability-flags.mts @@ -1,5 +1,14 @@ import type { MeowFlags } from '../../flags.mts' +export const excludePathsFlag: MeowFlags = { + excludePaths: { + type: 'string', + isMultiple: true, + description: + 'List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. `tests` becomes `tests/**`). Trailing slashes are stripped. Negation patterns (`!path`) are not supported. Accepts a comma-separated value or multiple flags.', + }, +} + export const reachabilityFlags: MeowFlags = { reachAnalysisMemoryLimit: { type: 'number', diff --git a/packages/cli/test/integration/cli/cmd-scan-create.test.mts b/packages/cli/test/integration/cli/cmd-scan-create.test.mts index 4c069467a..57de91f13 100644 --- a/packages/cli/test/integration/cli/cmd-scan-create.test.mts +++ b/packages/cli/test/integration/cli/cmd-scan-create.test.mts @@ -38,6 +38,7 @@ describe('socket scan create', async () => { --committers Committers --cwd working directory, defaults to process.cwd() --default-branch Set the default branch of the repository to the branch of this full-scan. Should only need to be done once, for example for the "main" or "master" branch. + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --interactive Allow for interactive elements, asking for input. Use --no-interactive to prevent any input questions, defaulting them to cancel/no. --json Output as JSON --markdown Output as Markdown @@ -143,6 +144,60 @@ describe('socket scan create', async () => { }, ) + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'target', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--exclude-paths', + 'tests', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should succeed when --exclude-paths is used without --reach', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect( + code, + 'should exit with code 0 when --exclude-paths is used standalone', + ).toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'create', + FLAG_ORG, + 'fakeOrg', + 'test/fixtures/commands/scan/reach/npm', + FLAG_DRY_RUN, + '--repo', + 'xyz', + '--branch', + 'abc', + '--reach', + '--exclude-paths', + 'tests', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should succeed when --exclude-paths is used with --reach', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0 when all flags are valid').toBe(0) + }, + ) + cmdit( [ 'scan', diff --git a/packages/cli/test/integration/cli/cmd-scan-reach-dry-run.test.mts b/packages/cli/test/integration/cli/cmd-scan-reach-dry-run.test.mts index f242f90a2..abc3ca93b 100644 --- a/packages/cli/test/integration/cli/cmd-scan-reach-dry-run.test.mts +++ b/packages/cli/test/integration/cli/cmd-scan-reach-dry-run.test.mts @@ -59,6 +59,7 @@ describe('socket scan reach - dry-run tests', async () => { --output Path to write the reachability report to (must end with .json). Defaults to .socket.facts.json in the current working directory. Reachability Options + --exclude-paths List of glob patterns to exclude from the scan, including SCA/SBOM manifest discovery and (when --reach is enabled) Tier 1 reachability analysis. Patterns are matched relative to the project root. Bare directory names are auto-extended to recursive globs (e.g. \`tests\` becomes \`tests/**\`). Trailing slashes are stripped. Negation patterns (\`!path\`) are not supported. Accepts a comma-separated value or multiple flags. --reach-analysis-memory-limit The maximum memory in MB to use for the reachability analysis. The default is 8192MB. --reach-analysis-timeout Set timeout for the reachability analysis. Split analysis runs may cause the total scan time to exceed this timeout significantly. --reach-disable-analytics Disable reachability analytics sharing with Socket. Also disables caching-based optimizations. @@ -291,6 +292,93 @@ describe('socket scan reach - dry-run tests', async () => { }, ) + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--exclude-paths', + 'node_modules,dist', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --exclude-paths with comma-separated values', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--exclude-paths', + 'node_modules', + '--exclude-paths', + 'dist', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept multiple --exclude-paths flags', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--exclude-paths', + 'build', + '--reach-exclude-paths', + 'node_modules,dist', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should accept --exclude-paths together with --reach-exclude-paths', + async cmd => { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd) + expect(stdout).toMatchInlineSnapshot(`"[DryRun]: Bailing now"`) + expect(code, 'should exit with code 0').toBe(0) + }, + ) + + cmdit( + [ + 'scan', + 'reach', + FLAG_DRY_RUN, + '--exclude-paths', + '!tests/keep', + '--org', + 'fakeOrg', + FLAG_CONFIG, + '{"apiToken":"fakeToken"}', + ], + 'should reject --exclude-paths negation patterns', + async cmd => { + const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) + const output = stdout + stderr + expect(output).toContain( + "--exclude-paths does not support negation patterns. Got: '!tests/keep'.", + ) + expect(code, 'should exit with non-zero code').not.toBe(0) + }, + ) + cmdit( [ 'scan', diff --git a/packages/cli/test/unit/commands/scan/exclude-paths.test.mts b/packages/cli/test/unit/commands/scan/exclude-paths.test.mts new file mode 100644 index 000000000..b0a020c49 --- /dev/null +++ b/packages/cli/test/unit/commands/scan/exclude-paths.test.mts @@ -0,0 +1,101 @@ +/** + * Unit tests for exclude-paths helpers. + * + * Validates the helpers that translate the user-facing --exclude-paths flag + * into projectIgnorePaths and Coana --exclude-dirs values. + */ + +import { describe, expect, it } from 'vitest' + +import { + assertNoNegationPatterns, + excludePathToProjectIgnorePath, + normalizeExcludePath, + projectIgnorePathsToReachExcludePaths, +} from '../../../../src/commands/scan/exclude-paths.mts' +import { InputError } from '../../../../src/utils/error/errors.mts' + +describe('exclude-paths', () => { + describe('assertNoNegationPatterns', () => { + it('allows positive patterns', () => { + expect(() => + assertNoNegationPatterns(['tests', 'packages/*']), + ).not.toThrow() + }) + + it('rejects negation patterns', () => { + expect(() => assertNoNegationPatterns(['!tests/keep'])).toThrow( + InputError, + ) + expect(() => assertNoNegationPatterns(['!tests/keep'])).toThrow( + "--exclude-paths does not support negation patterns. Got: '!tests/keep'.", + ) + }) + }) + + describe('excludePathToProjectIgnorePath', () => { + it.each([ + ['packages/*', 'packages/*/**'], + ['tests', 'tests/**'], + ['tests/', 'tests/**'], + ['tests/**', 'tests/**'], + ])('converts %s to %s', (input, expected) => { + expect(excludePathToProjectIgnorePath(input)).toBe(expected) + }) + }) + + describe('normalizeExcludePath', () => { + it.each([ + ['tests', 'tests/**'], + ['tests/', 'tests/**'], + ['tests/*', 'tests/*'], + ['tests/**', 'tests/**'], + ])('normalizes %s to %s', (input, expected) => { + expect(normalizeExcludePath(input)).toBe(expected) + }) + }) + + describe('projectIgnorePathsToReachExcludePaths', () => { + it('normalizes positive project ignore paths for Coana', () => { + expect( + projectIgnorePathsToReachExcludePaths( + ['tests', 'dist/', 'fixtures/**'], + { + cwd: '/repo', + target: '/repo', + }, + ), + ).toEqual([ + '**/tests', + '**/tests/**', + '**/dist', + '**/dist/**', + 'fixtures/**', + ]) + }) + + it('keeps project-root paths relative to nested Coana targets', () => { + expect( + projectIgnorePathsToReachExcludePaths( + ['tests/**', 'apps/api/tests/**', 'apps/api/packages/*/**'], + { + cwd: '/repo', + target: '/repo/apps/api', + }, + ), + ).toEqual(['tests/**', 'packages/*/**']) + }) + + it('returns no paths when project ignore paths use negation', () => { + expect( + projectIgnorePathsToReachExcludePaths( + ['fixtures/**', '!fixtures/keep'], + { + cwd: '/repo', + target: '/repo', + }, + ), + ).toEqual([]) + }) + }) +}) diff --git a/packages/cli/test/unit/commands/scan/handle-create-new-scan.test.mts b/packages/cli/test/unit/commands/scan/handle-create-new-scan.test.mts index 708c83f9d..9feb9fa10 100644 --- a/packages/cli/test/unit/commands/scan/handle-create-new-scan.test.mts +++ b/packages/cli/test/unit/commands/scan/handle-create-new-scan.test.mts @@ -129,6 +129,8 @@ describe('handleCreateNewScan', () => { pullRequest: 0, outputKind: 'json' as const, reach: { + excludePaths: [], + reachExcludePaths: [], runReachabilityAnalysis: false, }, readOnly: false, @@ -302,7 +304,11 @@ describe('handleCreateNewScan', () => { await handleCreateNewScan({ ...mockConfig, - reach: { runReachabilityAnalysis: true }, + reach: { + excludePaths: [], + reachExcludePaths: [], + runReachabilityAnalysis: true, + }, }) expect(mockPerformReachabilityAnalysis).toHaveBeenCalled() @@ -376,7 +382,11 @@ describe('handleCreateNewScan', () => { await handleCreateNewScan({ ...mockConfig, - reach: { runReachabilityAnalysis: true }, + reach: { + excludePaths: [], + reachExcludePaths: [], + runReachabilityAnalysis: true, + }, }) expect(mockOutputCreateNewScan).toHaveBeenCalledWith( diff --git a/packages/cli/test/unit/commands/scan/handle-scan-reach.test.mts b/packages/cli/test/unit/commands/scan/handle-scan-reach.test.mts index dd6859631..febdcde52 100644 --- a/packages/cli/test/unit/commands/scan/handle-scan-reach.test.mts +++ b/packages/cli/test/unit/commands/scan/handle-scan-reach.test.mts @@ -129,6 +129,7 @@ describe('handleScanReach', () => { outputKind: 'json', outputPath: '', reachabilityOptions: { + excludePaths: [], reachAnalysisTimeout: 300, reachAnalysisMemoryLimit: 2048, reachDisableAnalytics: false, @@ -147,6 +148,7 @@ describe('handleScanReach', () => { outputPath: '', packagePaths: ['/project/package.json', '/project/package-lock.json'], reachabilityOptions: { + excludePaths: [], reachAnalysisTimeout: 300, reachAnalysisMemoryLimit: 2048, reachDisableAnalytics: false, @@ -201,7 +203,7 @@ describe('handleScanReach', () => { orgSlug: 'test-org', outputKind: 'json', outputPath: '', - reachabilityOptions: {}, + reachabilityOptions: { excludePaths: [], reachExcludePaths: [] }, targets: ['nonexistent'], }) @@ -233,7 +235,7 @@ describe('handleScanReach', () => { orgSlug: 'test-org', outputKind: 'markdown', outputPath: '', - reachabilityOptions: { maxDepth: 10 }, + reachabilityOptions: { excludePaths: [], reachExcludePaths: [], maxDepth: 10 }, targets: ['./'], })