diff --git a/DOCS.md b/DOCS.md index 442266d0..80e0101a 100644 --- a/DOCS.md +++ b/DOCS.md @@ -2803,7 +2803,7 @@ A truncated or syntactically invalid lockfile stops the build before source file | Flag | Default | Description | |------|---------|-------------| -| `[paths...]` | `.` | Folders or files to map | +| `[paths...]` | `.` | Folders or files to map. A directory outside the working directory is mapped as its own project | | `--only ` | — | Restrict to languages (e.g. `ts,py,go`) | | `--exclude ` | — | Extra ignore glob (repeatable) | | `--jobs ` | auto | Worker count (`1` = single-threaded) | diff --git a/src/commands/build-root.test.ts b/src/commands/build-root.test.ts new file mode 100644 index 00000000..fcd09b17 --- /dev/null +++ b/src/commands/build-root.test.ts @@ -0,0 +1,66 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { discover } from '../engine/discover.js'; +import { resolveBuildRoot } from './build.js'; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function tree(): { parent: string; decoy: string; target: string } { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-build-root-')); + dirs.push(parent); + const decoy = path.join(parent, 'cwd-decoy'); + const target = path.join(parent, 'target'); + fs.mkdirSync(path.join(decoy, 'src'), { recursive: true }); + fs.mkdirSync(path.join(target, 'src'), { recursive: true }); + fs.writeFileSync(path.join(decoy, 'src', 'decoy.ts'), 'export const decoy = 1;\n'); + fs.writeFileSync(path.join(target, 'src', 'app.ts'), 'export const app = 1;\n'); + fs.writeFileSync(path.join(target, 'package-lock.json'), '{}\n'); + return { parent, decoy, target }; +} + +describe('resolveBuildRoot', () => { + it('maps a directory outside the working directory as its own project', () => { + const { decoy, target } = tree(); + expect(resolveBuildRoot(decoy, ['../target'])).toEqual({ root: target, paths: [] }); + expect(resolveBuildRoot(decoy, [target])).toEqual({ root: target, paths: [] }); + }); + + it('keeps a path inside the working directory as a scope', () => { + const { parent, target } = tree(); + expect(resolveBuildRoot(parent, ['target'])).toEqual({ root: parent, paths: ['target'] }); + expect(resolveBuildRoot(target, [])).toEqual({ root: target, paths: [] }); + expect(resolveBuildRoot(target, ['.'])).toEqual({ root: target, paths: ['.'] }); + expect(resolveBuildRoot(target, ['src'])).toEqual({ root: target, paths: ['src'] }); + }); + + it('keeps a file, and more than one path, as a scope of the working directory', () => { + const { decoy, target } = tree(); + const file = path.join(target, 'src', 'app.ts'); + expect(resolveBuildRoot(decoy, ['../target/src/app.ts'])).toEqual({ + root: decoy, + paths: ['../target/src/app.ts'], + }); + expect(resolveBuildRoot(decoy, ['../target', file])).toEqual({ + root: decoy, + paths: ['../target', file], + }); + }); +}); + +describe('discover outside the project root', () => { + it('skips a scope directory outside the root instead of rejecting its relative path', () => { + const { decoy, target } = tree(); + expect(discover({ root: decoy, paths: [target] }).map((file) => file.rel)).toEqual([]); + }); + + it('still maps a file whose name begins with two dots', () => { + const { decoy } = tree(); + fs.writeFileSync(path.join(decoy, 'src', '..keep.ts'), 'export const keep = 1;\n'); + expect(discover({ root: decoy }).map((file) => file.rel)).toContain('src/..keep.ts'); + }); +}); diff --git a/src/commands/build.ts b/src/commands/build.ts index 2a36f88d..61b248c4 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -63,7 +63,10 @@ export function registerBuild(program: Command): void { const cmd = program .command('build') .description('build / update the code map (incremental, deterministic)') - .argument('[paths...]', 'folders or files to map (default: current folder)') + .argument( + '[paths...]', + 'folders or files to map (default: the working directory). A directory outside the working directory is mapped as its own project', + ) .option('--only ', 'restrict to languages, e.g. ts,py,go') .option('--exclude ', 'extra ignore glob (repeatable)', collect, []) .option('--no-html', 'do not write graph.html') @@ -97,13 +100,44 @@ export interface RunBuildHooks { onParseProgress?: (done: number, total: number) => void; } +/** + * Where `vg build` reads lockfiles, `node_modules`, and project config. + * + * Paths inside the working directory (or `-C`) stay a scope of that project: + * `vg build src` still maps `src` with the repo's lockfile and config. A + * single directory outside that project is the project itself — `vg build + * ../other` must not read the working directory's lockfile, install tree, or + * config, and must not write artifacts there. + */ +export function resolveBuildRoot(cwd: string, paths: readonly string[]): { root: string; paths: string[] } { + const base = path.resolve(cwd); + const listed = [...paths]; + if (listed.length !== 1) return { root: base, paths: listed }; + const only = path.resolve(base, listed[0]); + let stat: fs.Stats; + try { + stat = fs.statSync(only); + } catch { + return { root: base, paths: listed }; + } + if (!stat.isDirectory() || only === base || isInsideDir(base, only)) return { root: base, paths: listed }; + return { root: only, paths: [] }; +} + +function isInsideDir(parent: string, child: string): boolean { + const rel = path.relative(parent, child); + return rel !== '' && rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); +} + export async function runBuild( paths: string[], opts: BuildCmdOpts, global: GlobalOpts, hooks?: RunBuildHooks, ): Promise { - const root = path.resolve(global.cwd ?? '.'); + const located = resolveBuildRoot(global.cwd ?? '.', paths); + const root = located.root; + const scopePaths = located.paths; if (opts.verify) { await verifyGraph(root, opts, global); @@ -129,7 +163,7 @@ export async function runBuild( try { result = await buildGraph({ root, - paths: paths.length ? paths : undefined, + paths: scopePaths.length ? scopePaths : undefined, only, exclude: opts.exclude, jobs, @@ -194,7 +228,7 @@ export async function runBuild( writeSnapshot(root, result.graph.provenance.corpusHash, result.fileStats, { only, exclude: mergeExcludes(root, opts.exclude), - paths: paths.length ? paths : undefined, + paths: scopePaths.length ? scopePaths : undefined, deep: global.deep, noGround: opts.ground === false, scip: typeof opts.scip === 'string' ? opts.scip : undefined, diff --git a/src/engine/discover.ts b/src/engine/discover.ts index 4873937c..88f9ee43 100644 --- a/src/engine/discover.ts +++ b/src/engine/discover.ts @@ -297,6 +297,10 @@ export function discover(options: DiscoverOptions): DiscoveredFile[] { for (const entry of entries) { const abs = path.join(dir, entry.name); const rel = toPosix(path.relative(root, abs)); + // A scope directory can sit outside this root. The ignore matcher + // rejects `../` paths, and those files are not part of the project. + // A name that merely begins with `..` (`..keep`) is inside the root. + if (rel === '..' || rel.startsWith('../')) continue; if (entry.isSymbolicLink()) { if (rel && (rootIg.ignores(rel) || rootIg.ignores(`${rel}/`))) continue; rememberSkippedSymlink(skippedSymlinks, rel); diff --git a/src/engine/truncated-lockfile.cli.test.ts b/src/engine/truncated-lockfile.cli.test.ts index 1e4e3cc4..5fde8208 100644 --- a/src/engine/truncated-lockfile.cli.test.ts +++ b/src/engine/truncated-lockfile.cli.test.ts @@ -24,8 +24,8 @@ function fixture(): string { } function run(dir: string, command: 'scan' | 'build'): { status: number | null; signal: NodeJS.Signals | null; stderr: string; error?: Error } { - // `-C` makes the fixture the project root. A path argument alone is a scope - // inside the process cwd, so a lockfile outside that cwd would be skipped. + // `-C` makes the fixture the project root. A path inside the process cwd is + // only a discovery scope, so this test passes `-C` rather than a path. const args = command === 'scan' ? [cli, 'scan', dir, '--offline', '--no-graph', '--no-daemon', '--quiet'] diff --git a/test/cwd-isolation.test.ts b/test/cwd-isolation.test.ts new file mode 100644 index 00000000..4dbb00ac --- /dev/null +++ b/test/cwd-isolation.test.ts @@ -0,0 +1,243 @@ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it } from 'vitest'; + +/** + * `vg scan ` and `vg build ` read the project at ``. + * A lockfile, `node_modules` tree, or `.vibgrate/config.yml` in the current + * working directory must not change the result. The fixture is two sibling + * projects so a walk that only climbs to a parent cannot see the decoy. + * + * Normalisation before comparing the two working directories: + * - scan JSON drops `timestamp` and `durationMs` (wall clock) + * - absolute paths of the target directory are rewritten to `` + * - scan JSON is compared as data, not bytes. Extended scanners finish + * concurrently, so object key order can differ between two runs of the + * same tree. A decoy version, config value, or path is still a failure. + * - stderr progress lines include elapsed time (`0.1s`). Those durations + * are rewritten to ``. + * + * `vg sbom export` takes `--root ` rather than a positional path. The + * export is run against one scan artifact from both working directories. + */ + +const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const cli = path.join(pkgRoot, 'src/cli.ts'); +// `--import tsx` resolves the package from the process cwd. The cwd under test +// is a fixture, so load the runner by file URL from this repo instead. +const tsxLoader = pathToFileURL(path.join(pkgRoot, 'node_modules/tsx/dist/loader.mjs')).href; +const PIN = '2020-01-01T00:00:00.000Z'; + +const DECOY_MARKERS = [ + 'cwd-decoy-pkg', + 'decoy-only-pkg', + 'cwd-installed-only', + 'CWD_DECOY_SOURCE', + 'from-cwd-decoy-config', + '9.9.9', + '8.8.8', + '7.7.7', + '6.6.6', +] as const; + +const dirs: string[] = []; +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function writeProject(dir: string, files: Record): void { + 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); + } +} + +function lockfile(name: string, deps: Record): string { + const packages: Record }> = { + '': { name, version: '1.0.0', dependencies: deps }, + }; + for (const [pkg, version] of Object.entries(deps)) { + packages[`node_modules/${pkg}`] = { version }; + } + return `${JSON.stringify({ name, lockfileVersion: 3, requires: true, packages }, null, 2)}\n`; +} + +function fixture(): { parent: string; decoy: string; target: string } { + const parent = fs.mkdtempSync(path.join(os.tmpdir(), 'vg-cwd-isolation-')); + dirs.push(parent); + const decoy = path.join(parent, 'cwd-decoy'); + const target = path.join(parent, 'target'); + writeProject(decoy, { + 'package.json': `${JSON.stringify({ + name: 'cwd-decoy-pkg', + version: '0.0.1', + dependencies: { 'left-pad': '9.9.9', 'decoy-only-pkg': '8.8.8' }, + }, null, 2)}\n`, + 'package-lock.json': lockfile('cwd-decoy-pkg', { 'left-pad': '9.9.9', 'decoy-only-pkg': '8.8.8' }), + 'node_modules/left-pad/package.json': `${JSON.stringify({ name: 'left-pad', version: '7.7.7' })}\n`, + 'node_modules/decoy-only-pkg/package.json': `${JSON.stringify({ name: 'decoy-only-pkg', version: '8.8.8' })}\n`, + 'node_modules/cwd-installed-only/package.json': `${JSON.stringify({ name: 'cwd-installed-only', version: '6.6.6' })}\n`, + '.vibgrate/config.yml': 'exclude:\n - from-cwd-decoy-config/**\ndriftBudget:\n mode: warn\n maxScore: 13\n', + 'src/decoy.ts': 'export const CWD_DECOY_SOURCE = 1;\n', + }); + writeProject(target, { + 'package.json': `${JSON.stringify({ + name: 'target-app', + version: '1.0.0', + dependencies: { 'left-pad': '1.3.0' }, + }, null, 2)}\n`, + 'package-lock.json': lockfile('target-app', { 'left-pad': '1.3.0' }), + 'node_modules/left-pad/package.json': `${JSON.stringify({ name: 'left-pad', version: '1.2.2' })}\n`, + '.vibgrate/config.yml': 'exclude:\n - from-target-config/**\ndriftBudget:\n mode: warn\n maxScore: 77\n', + 'src/app.ts': 'export function targetHello(): number {\n return 1;\n}\n', + }); + return { parent, decoy, target }; +} + +function run(cwd: string, args: string[]): { status: number | null; stdout: string; stderr: string } { + const res = spawnSync(process.execPath, ['--import', tsxLoader, cli, ...args], { + cwd, + encoding: 'utf8', + timeout: 90_000, + env: { + ...process.env, + NO_COLOR: '1', + VIBGRATE_NO_KERNEL: '1', + VIBGRATE_DSN: '', + VIBGRATE_GRAPH_IN_REPO: '1', + }, + }); + return { status: res.status, stdout: res.stdout ?? '', stderr: res.stderr ?? '' }; +} + +function listFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + const out: string[] = []; + const walk = (current: string): void => { + for (const name of fs.readdirSync(current).sort()) { + const abs = path.join(current, name); + if (fs.statSync(abs).isDirectory()) walk(abs); + else out.push(path.relative(dir, abs).split(path.sep).join('/')); + } + }; + walk(dir); + return out; +} + +function assertNoDecoy(label: string, text: string, decoy: string): void { + expect(text, label).not.toContain(decoy); + for (const marker of DECOY_MARKERS) { + expect(text, `${label} contains ${marker}`).not.toContain(marker); + } +} + +/** Progress elapsed time is wall-clock. The words around it are not. */ +function normalizeStderr(text: string, target: string): string { + return text.split(target).join('').replace(/\b\d+(?:\.\d+)?s\b/g, ''); +} + +/** Drop wall-clock fields. Rewrite the target's absolute path and nothing else. */ +function normalizeScan(stdout: string, target: string): unknown { + const artifact = JSON.parse(stdout.split(target).join('')) as Record; + delete artifact.timestamp; + delete artifact.durationMs; + return artifact; +} + +describe('scan and build ignore the working directory', () => { + it('vg scan matches a scan from inside the project', () => { + const { decoy, target } = fixture(); + const fromDecoy = run(decoy, ['scan', '../target', '--format', 'json', '--offline', '--no-daemon', '--quiet', '--no-graph']); + expect(fromDecoy.status, fromDecoy.stderr).toBe(0); + expect(listFiles(path.join(decoy, '.vibgrate'))).toEqual(['config.yml']); + expect(fs.existsSync(path.join(target, '.vibgrate', 'scan_result.json'))).toBe(true); + + const fromTarget = run(target, ['scan', '.', '--format', 'json', '--offline', '--no-daemon', '--quiet', '--no-graph']); + expect(fromTarget.status, fromTarget.stderr).toBe(0); + expect(listFiles(path.join(decoy, '.vibgrate'))).toEqual(['config.yml']); + + const combined = `${fromDecoy.stdout}\n${fromDecoy.stderr}\n${fromTarget.stdout}\n${fromTarget.stderr}`; + assertNoDecoy('scan output', combined, decoy); + expect(fromDecoy.stdout).toContain('target-app'); + expect(fromDecoy.stdout).toContain('1.3.0'); + expect(fromDecoy.stdout).toContain('from-target-config/**'); + expect(normalizeScan(fromDecoy.stdout, target)).toEqual(normalizeScan(fromTarget.stdout, target)); + expect(normalizeStderr(fromDecoy.stderr, target)).toBe(normalizeStderr(fromTarget.stderr, target)); + }, 90_000); + + it('vg build matches a build from inside the project', () => { + const { decoy, target } = fixture(); + const buildArgs = [ + '--offline', + '--no-daemon', + '--no-warm', + '--no-publish', + '--no-html', + '--no-report', + '--no-cache', + '--jobs', + '1', + '--generated-at', + PIN, + '--quiet', + ]; + const fromDecoy = run(decoy, ['build', '../target', ...buildArgs]); + expect(fromDecoy.status, fromDecoy.stderr).toBe(0); + const graphPath = path.join(target, '.vibgrate', 'graph.json'); + expect(fs.existsSync(graphPath), 'graph is written under the project, not the working directory').toBe(true); + expect(listFiles(path.join(decoy, '.vibgrate'))).toEqual(['config.yml']); + const firstGraph = fs.readFileSync(graphPath, 'utf8'); + + const fromTarget = run(target, ['build', ...buildArgs]); + expect(fromTarget.status, fromTarget.stderr).toBe(0); + expect(listFiles(path.join(decoy, '.vibgrate'))).toEqual(['config.yml']); + const secondGraph = fs.readFileSync(graphPath, 'utf8'); + + const combined = `${fromDecoy.stdout}\n${fromDecoy.stderr}\n${fromTarget.stdout}\n${fromTarget.stderr}\n${firstGraph}`; + assertNoDecoy('build output', combined, decoy); + expect(firstGraph).toContain('targetHello'); + expect(firstGraph).toContain('src/app.ts'); + expect(firstGraph).toBe(secondGraph); + }, 90_000); + + it('vg sbom export --root matches an export from inside the project', () => { + const { decoy, target } = fixture(); + const scan = run(target, ['scan', '.', '--format', 'json', '--offline', '--no-daemon', '--quiet', '--no-graph']); + expect(scan.status, scan.stderr).toBe(0); + + const fromDecoy = run(decoy, [ + 'sbom', + 'export', + '--in', + '../target/.vibgrate/scan_result.json', + '--root', + '../target', + '--format', + 'cyclonedx', + ]); + const fromTarget = run(target, [ + 'sbom', + 'export', + '--in', + '.vibgrate/scan_result.json', + '--root', + '.', + '--format', + 'cyclonedx', + ]); + expect(fromDecoy.status, fromDecoy.stderr).toBe(0); + expect(fromTarget.status, fromTarget.stderr).toBe(0); + expect(listFiles(path.join(decoy, '.vibgrate'))).toEqual(['config.yml']); + + const combined = `${fromDecoy.stdout}\n${fromDecoy.stderr}\n${fromTarget.stdout}\n${fromTarget.stderr}`; + assertNoDecoy('sbom output', combined, decoy); + expect(fromDecoy.stdout).toContain('left-pad'); + expect(fromDecoy.stdout).toContain('1.3.0'); + expect(fromDecoy.stdout).toBe(fromTarget.stdout); + expect(normalizeStderr(fromDecoy.stderr, target)).toBe(normalizeStderr(fromTarget.stderr, target)); + }, 90_000); +});