diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..8ab8c3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,6 +131,14 @@ backward compatible. ### Fixed +- **`vg scan` and `vg build` stop when a lockfile is truncated or not valid + syntax.** A cut-off or syntactically invalid `package-lock.json`, `pnpm-lock.yaml`, + `yarn.lock`, or other lockfile used to be skipped, so the command could exit + successfully with a partial dependency graph. The command now exits non-zero + before source parsing starts. The error names the file and tells you to + regenerate it with your package manager. Lockfile contents are not printed. + A valid lockfile still scans and builds as before. + - **`vg show arch` clipped the map to a fixed viewport.** Columns that ran off the bottom of the window could not be scrolled or zoomed; the canvas is now a pannable, zoomable map (scroll or drag, pinch / Ctrl-scroll, + / −). diff --git a/DOCS.md b/DOCS.md index 6ad775e..850556f 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1103,6 +1103,8 @@ vg scan [path] [--vulns] [--full] [--format text|json|sarif|md] [--out ] [ By default, the scan writes `.vibgrate/scan_result.json`. Use `--no-local-artifacts` or `--max-privacy` to suppress local JSON artifact files. +A truncated or syntactically invalid lockfile stops the scan before a dependency graph is built. The error names the file (for example `package-lock.json`, `pnpm-lock.yaml`, or `yarn.lock`) and tells you to regenerate it with your package manager. The message does not include the lockfile, and the process exits non-zero. A valid lockfile is scanned as before. + For offline drift scoring, pass `--package-manifest ` with a downloaded manifest bundle such as `https://github.com/vibgrate/manifests/latest-packages.zip`. Examples: @@ -1335,6 +1337,8 @@ vg build [paths...] Maps source code into a graph artifact, enabling all downstream queries (`vg show`, `vg ask`, `vg impact`, etc.). +A truncated or syntactically invalid lockfile stops the build before source files are parsed, so parse workers are not left running and a partial map is not written. The error names the file and tells you to regenerate it with your package manager. A valid lockfile builds as before. + | Flag | Default | Description | |------|---------|-------------| | `[paths...]` | `.` | Folders or files to map | diff --git a/src/commands/build.ts b/src/commands/build.ts index f148f31..d9d6515 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -21,6 +21,7 @@ import { serializeGraph } from '../engine/serialize.js'; import { renderReport } from '../engine/report.js'; import { renderHtml } from '../engine/html.js'; import { UsageError, mergeExcludes } from '../engine/discover.js'; +import { LockfileSyntaxError } from '../engine/lockfile-guard.js'; import { ResourceLimitError } from '../engine/limits.js'; import { CliError, ExitCode, usageError } from '../util/exit.js'; import { resolveSelfJsEntry } from '../util/cli-invocation.js'; @@ -149,6 +150,7 @@ export async function runBuild( bar?.done(); if (err instanceof UsageError) throw usageError(err.message); if (err instanceof ResourceLimitError) throw new CliError(err.message, ExitCode.ERROR); + if (err instanceof LockfileSyntaxError) throw new CliError(err.message, ExitCode.ERROR); throw err; } bar?.done(); diff --git a/src/engine/build.ts b/src/engine/build.ts index 5e578d3..298c975 100644 --- a/src/engine/build.ts +++ b/src/engine/build.ts @@ -49,6 +49,7 @@ import type { ScipIndex } from './scip.js'; import type { FileParse } from './types.js'; import type { ResolveResult } from './resolve.js'; import { fileRolesFromParses } from './ast-roles.js'; +import { assertLockfilesValid } from './lockfile-guard.js'; import type { AstRoleHit } from '../core-open/scanners/architecture/ast-roles.js'; export interface BuildOptions { @@ -152,6 +153,9 @@ export async function buildGraph(options: BuildOptions): Promise { const timer = new StageTimer(); timer.start('total'); const root = path.resolve(options.root); + // Before discovery and source parse workers. A bad lockfile must not start + // a worker pool or leave a partial graph behind. + assertLockfilesValid(root); const exclude = mergeExcludes(root, options.exclude); timer.start('discover'); const files = discover({ diff --git a/src/engine/lockfile-guard.test.ts b/src/engine/lockfile-guard.test.ts new file mode 100644 index 0000000..9947e75 --- /dev/null +++ b/src/engine/lockfile-guard.test.ts @@ -0,0 +1,229 @@ +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildGraph } from './build.js'; +import { serializeGraph } from './serialize.js'; +import { assertLockfilesValid, LockfileSyntaxError } from './lockfile-guard.js'; + +const parseState = vi.hoisted(() => ({ n: 0 })); + +vi.mock('./pool.js', async () => { + const actual = await vi.importActual('./pool.js'); + return { + ...actual, + async parseFiles(...args: Parameters): Promise>> { + parseState.n++; + return actual.parseFiles(...args); + }, + }; +}); + +const REPO = fileURLToPath(new URL('../..', import.meta.url)); +const CLI = fileURLToPath(new URL('../cli.ts', import.meta.url)); +const PIN = '2020-01-01T00:00:00.000Z'; +const SENTINEL = 'SENTINEL_LOCKFILE_BODY_9f3a2c'; + +const dirs: string[] = []; +afterEach(() => { + while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); +}); + +function project(files: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-lockguard-')); + dirs.push(dir); + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + return dir; +} + +const VALID_PACKAGE_LOCK = JSON.stringify({ + name: 'ok', + lockfileVersion: 3, + requires: true, + packages: { '': { name: 'ok', version: '1.0.0' } }, +}); + +const TRUNCATED_PACKAGE_LOCK = `{"name":"${SENTINEL}","lockfileVersion":3,"packages":{`; + +function runCli(args: string[]): { code: number; stdout: string; stderr: string } { + try { + const stdout = execFileSync(process.execPath, ['--import', 'tsx', CLI, ...args], { + cwd: REPO, + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + timeout: 25_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + const e = err as { status?: number | null; stdout?: string | Buffer; stderr?: string | Buffer }; + const text = (value: string | Buffer | undefined): string => + typeof value === 'string' ? value : value ? value.toString('utf8') : ''; + return { code: e.status ?? 1, stdout: text(e.stdout), stderr: text(e.stderr) }; + } +} + +describe('assertLockfilesValid', () => { + it('accepts this package\'s own lockfiles', () => { + expect(() => assertLockfilesValid(REPO)).not.toThrow(); + }); + + it('names a truncated package-lock.json and omits its contents', () => { + const root = project({ 'package-lock.json': TRUNCATED_PACKAGE_LOCK }); + expect(() => assertLockfilesValid(root)).toThrow(LockfileSyntaxError); + try { + assertLockfilesValid(root); + } catch (err) { + expect(err).toBeInstanceOf(LockfileSyntaxError); + const message = (err as Error).message; + expect(message).toBe( + 'invalid lockfile package-lock.json: truncated or not valid syntax. Regenerate it with your package manager.', + ); + expect(message).not.toContain(SENTINEL); + expect(message).not.toContain('lockfileVersion'); + } + }); + + it('rejects a truncated pnpm-lock.yaml, yarn.lock, and Cargo.lock', () => { + const pnpm = project({ + 'pnpm-lock.yaml': `lockfileVersion: '9.0'\npackages:\n ${SENTINEL}@1.0.0:\n resolution: {integrity: sha512-dead\n`, + }); + expect(() => assertLockfilesValid(pnpm)).toThrow(/pnpm-lock\.yaml/); + + const yarn = project({ + 'yarn.lock': `# yarn lockfile v1\n${SENTINEL}@^1.0.0:\n version "1.0.0\n`, + }); + expect(() => assertLockfilesValid(yarn)).toThrow(/yarn\.lock/); + + const cargo = project({ + 'Cargo.lock': `[[package]]\nname = "${SENTINEL}"\nversion = "1.0.0\n`, + }); + expect(() => assertLockfilesValid(cargo)).toThrow(/Cargo\.lock/); + }); + + it('rejects garbage that is not a lockfile document', () => { + const root = project({ 'pnpm-lock.yaml': ` not yaml at all ${SENTINEL}` }); + let message = ''; + try { + assertLockfilesValid(root); + } catch (err) { + message = (err as Error).message; + } + expect(message).toContain('pnpm-lock.yaml'); + expect(message).not.toContain(SENTINEL); + }); + + it('lists every bad lockfile in sorted path order', () => { + const root = project({ + 'b/pnpm-lock.yaml': ' not yaml at all', + 'a/package-lock.json': TRUNCATED_PACKAGE_LOCK, + }); + expect(() => assertLockfilesValid(root)).toThrow( + 'invalid lockfile a/package-lock.json, b/pnpm-lock.yaml: truncated or not valid syntax. Regenerate it with your package manager.', + ); + }); + + it('accepts valid lockfiles and an empty go.sum', () => { + const root = project({ + 'package-lock.json': VALID_PACKAGE_LOCK, + 'pnpm-lock.yaml': "lockfileVersion: '9.0'\n", + 'yarn.lock': '# yarn lockfile v1\nleft-pad@^1.3.0:\n version "1.3.0"\n', + 'Cargo.lock': 'version = 3\n\n[[package]]\nname = "memchr"\nversion = "2.7.4"\n', + 'go.sum': '', + 'gradle.lockfile': '# generated\nempty=\n', + 'Gemfile.lock': 'GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.6)\n\nPLATFORMS\n ruby\n\nDEPENDENCIES\n rake\n\nBUNDLED WITH\n 2.4.0\n', + 'bun.lockb': Buffer.from([0, 1, 2, 3]), + }); + expect(() => assertLockfilesValid(root)).not.toThrow(); + }); + + it('ignores a truncated lockfile that gitignore excludes', () => { + const root = project({ + '.gitignore': 'scratch/\n', + 'scratch/package-lock.json': TRUNCATED_PACKAGE_LOCK, + 'package-lock.json': VALID_PACKAGE_LOCK, + }); + expect(() => assertLockfilesValid(root)).not.toThrow(); + }); +}); + +describe('vg scan / vg build', () => { + it('stops buildGraph before source parse workers run', async () => { + const root = project({ + 'package-lock.json': TRUNCATED_PACKAGE_LOCK, + 'src/a.ts': 'export const a = 1;\n', + }); + const before = parseState.n; + await expect(buildGraph({ root, generatedAt: PIN, inline: true, noCache: true, noIndex: true })).rejects.toBeInstanceOf( + LockfileSyntaxError, + ); + expect(parseState.n).toBe(before); + expect(fs.existsSync(path.join(root, '.vibgrate', 'graph.json'))).toBe(false); + }); + + it('builds the same graph when a valid lockfile is present', async () => { + const files = { + 'package.json': '{"name":"ok","version":"1.0.0"}\n', + 'src/a.ts': 'export const a = 1;\n', + }; + const plain = project(files); + const locked = project({ ...files, 'package-lock.json': VALID_PACKAGE_LOCK }); + const opts = { generatedAt: PIN, inline: true, noCache: true, noIndex: true } as const; + const before = parseState.n; + const a = serializeGraph((await buildGraph({ root: plain, ...opts })).graph); + const b = serializeGraph((await buildGraph({ root: locked, ...opts })).graph); + expect(parseState.n).toBeGreaterThan(before); + expect(b).toBe(a); + }); + + it('exits non-zero from vg scan and vg build without echoing the lockfile', () => { + const root = project({ + 'package.json': '{"name":"broken","version":"1.0.0"}\n', + 'package-lock.json': TRUNCATED_PACKAGE_LOCK, + 'src/a.ts': 'export const a = 1;\n', + }); + const expected = + 'invalid lockfile package-lock.json: truncated or not valid syntax. Regenerate it with your package manager.'; + + const scan = runCli(['--offline', 'scan', root, '--offline', '--quiet', '--no-daemon']); + expect(scan.code).not.toBe(0); + expect(scan.stderr).toContain(expected); + expect(`${scan.stdout}\n${scan.stderr}`).not.toContain(SENTINEL); + expect(fs.existsSync(path.join(root, '.vibgrate', 'scan_result.json'))).toBe(false); + + const build = runCli([ + '--offline', + 'build', + '-C', + root, + '--offline', + '--quiet', + '--no-daemon', + '--no-warm', + '--no-html', + '--no-report', + ]); + expect(build.code).not.toBe(0); + expect(build.stderr).toContain(expected); + expect(`${build.stdout}\n${build.stderr}`).not.toContain(SENTINEL); + expect(fs.existsSync(path.join(root, '.vibgrate', 'graph.json'))).toBe(false); + }); + + it('still scans a project with a valid lockfile', () => { + const root = project({ + 'package.json': '{"name":"ok","version":"1.0.0"}\n', + 'package-lock.json': VALID_PACKAGE_LOCK, + 'index.js': 'module.exports = 1;\n', + }); + const scan = runCli(['--offline', 'scan', root, '--offline', '--no-graph', '--quiet', '--no-daemon']); + expect(scan.code).toBe(0); + expect(`${scan.stdout}\n${scan.stderr}`).not.toContain('truncated or not valid syntax'); + expect(fs.existsSync(path.join(root, '.vibgrate', 'scan_result.json'))).toBe(true); + }); +}); diff --git a/src/engine/lockfile-guard.ts b/src/engine/lockfile-guard.ts new file mode 100644 index 0000000..cc11265 --- /dev/null +++ b/src/engine/lockfile-guard.ts @@ -0,0 +1,305 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import ignore from 'ignore'; +import { parse as parseToml } from 'smol-toml'; +import { parse as parseYaml } from 'yaml'; +import { isSkippedDirName, SKIP_FILES } from './discover.js'; + +/** + * Stop `vg scan` / `vg build` when a lockfile is truncated or not valid + * syntax. Readers that swallow a parse error keep going and can emit a + * partial dependency graph (or look successful with nothing resolved). This + * check runs before source parse workers start, so a bad file never leaves + * workers running and never reaches graph construction. + * + * The message names the lockfile and says to regenerate it. Parser errors are + * discarded: they quote the source, and a lockfile must not be echoed back. + */ + +const MESSAGE_SUFFIX = 'truncated or not valid syntax. Regenerate it with your package manager.'; + +/** Generated JavaScript bundles, not lock documents. */ +const SKIP_VALIDATION = new Set(['.pnp.cjs', '.pnp.loader.mjs']); + +const JSON_LOCKS = new Set([ + 'package-lock.json', + 'npm-shrinkwrap.json', + 'composer.lock', + 'packages.lock.json', + 'package.resolved', + 'pipfile.lock', + 'bun.lock', + 'deno.lock', + '.pnp.data.json', + 'renv.lock', + 'flake.lock', + 'conan.lock', + 'module.bazel.lock', +]); + +const YAML_LOCKS = new Set([ + 'pnpm-lock.yaml', + 'pubspec.lock', + 'conda-lock.yml', + 'chart.lock', + 'stack.yaml.lock', + 'glide.lock', +]); + +const TOML_LOCKS = new Set([ + 'poetry.lock', + 'uv.lock', + 'pdm.lock', + 'cargo.lock', + 'pylock.toml', + 'gopkg.lock', + 'manifest.toml', +]); + +const BINARY_LOCKS = new Set(['bun.lockb']); + +export class LockfileSyntaxError extends Error { + readonly paths: readonly string[]; + constructor(paths: readonly string[]) { + super(`invalid lockfile ${paths.join(', ')}: ${MESSAGE_SUFFIX}`); + this.name = 'LockfileSyntaxError'; + this.paths = paths; + } +} + +/** Throw {@link LockfileSyntaxError} when any lockfile under `root` is unusable. */ +export function assertLockfilesValid(root: string): void { + const abs = path.resolve(root); + const bad = findLockfiles(abs).filter((rel) => !lockfileFileValid(abs, rel)); + if (bad.length) throw new LockfileSyntaxError(bad); +} + +function findLockfiles(root: string): string[] { + const ig = ignore(); + const gitignorePath = path.join(root, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + try { + ig.add(fs.readFileSync(gitignorePath, 'utf8')); + } catch { + // Unreadable ignore file: still walk, and skip the usual dependency dirs. + } + } + + const found: string[] = []; + const walk = (dir: string): void => { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of sorted) { + if (entry.name === '.' || entry.name === '..') continue; + const abs = path.join(dir, entry.name); + const rel = toPosix(path.relative(root, abs)); + if (!rel || rel.startsWith('..')) continue; + + let isDir = entry.isDirectory(); + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + const st = fs.statSync(abs); + isDir = st.isDirectory(); + isFile = st.isFile(); + } catch { + continue; + } + // Directory links can cycle. A linked lockfile is still read. + if (isDir) continue; + } + if (isDir) { + if (isSkippedDirName(entry.name)) continue; + if (ig.ignores(`${rel}/`)) continue; + walk(abs); + continue; + } + if (!isFile) continue; + if (ig.ignores(rel)) continue; + const base = entry.name.toLowerCase(); + if (!SKIP_FILES.has(base) || SKIP_VALIDATION.has(base)) continue; + found.push(rel); + } + }; + + walk(root); + found.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + return found; +} + +function lockfileFileValid(root: string, rel: string): boolean { + const abs = path.join(root, rel); + const base = path.posix.basename(rel).toLowerCase(); + let buf: Buffer; + try { + buf = fs.readFileSync(abs); + } catch { + // Unreadable: nothing was parsed, so there is no partial graph to stop. + return true; + } + if (BINARY_LOCKS.has(base)) return buf.length > 0; + return !lockfileTextInvalid(base, stripBom(buf.toString('utf8'))); +} + +function lockfileTextInvalid(basename: string, text: string): boolean { + if (JSON_LOCKS.has(basename)) return jsonLockInvalid(text); + if (YAML_LOCKS.has(basename)) return yamlLockInvalid(text); + if (TOML_LOCKS.has(basename)) return tomlLockInvalid(text); + if (basename === 'yarn.lock') return yarnInvalid(text); + if (basename === 'go.sum' || basename === 'go.work.sum') return goSumInvalid(text); + if (basename === 'gradle.lockfile') return gradleInvalid(text); + if (basename === 'gemfile.lock') return gemfileInvalid(text); + return structuralInvalid(text); +} + +function jsonLockInvalid(text: string): boolean { + if (!text.trim()) return true; + try { + const doc: unknown = JSON.parse(text); + return !isRecord(doc); + } catch { + return true; + } +} + +function yamlLockInvalid(text: string): boolean { + if (!text.trim()) return true; + try { + return !isRecord(parseYaml(text)); + } catch { + return true; + } +} + +function tomlLockInvalid(text: string): boolean { + if (!text.trim()) return true; + try { + return !isRecord(parseToml(text)); + } catch { + return true; + } +} + +/** Classic and Berry yarn.lock. A block that never reaches `version` is cut off. */ +function yarnInvalid(text: string): boolean { + if (!text.trim()) return true; + if (unbalancedDoubleQuotes(text)) return true; + let open = false; + let sawVersion = false; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, ''); + if (!line.trim() || /^\s*#/.test(line)) continue; + if (!/^\s/.test(line)) { + if (open && !sawVersion) return true; + if (!line.trimEnd().endsWith(':')) return true; + open = true; + sawVersion = false; + continue; + } + if (!open) return true; + if (/^\s+version:?\s+\S/.test(line)) sawVersion = true; + } + return open && !sawVersion; +} + +/** `go.sum` / `go.work.sum`. Empty is a module with no dependencies. */ +function goSumInvalid(text: string): boolean { + if (!text.trim()) return false; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, '').trim(); + if (!line) continue; + if (!/^\S+\s+\S+\s+h1:\S+$/.test(line)) return true; + } + return false; +} + +function gradleInvalid(text: string): boolean { + if (!text.trim()) return true; + let sawEntry = false; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, '').trim(); + if (!line || line.startsWith('#')) continue; + if (line.startsWith('empty=')) { + sawEntry = true; + continue; + } + if (!/^[^:=\s#][^:=\s]*:[^:=\s]+:[^=\s]+=\S+/.test(line)) return true; + sawEntry = true; + } + return !sawEntry; +} + +function gemfileInvalid(text: string): boolean { + if (!text.trim()) return true; + if (unbalancedDelimiters(text)) return true; + for (const raw of text.split('\n')) { + const line = raw.replace(/\r$/, ''); + if (/^ {4}\S/.test(line) && !/^ {4}[A-Za-z0-9._-]+ \([^()]+\)$/.test(line.trimEnd())) return true; + } + return false; +} + +function structuralInvalid(text: string): boolean { + if (!text.trim()) return true; + return unbalancedDelimiters(text); +} + +function unbalancedDoubleQuotes(text: string): boolean { + let open = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '\\') { + i++; + continue; + } + if (ch === '"') open = !open; + } + return open; +} + +function unbalancedDelimiters(text: string): boolean { + let brace = 0; + let bracket = 0; + let paren = 0; + let quote = false; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (quote) { + if (ch === '\\') { + i++; + continue; + } + if (ch === '"') quote = false; + continue; + } + if (ch === '"') { + quote = true; + continue; + } + if (ch === '{') brace++; + else if (ch === '}') brace--; + else if (ch === '[') bracket++; + else if (ch === ']') bracket--; + else if (ch === '(') paren++; + else if (ch === ')') paren--; + if (brace < 0 || bracket < 0 || paren < 0) return true; + } + return quote || brace !== 0 || bracket !== 0 || paren !== 0; +} + +function isRecord(doc: unknown): doc is Record { + return !!doc && typeof doc === 'object' && !Array.isArray(doc); +} + +function stripBom(text: string): string { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; +} + +function toPosix(p: string): string { + return p.split(path.sep).join('/'); +} diff --git a/src/reporting/commands/scan.ts b/src/reporting/commands/scan.ts index 76a4e3b..a7b108b 100644 --- a/src/reporting/commands/scan.ts +++ b/src/reporting/commands/scan.ts @@ -34,13 +34,14 @@ import { buildClaimUrl } from './push.js'; import { emitIngestIdLine, emitDriftScoreLine } from '../utils/ingest-id-output.js'; import { uploadScanArtifact } from '../utils/upload.js'; import { buildGraph } from '../../engine/build.js'; +import { assertLockfilesValid, LockfileSyntaxError } from '../../engine/lockfile-guard.js'; import { writeArtifacts, resolveGraphPath } from '../../engine/artifacts.js'; import { readHaileSidecar } from '../../engine/haile/sidecar.js'; import { isUsableHaileSymbol } from '../../engine/haile/format.js'; import { writeSnapshot } from '../../engine/freshness.js'; import { detectAiAssistant, printAiContextPrompt } from '../ai-context-prompt.js'; import { resolveCliInvocation } from '../../util/cli-invocation.js'; -import { usageError } from '../../util/exit.js'; +import { CliError, ExitCode, usageError } from '../../util/exit.js'; import { runSecurityPacks, type SecurityRunResult } from '../../security/run-packs.js'; import { evaluateSecurityGate, lowestThreshold, parseFailOn } from '../../security/gate.js'; import { securityFindingRow, securityPacksLabel } from '../../core-open/formatters/text.js'; @@ -444,6 +445,15 @@ export const scanCommand = new Command('scan') process.exit(1); } + // Before project scanners and the code-map parse workers. A truncated + // lockfile must not become a partial dependency graph or a successful exit. + try { + assertLockfilesValid(rootDir); + } catch (err) { + if (err instanceof LockfileSyntaxError) throw new CliError(err.message, ExitCode.ERROR); + throw err; + } + // `--fail-on` is parsed up front so a typo is a usage error before a long // scan, not after it. One legacy value keeps its meaning exactly; the // security gates are evaluated after the scan against `extended.security`.