From 6da0664c7c6f818aaaffb000b3d67685a481e27b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 21 Jul 2026 14:57:53 -0700 Subject: [PATCH] feat(lint): --exclude flag for mixed-content repos (takeover of #2649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds --exclude=a,b (and LintOpts.exclude) so mixed-content repos can skip software trees and repo metafiles by basename when collecting pages. The only built-in default is node_modules — vendored dependency trees are never knowledge pages; dot/underscore entries were already skipped by the walk. Diverges from #2649 deliberately: the original hardcoded an opinionated default list (README.md, CHANGELOG.md, CLAUDE.md, test/ dirs at any depth, plus fork-specific filenames), which silently changed lint counts for every existing repo. Those are repo policy — pass --exclude. Co-authored-by: ryangu00 Co-Authored-By: Claude Fable 5 --- src/commands/lint.ts | 51 +++++++++++++++++++++++++++++++++++++------- test/lint.test.ts | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 8 deletions(-) diff --git a/src/commands/lint.ts b/src/commands/lint.ts index e14e05456..57125a911 100644 --- a/src/commands/lint.ts +++ b/src/commands/lint.ts @@ -378,15 +378,30 @@ async function resolveLintContentSanity( }; } +/** + * Directories never containing knowledge pages, skipped by default. + * Deliberately tiny: only vendored dependency trees qualify. Anything + * more opinionated (README.md, CHANGELOG.md, test/) is repo policy — + * callers opt in via `--exclude` / `LintOpts.exclude`. Dot- and + * underscore-prefixed entries are already skipped by the walk. + */ +const DEFAULT_LINT_EXCLUDE_DIRS = new Set(['node_modules']); + /** Collect markdown files from a directory */ -function collectPages(dir: string): string[] { +function collectPages(dir: string, extraExcludes: string[] = []): string[] { + const extra = new Set(extraExcludes); const pages: string[] = []; function walk(d: string) { for (const entry of readdirSync(d)) { if (entry.startsWith('.') || entry.startsWith('_')) continue; const full = join(d, entry); - if (lstatSync(full).isDirectory()) walk(full); - else if (entry.endsWith('.md')) pages.push(full); + if (lstatSync(full).isDirectory()) { + if (DEFAULT_LINT_EXCLUDE_DIRS.has(entry) || extra.has(entry)) continue; + walk(full); + } else if (entry.endsWith('.md')) { + if (extra.has(entry)) continue; + pages.push(full); + } } } walk(dir); @@ -414,6 +429,13 @@ export interface LintOpts { * yields + checks this every 200 pages. */ signal?: AbortSignal; + /** + * #2649: extra dir/file basenames to skip while collecting pages, in + * addition to node_modules and dot/underscore entries. For mixed-content + * repos (knowledge pages alongside software trees). Ignored for + * single-file targets. + */ + exclude?: string[]; } export interface LintResult { @@ -440,7 +462,7 @@ export async function runLintCore(opts: LintOpts): Promise { } const isSingleFile = statSync(opts.target).isFile(); - const pages = isSingleFile ? [opts.target] : collectPages(opts.target); + const pages = isSingleFile ? [opts.target] : collectPages(opts.target, opts.exclude ?? []); // Resolve content-sanity config once for this lint run (D1: lift DB // config when reachable). Caller can pre-pass via opts.contentSanity @@ -491,14 +513,27 @@ export async function runLintCore(opts: LintOpts): Promise { } export async function runLint(args: string[]) { - const target = args.find(a => !a.startsWith('--')); + // #2649: --exclude=a,b or --exclude a,b — extra basenames to skip. + const extraExcludes: string[] = []; + const skipIdx = new Set(); + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a.startsWith('--exclude=')) { + extraExcludes.push(...a.slice('--exclude='.length).split(',').map(s => s.trim()).filter(Boolean)); + } else if (a === '--exclude' && i + 1 < args.length) { + extraExcludes.push(...args[i + 1].split(',').map(s => s.trim()).filter(Boolean)); + skipIdx.add(i + 1); + } + } + const target = args.find((a, i) => !a.startsWith('--') && !skipIdx.has(i)); const doFix = args.includes('--fix'); const dryRun = args.includes('--dry-run'); if (!target) { - console.error('Usage: gbrain lint [--fix] [--dry-run]'); + console.error('Usage: gbrain lint [--fix] [--dry-run] [--exclude a,b]'); console.error(' --fix Auto-fix fixable issues (LLM preambles, code fences)'); console.error(' --dry-run Preview fixes without writing'); + console.error(' --exclude Comma-separated dir/file basenames to skip (in addition to node_modules)'); process.exit(1); } @@ -510,7 +545,7 @@ export async function runLint(args: string[]) { // Single file or directory — print human detail as we go, then rely on // Core for the aggregate numbers at the end. const isSingleFile = statSync(target).isFile(); - const pages = isSingleFile ? [target] : collectPages(target); + const pages = isSingleFile ? [target] : collectPages(target, extraExcludes); // Progress on stderr. Stdout keeps the per-issue human output it always had. const { createProgress } = await import('../core/progress.ts'); @@ -557,7 +592,7 @@ export async function runLint(args: string[]) { // produces canonical numbers for the summary line). // Pass contentSanity through so runLintCore skips its own resolve // (we already resolved once for the human-detail loop above). - const result = await runLintCore({ target, fix: doFix, dryRun, contentSanity }); + const result = await runLintCore({ target, fix: doFix, dryRun, contentSanity, exclude: extraExcludes }); console.log(`\n${result.pages_scanned} pages scanned. ${result.total_issues} issue(s) in ${result.pages_with_issues} page(s).`); if (doFix) { console.log(`${dryRun ? '(dry run) ' : ''}${result.total_fixed} auto-fixed.`); diff --git a/test/lint.test.ts b/test/lint.test.ts index ca38b8d23..bc3dcd8e9 100644 --- a/test/lint.test.ts +++ b/test/lint.test.ts @@ -116,3 +116,48 @@ describe('fixContent', () => { expect(fixed).toContain('# Title'); }); }); + +describe('runLintCore exclude (takeover of #2649)', () => { + const { mkdtempSync, rmSync, mkdirSync, writeFileSync } = require('node:fs') as typeof import('node:fs'); + const { tmpdir } = require('node:os') as typeof import('node:os'); + const { join } = require('node:path') as typeof import('node:path'); + const { runLintCore } = require('../src/commands/lint.ts') as typeof import('../src/commands/lint.ts'); + + const PAGE = '---\ntitle: T\ntype: note\ncreated: 2026-04-11\n---\n\n# T\n\nBody.\n'; + + function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), 'gbrain-lint-excl-')); + writeFileSync(join(dir, 'page.md'), PAGE); + writeFileSync(join(dir, 'README.md'), PAGE); + mkdirSync(join(dir, 'node_modules', 'dep'), { recursive: true }); + writeFileSync(join(dir, 'node_modules', 'dep', 'vendor.md'), PAGE); + mkdirSync(join(dir, 'software')); + writeFileSync(join(dir, 'software', 'notes.md'), PAGE); + return dir; + } + + test('node_modules is excluded by default; nothing else is', async () => { + const dir = makeRepo(); + try { + const result = await runLintCore({ target: dir, contentSanity: { disabled: true } }); + // page.md + README.md + software/notes.md — vendor.md skipped. + expect(result.pages_scanned).toBe(3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test('--exclude basenames skip dirs and files', async () => { + const dir = makeRepo(); + try { + const result = await runLintCore({ + target: dir, + contentSanity: { disabled: true }, + exclude: ['software', 'README.md'], + }); + expect(result.pages_scanned).toBe(1); // only page.md + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});