Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2177,6 +2177,8 @@ Switches (flags that take no value, such as `--vulns`, `--offline` or `--no-grap

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.

`vg scan` does not follow symlinks while it indexes the tree. A skipped link is named once on stderr. See [Symlinks](#symlinks).

For offline drift scoring, pass `--package-manifest <file>` with a downloaded manifest bundle such as `https://github.com/vibgrate/manifests/latest-packages.zip`. The manifest shape, the fail-closed errors, and what offline mode skips are in [Offline scan with a package-version manifest](#offline-scan-with-a-package-version-manifest).
Expand All @@ -2193,7 +2195,7 @@ pnpm-lock.yaml: unknown optional lockfile field "futureOptional"; continuing wit

The path is relative to the directory you ran the command from, when the lockfile is under that directory. Otherwise the warning uses the file name only. The warning names the field. It does not print the field's value, an environment value, or an authorization header. A field name that is not a plain identifier is reported as `(name omitted)`.

A lockfile that is truncated, empty, or missing the structure its format requires still fails the command. The error names the file and the format, tells you to regenerate the file with your package manager, and the process exits non-zero. The error does not include the file's contents.
A lockfile that is truncated or not valid syntax still fails the command, including the other lockfiles the walk skips (for example `bun.lock`, `mix.lock`, `Cargo.lock`). The error names each bad file, in path order, tells you to regenerate it with your package manager, and the process exits non-zero. The error does not include the file's contents. An empty `go.sum` or `go.work.sum` is valid: the module has no dependencies.

`vg scan` and `vg sbom export` follow this for npm, pnpm, yarn, poetry, uv, pdm, Pipfile, Cargo, Composer, NuGet, Swift package pins, and pub. A Gradle lock line, a Gemfile spec, and a `go.sum` line do not have optional field names. A line that does not match that format is invalid, and the command fails.

Expand Down Expand Up @@ -2751,6 +2753,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 |
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { registerHcs } from './commands/hcs.js';
import { registerReview } from './commands/review.js';
import { ConfigFileError } from './core-open/config.js';
import { LockfileParseError } from './core-open/utils/lockfile-parse.js';
import { LockfileSyntaxError } from './engine/lockfile-guard.js';
import { UnsafeRootError } from './core-open/utils/root-safety.js';
import { CliError, ExitCode, usageError } from './util/exit.js';
import { ParseWorkerError } from './engine/pool.js';
Expand Down Expand Up @@ -443,6 +444,7 @@ function handleError(err: unknown): never {
err instanceof CliError ||
err instanceof ParseWorkerError ||
err instanceof LockfileParseError ||
err instanceof LockfileSyntaxError ||
err instanceof ConfigFileError ||
err instanceof UnsafeRootError
) {
Expand Down
2 changes: 2 additions & 0 deletions src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { ParseWorkerError } from '../engine/pool.js';
import { UnsafeRootError } from '../core-open/utils/root-safety.js';
Expand Down Expand Up @@ -155,6 +156,7 @@ export async function runBuild(
throw new CliError(err.message, ExitCode.ERROR);
}
if (err instanceof UnsafeRootError) throw new CliError(err.message, ExitCode.ERROR);
if (err instanceof LockfileSyntaxError) throw new CliError(err.message, ExitCode.ERROR);
throw err;
}
bar?.done();
Expand Down
4 changes: 3 additions & 1 deletion src/core-open/utils/lockfile-parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const KIND_BY_BASENAME: Readonly<Record<string, LockfileKind>> = {
'cargo.lock': 'TOML',
'yarn.lock': 'yarn.lock',
'go.sum': 'go.sum',
'go.work.sum': 'go.sum',
'gradle.lockfile': 'gradle.lockfile',
'gemfile.lock': 'Gemfile.lock',
};
Expand Down Expand Up @@ -204,7 +205,8 @@ function assertYarn(filePath: string, text: string): void {
}

function assertGoSum(filePath: string, text: string): void {
if (!text.trim()) throw new LockfileParseError(filePath, 'go.sum');
// An empty sum is a module with no dependencies, not a truncated file.
if (!text.trim()) return;
const lineRe = /^\S+\s+v\S+\s+h1:\S+$/;
for (const raw of text.split('\n')) {
const line = raw.trim();
Expand Down
4 changes: 4 additions & 0 deletions src/engine/build.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { discover, mergeExcludes, type DiscoveredFile } from './discover.js';
import { assertLockfilesValid } from './lockfile-guard.js';
import { parseFiles } from './pool.js';
import { resolve } from './resolve.js';
import { inheritDuties } from './duties-inherit.js';
Expand Down Expand Up @@ -162,6 +163,9 @@ export async function buildGraph(options: BuildOptions): Promise<BuildResult> {
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);
// Resolved before discovery so the walk itself stops at the corpus cap
// instead of reading a filesystem root or an enormous unpack first.
Expand Down
235 changes: 235 additions & 0 deletions src/engine/lockfile-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
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<typeof import('./pool.js')>('./pool.js');
return {
...actual,
async parseFiles(...args: Parameters<typeof actual.parseFiles>): Promise<Awaited<ReturnType<typeof actual.parseFiles>>> {
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, string | Buffer>): 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',
VIBGRATE_NO_KERNEL: '1',
VIBGRATE_DSN: '',
},
timeout: 45_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);
});
});
Loading
Loading