Skip to content
Closed
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ backward compatible.

### Fixed

- **Blank ignore and exclude patterns no longer hide the whole tree.** An empty
or whitespace-only exclude — from `--exclude`, from project config, or from a
`.gitignore` line, including a file that contains only a carriage return —
was compiled as a pattern that matches every path, so `vg build` and
`vg scan` walked nothing. Those entries are skipped. A real pattern next to
a blank one still applies.

- **`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, + / −).
Expand Down
7 changes: 6 additions & 1 deletion DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1339,7 +1339,7 @@ Maps source code into a graph artifact, enabling all downstream queries (`vg sho
|------|---------|-------------|
| `[paths...]` | `.` | Folders or files to map |
| `--only <langs>` | — | Restrict to languages (e.g. `ts,py,go`) |
| `--exclude <glob>` | — | Extra ignore glob (repeatable) |
| `--exclude <glob>` | — | Extra ignore glob (repeatable). Empty and whitespace-only values are skipped |
| `--jobs <n>` | auto | Worker count (`1` = single-threaded) |
| `--scip <file>` | auto-detect | Ingest a SCIP index for precise resolution |
| `--no-scip` | — | Ignore any SCIP index |
Expand Down Expand Up @@ -2912,6 +2912,11 @@ Vibgrate reads the first it finds in the order `.vibgrate/config.yml`,
`vibgrate.config.json`, and never merges two. YAML and JSON take exactly the
same settings; `vg doctor` names any config file that is present but ignored.

An empty or whitespace-only `exclude` entry is skipped, and so is a blank
`.gitignore` line — including a file whose only content is a carriage return.
A blank entry never hides the project. A real pattern written next to one
still applies, for both `vg build` and `vg scan`.

### Drift budget

`driftBudget` sets limits on DriftScore that `vg scan` and the Vibgrate GitHub
Expand Down
5 changes: 4 additions & 1 deletion src/core-open/run-core-scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { formatSarif } from './formatters/sarif.js';
import { formatMarkdown } from './formatters/markdown.js';
import { loadConfig, appendExcludePatterns } from './config.js';
import { pathExists, readJsonFile, writeJsonFile, writeTextFile, ensureDir, FileCache, quickTreeCount } from './utils/fs.js';
import { omitBlankPatterns } from './utils/blank-patterns.js';
import { detectVcs } from './utils/vcs.js';
import { resolveRepositoryName } from './utils/repository-name.js';
import { ScanProgress } from './ui/progress.js';
Expand Down Expand Up @@ -171,7 +172,9 @@ export async function runCoreScan(
const fileCache = new FileCache();
// Merge config-file excludes with any patterns passed on the command line
// (--exclude). CLI patterns are additive and de-duplicated.
const excludePatterns = [...new Set([...(config.exclude ?? []), ...(opts.exclude ?? [])])];
const excludePatterns = omitBlankPatterns([
...new Set([...(config.exclude ?? []), ...(opts.exclude ?? [])]),
]);
fileCache.setExcludePatterns(excludePatterns);
const projectScanTimeoutMs = ((opts.projectScanTimeout ?? config.projectScanTimeout ?? 180) * 1000);
fileCache.setMaxFileSize(config.maxFileSizeToScan ?? 5_242_880);
Expand Down
47 changes: 47 additions & 0 deletions src/core-open/utils/blank-patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Ignore } from 'ignore';

/**
* True when a pattern has no non-whitespace content.
*
* Empty, space, tab, and carriage-return-only entries are separators. The
* ignore matcher turns a bare carriage return into an empty expression that
* matches every path, so `vg build` and `vg scan` would walk nothing.
*/
export function isBlankPattern(pattern: string): boolean {
return typeof pattern !== 'string' || pattern.trim() === '';
}

/** Keep real patterns, in order. Blank entries are dropped. */
export function omitBlankPatterns(patterns: readonly string[]): string[] {
const kept: string[] = [];
for (const pattern of patterns) {
if (!isBlankPattern(pattern)) kept.push(pattern);
}
return kept;
}

/**
* `.gitignore` lines that name something.
*
* Splits on LF, CRLF, and a bare CR so a carriage-return-only line is its
* own entry and can be dropped. Neighbouring real patterns are unchanged.
*/
export function meaningfulGitignoreLines(text: string): string[] {
const lines: string[] = [];
for (const line of text.split(/\r\n|\n|\r/)) {
if (!isBlankPattern(line)) lines.push(line);
}
return lines;
}

/** Add ignore-file text, skipping blank lines. */
export function addGitignore(ig: Ignore, text: string): void {
const lines = meaningfulGitignoreLines(text);
if (lines.length > 0) ig.add(lines);
}

/** Add exclude globs, skipping empty and whitespace-only values. */
export function addExcludePatterns(ig: Ignore, patterns: readonly string[]): void {
const kept = omitBlankPatterns(patterns);
if (kept.length > 0) ig.add(kept);
}
7 changes: 6 additions & 1 deletion src/core-open/utils/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { promisify } from 'node:util';
import type { Dirent } from 'node:fs';
import ignore, { type Ignore } from 'ignore';
import { Semaphore } from './semaphore.js';
import { meaningfulGitignoreLines } from './blank-patterns.js';
import { compileGlobs } from './glob.js';


Expand All @@ -35,7 +36,11 @@ interface GitignoreLevel {
async function extendGitignoreLevels(dir: string, levels: GitignoreLevel[]): Promise<GitignoreLevel[]> {
try {
const txt = await fs.readFile(path.join(dir, '.gitignore'), 'utf8');
return [...levels, { dir, ig: ignore().add(txt) }];
// Drop blank lines, including a file that is only a carriage return.
// Those are separators; compiling one matches every path in the tree.
const lines = meaningfulGitignoreLines(txt);
if (lines.length === 0) return levels;
return [...levels, { dir, ig: ignore().add(lines) }];
} catch {
return levels;
}
Expand Down
8 changes: 6 additions & 2 deletions src/core-open/utils/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// scripts/vendor-core-open.mjs. Do not edit here — change the source package
// and re-run the vendor script. Apache-2.0.
import * as path from 'node:path';
import { omitBlankPatterns } from './blank-patterns.js';

/**
* Lightweight glob matcher — zero external dependencies.
Expand Down Expand Up @@ -47,9 +48,12 @@ export function parseExcludePatterns(input: string | string[] | undefined): stri
}

export function compileGlobs(patterns: string[]): ((relPath: string) => boolean) | null {
if (patterns.length === 0) return null;
// Blank entries match nothing on purpose. A whitespace-only glob must not
// be compiled into a rule that hides the tree.
const usable = omitBlankPatterns(patterns);
if (usable.length === 0) return null;

const matchers = patterns.map((p) => compileOne(normalise(p)));
const matchers = usable.map((p) => compileOne(normalise(p)));

return (relPath: string) => {
const norm = normalise(relPath);
Expand Down
7 changes: 4 additions & 3 deletions src/engine/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'node:path';
import ignore, { type Ignore } from 'ignore';
import { langForExtension, langById, type LanguageDef } from './languages.js';
import { readDataConfigSync } from '../core-open/config.js';
import { addExcludePatterns, addGitignore, omitBlankPatterns } from '../core-open/utils/blank-patterns.js';

/**
* Deterministic file discovery.
Expand Down Expand Up @@ -187,7 +188,7 @@ export function readConfigExcludes(root: string): string[] {
export function mergeExcludes(root: string, extra?: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const pattern of [...readConfigExcludes(root), ...(extra ?? [])]) {
for (const pattern of omitBlankPatterns([...readConfigExcludes(root), ...(extra ?? [])])) {
if (seen.has(pattern)) continue;
seen.add(pattern);
out.push(pattern);
Expand All @@ -200,9 +201,9 @@ function buildRootIgnore(root: string, exclude: string[]): Ignore {
const ig = ignore();
const gitignorePath = path.join(root, '.gitignore');
if (fs.existsSync(gitignorePath)) {
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
addGitignore(ig, fs.readFileSync(gitignorePath, 'utf8'));
}
if (exclude.length) ig.add(exclude);
addExcludePatterns(ig, exclude);
return ig;
}

Expand Down
5 changes: 3 additions & 2 deletions src/engine/docs-ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import ignore, { type Ignore } from 'ignore';
import { addExcludePatterns, addGitignore } from '../core-open/utils/blank-patterns.js';
import { redactSecrets } from '../core-open/utils/redact.js';
import { nodeId } from './ids.js';
import { isSkippedDirName, SKIP_FILES } from './discover.js';
Expand Down Expand Up @@ -308,12 +309,12 @@ function buildRootIgnore(root: string, exclude: string[]): Ignore {
const gitignorePath = path.join(root, '.gitignore');
if (fs.existsSync(gitignorePath)) {
try {
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
addGitignore(ig, fs.readFileSync(gitignorePath, 'utf8'));
} catch {
/* ignore */
}
}
if (exclude.length) ig.add(exclude);
addExcludePatterns(ig, exclude);
return ig;
}

Expand Down
5 changes: 3 additions & 2 deletions src/engine/manifests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import ignore, { type Ignore } from 'ignore';
import { XMLParser } from 'fast-xml-parser';
import { addExcludePatterns, addGitignore } from '../core-open/utils/blank-patterns.js';
import { nodeId, edgeId } from './ids.js';
import { isSkippedDirName } from './discover.js';
import { parseToml } from '../core-open/utils/toml.js';
Expand Down Expand Up @@ -455,12 +456,12 @@ function buildRootIgnore(root: string, exclude: string[]): Ignore {
const gitignorePath = path.join(root, '.gitignore');
if (fs.existsSync(gitignorePath)) {
try {
ig.add(fs.readFileSync(gitignorePath, 'utf8'));
addGitignore(ig, fs.readFileSync(gitignorePath, 'utf8'));
} catch {
/* ignore */
}
}
if (exclude.length) ig.add(exclude);
addExcludePatterns(ig, exclude);
return ig;
}

Expand Down
8 changes: 6 additions & 2 deletions src/reporting/utils/glob.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as path from 'node:path';
import { omitBlankPatterns } from '../../core-open/utils/blank-patterns.js';

/**
* Lightweight glob matcher — zero external dependencies.
Expand All @@ -22,9 +23,12 @@ import * as path from 'node:path';
* Returns `null` if the pattern list is empty (nothing excluded).
*/
export function compileGlobs(patterns: string[]): ((relPath: string) => boolean) | null {
if (patterns.length === 0) return null;
// Blank entries match nothing on purpose. A whitespace-only glob must not
// be compiled into a rule that hides the tree.
const usable = omitBlankPatterns(patterns);
if (usable.length === 0) return null;

const matchers = patterns.map((p) => compileOne(normalise(p)));
const matchers = usable.map((p) => compileOne(normalise(p)));

return (relPath: string) => {
const norm = normalise(relPath);
Expand Down
102 changes: 102 additions & 0 deletions test/blank-ignore-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, it, expect, afterEach } from 'vitest';
import { discover, mergeExcludes } from '../src/engine/discover.js';
import { FileCache } from '../src/core-open/utils/fs.js';
import { makeProject, cleanup } from './helpers.js';

const dirs: string[] = [];
function project(files: Record<string, string>): string {
const d = makeProject(files);
dirs.push(d);
return d;
}
afterEach(() => {
while (dirs.length) cleanup(dirs.pop()!);
});

const SOURCE = {
'a.ts': 'export const a = 1;\n',
'gen/b.ts': 'export const b = 1;\n',
};

function discovered(root: string, exclude?: string[]): string[] {
return discover({ root, ...(exclude ? { exclude } : {}) }).map((f) => f.rel);
}

async function walked(root: string, exclude?: string[]): Promise<string[]> {
const cache = new FileCache();
if (exclude) cache.setExcludePatterns(exclude);
const entries = await cache.walkDir(root);
return entries
.filter((e) => e.isFile)
.map((e) => e.relPath.split('\\').join('/'))
.sort();
}

describe('blank ignore and exclude patterns', () => {
describe('build discovery', () => {
it('does not let an empty exclude hide the tree', () => {
const root = project(SOURCE);
expect(discovered(root, [''])).toEqual(['a.ts', 'gen/b.ts']);
});

it('does not let a whitespace-only exclude hide the tree', () => {
const root = project(SOURCE);
expect(discovered(root, [' ', '\t', '\r', '\n', ' \r'])).toEqual(['a.ts', 'gen/b.ts']);
});

it('does not let a carriage-return-only ignore file hide the tree', () => {
const root = project({ ...SOURCE, '.gitignore': '\r' });
expect(discovered(root)).toEqual(['a.ts', 'gen/b.ts']);
});

it('still applies a real pattern that sits beside a blank one', () => {
const byExclude = project(SOURCE);
expect(discovered(byExclude, ['', ' ', '\r', 'gen/**'])).toEqual(['a.ts']);

const byIgnore = project({ ...SOURCE, '.gitignore': 'gen/**\n\r' });
expect(discovered(byIgnore)).toEqual(['a.ts']);

const byConfig = project({
...SOURCE,
'vibgrate.config.json': JSON.stringify({ exclude: ['', ' \t ', '\r', 'gen/**'] }),
});
expect(mergeExcludes(byConfig)).toEqual(['gen/**']);
expect(discovered(byConfig, mergeExcludes(byConfig))).toEqual(['a.ts']);
});
});

describe('scan walk', () => {
it('does not let an empty exclude hide the tree', async () => {
const root = project({ 'a.txt': 'a', 'gen/b.txt': 'b' });
const names = await walked(root, ['']);
expect(names).toContain('a.txt');
expect(names).toContain('gen/b.txt');
});

it('does not let a whitespace-only exclude hide the tree', async () => {
const root = project({ 'a.txt': 'a', 'gen/b.txt': 'b' });
const names = await walked(root, [' ', '\t', '\r', '\n', ' \r']);
expect(names).toContain('a.txt');
expect(names).toContain('gen/b.txt');
});

it('does not let a carriage-return-only ignore file hide the tree', async () => {
const root = project({ 'a.txt': 'a', 'gen/b.txt': 'b', '.gitignore': '\r' });
const names = await walked(root);
expect(names).toContain('a.txt');
expect(names).toContain('gen/b.txt');
});

it('still applies a real pattern that sits beside a blank one', async () => {
const byExclude = project({ 'a.txt': 'a', 'gen/b.txt': 'b' });
const excluded = await walked(byExclude, ['', ' ', '\r', 'gen']);
expect(excluded).toContain('a.txt');
expect(excluded).not.toContain('gen/b.txt');

const byIgnore = project({ 'a.txt': 'a', 'gen/b.txt': 'b', '.gitignore': 'gen/\n\r' });
const ignored = await walked(byIgnore);
expect(ignored).toContain('a.txt');
expect(ignored).not.toContain('gen/b.txt');
});
});
});
Loading