diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2ec89..e61e961 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, + / −). diff --git a/DOCS.md b/DOCS.md index 6ad775e..5de4d89 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1339,7 +1339,7 @@ Maps source code into a graph artifact, enabling all downstream queries (`vg sho |------|---------|-------------| | `[paths...]` | `.` | Folders or files to map | | `--only ` | — | Restrict to languages (e.g. `ts,py,go`) | -| `--exclude ` | — | Extra ignore glob (repeatable) | +| `--exclude ` | — | Extra ignore glob (repeatable). Empty and whitespace-only values are skipped | | `--jobs ` | auto | Worker count (`1` = single-threaded) | | `--scip ` | auto-detect | Ingest a SCIP index for precise resolution | | `--no-scip` | — | Ignore any SCIP index | @@ -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 diff --git a/src/core-open/run-core-scan.ts b/src/core-open/run-core-scan.ts index 5375ff9..5f1fcfe 100644 --- a/src/core-open/run-core-scan.ts +++ b/src/core-open/run-core-scan.ts @@ -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'; @@ -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); diff --git a/src/core-open/utils/blank-patterns.ts b/src/core-open/utils/blank-patterns.ts new file mode 100644 index 0000000..ad15665 --- /dev/null +++ b/src/core-open/utils/blank-patterns.ts @@ -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); +} diff --git a/src/core-open/utils/fs.ts b/src/core-open/utils/fs.ts index 97697fd..ab828be 100644 --- a/src/core-open/utils/fs.ts +++ b/src/core-open/utils/fs.ts @@ -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'; @@ -35,7 +36,11 @@ interface GitignoreLevel { async function extendGitignoreLevels(dir: string, levels: GitignoreLevel[]): Promise { 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; } diff --git a/src/core-open/utils/glob.ts b/src/core-open/utils/glob.ts index d6b15ee..e88f1eb 100644 --- a/src/core-open/utils/glob.ts +++ b/src/core-open/utils/glob.ts @@ -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. @@ -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); diff --git a/src/engine/discover.ts b/src/engine/discover.ts index aa5fce8..c45a1c3 100644 --- a/src/engine/discover.ts +++ b/src/engine/discover.ts @@ -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. @@ -187,7 +188,7 @@ export function readConfigExcludes(root: string): string[] { export function mergeExcludes(root: string, extra?: string[]): string[] { const seen = new Set(); 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); @@ -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; } diff --git a/src/engine/docs-ingest.ts b/src/engine/docs-ingest.ts index 6308fe6..0992d18 100644 --- a/src/engine/docs-ingest.ts +++ b/src/engine/docs-ingest.ts @@ -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'; @@ -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; } diff --git a/src/engine/manifests.ts b/src/engine/manifests.ts index 9e79583..96004c5 100644 --- a/src/engine/manifests.ts +++ b/src/engine/manifests.ts @@ -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'; @@ -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; } diff --git a/src/reporting/utils/glob.ts b/src/reporting/utils/glob.ts index bbc54dd..df52113 100644 --- a/src/reporting/utils/glob.ts +++ b/src/reporting/utils/glob.ts @@ -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. @@ -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); diff --git a/test/blank-ignore-patterns.test.ts b/test/blank-ignore-patterns.test.ts new file mode 100644 index 0000000..f1e476b --- /dev/null +++ b/test/blank-ignore-patterns.test.ts @@ -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 { + 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 { + 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'); + }); + }); +});