Skip to content
Draft
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
2 changes: 1 addition & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <langs>` | — | Restrict to languages (e.g. `ts,py,go`) |
| `--exclude <glob>` | — | Extra ignore glob (repeatable) |
| `--jobs <n>` | auto | Worker count (`1` = single-threaded) |
Expand Down
66 changes: 66 additions & 0 deletions src/commands/build-root.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
42 changes: 38 additions & 4 deletions src/commands/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <langs>', 'restrict to languages, e.g. ts,py,go')
.option('--exclude <glob>', 'extra ignore glob (repeatable)', collect, [])
.option('--no-html', 'do not write graph.html')
Expand Down Expand Up @@ -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<void> {
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);
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions src/engine/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/engine/truncated-lockfile.cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
Loading
Loading