feat(core): add IgnoreFilter module with .understandignore parsing and tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lum1104
2026-04-10 11:33:10 +08:00
Unverified
parent 07b02d18ae
commit 5e86254b77
2 changed files with 265 additions and 0 deletions
@@ -0,0 +1,153 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createIgnoreFilter, DEFAULT_IGNORE_PATTERNS } from "../ignore-filter";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("IgnoreFilter", () => {
let testDir: string;
beforeEach(() => {
testDir = join(tmpdir(), `ignore-filter-test-${Date.now()}`);
mkdirSync(testDir, { recursive: true });
mkdirSync(join(testDir, ".understand-anything"), { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
describe("DEFAULT_IGNORE_PATTERNS", () => {
it("contains node_modules", () => {
expect(DEFAULT_IGNORE_PATTERNS).toContain("node_modules/");
});
it("contains .git", () => {
expect(DEFAULT_IGNORE_PATTERNS).toContain(".git/");
});
it("contains bin and obj for .NET", () => {
expect(DEFAULT_IGNORE_PATTERNS).toContain("bin/");
expect(DEFAULT_IGNORE_PATTERNS).toContain("obj/");
});
it("contains build output directories", () => {
expect(DEFAULT_IGNORE_PATTERNS).toContain("dist/");
expect(DEFAULT_IGNORE_PATTERNS).toContain("build/");
expect(DEFAULT_IGNORE_PATTERNS).toContain("out/");
expect(DEFAULT_IGNORE_PATTERNS).toContain("coverage/");
});
});
describe("createIgnoreFilter with no user file", () => {
it("ignores files matching default patterns", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("node_modules/foo/bar.js")).toBe(true);
expect(filter.isIgnored("dist/index.js")).toBe(true);
expect(filter.isIgnored(".git/config")).toBe(true);
expect(filter.isIgnored("bin/Debug/app.dll")).toBe(true);
expect(filter.isIgnored("obj/Release/net8.0/app.dll")).toBe(true);
});
it("does not ignore source files", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("src/index.ts")).toBe(false);
expect(filter.isIgnored("README.md")).toBe(false);
expect(filter.isIgnored("package.json")).toBe(false);
});
it("ignores lock files", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("pnpm-lock.yaml")).toBe(true);
expect(filter.isIgnored("package-lock.json")).toBe(true);
expect(filter.isIgnored("yarn.lock")).toBe(true);
});
it("ignores binary/asset files", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("logo.png")).toBe(true);
expect(filter.isIgnored("font.woff2")).toBe(true);
expect(filter.isIgnored("doc.pdf")).toBe(true);
});
it("ignores generated files", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("bundle.min.js")).toBe(true);
expect(filter.isIgnored("style.min.css")).toBe(true);
expect(filter.isIgnored("source.map")).toBe(true);
});
it("ignores IDE directories", () => {
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored(".idea/workspace.xml")).toBe(true);
expect(filter.isIgnored(".vscode/settings.json")).toBe(true);
});
});
describe("createIgnoreFilter with user .understandignore", () => {
it("reads patterns from .understand-anything/.understandignore", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"# Exclude tests\n__tests__/\n*.test.ts\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("__tests__/foo.test.ts")).toBe(true);
expect(filter.isIgnored("src/utils.test.ts")).toBe(true);
expect(filter.isIgnored("src/utils.ts")).toBe(false);
});
it("reads patterns from project root .understandignore", () => {
writeFileSync(
join(testDir, ".understandignore"),
"docs/\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("docs/README.md")).toBe(true);
expect(filter.isIgnored("src/index.ts")).toBe(false);
});
it("handles # comments and blank lines", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"# This is a comment\n\n\nfixtures/\n\n# Another comment\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("fixtures/data.json")).toBe(true);
expect(filter.isIgnored("src/index.ts")).toBe(false);
});
it("supports ! negation to override defaults", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"!dist/\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("dist/index.js")).toBe(false);
});
it("supports ** recursive matching", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"**/snapshots/\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("src/components/snapshots/Button.snap")).toBe(true);
expect(filter.isIgnored("snapshots/foo.snap")).toBe(true);
});
it("merges .understand-anything/ and root .understandignore", () => {
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"__tests__/\n"
);
writeFileSync(
join(testDir, ".understandignore"),
"fixtures/\n"
);
const filter = createIgnoreFilter(testDir);
expect(filter.isIgnored("__tests__/foo.ts")).toBe(true);
expect(filter.isIgnored("fixtures/data.json")).toBe(true);
expect(filter.isIgnored("src/index.ts")).toBe(false);
});
});
});
@@ -0,0 +1,112 @@
import ignore, { type Ignore } from "ignore";
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
/**
* Hardcoded default ignore patterns matching the project-scanner agent's
* exclusion rules, plus bin/obj for .NET projects.
*/
export const DEFAULT_IGNORE_PATTERNS: string[] = [
// Dependency directories
"node_modules/",
".git/",
"vendor/",
"venv/",
".venv/",
"__pycache__/",
// Build output
"dist/",
"build/",
"out/",
"coverage/",
".next/",
".cache/",
".turbo/",
"target/",
"bin/",
"obj/",
// Lock files
"*.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
// Binary/asset files
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.svg",
"*.ico",
"*.woff",
"*.woff2",
"*.ttf",
"*.eot",
"*.mp3",
"*.mp4",
"*.pdf",
"*.zip",
"*.tar",
"*.gz",
// Generated files
"*.min.js",
"*.min.css",
"*.map",
"*.generated.*",
// IDE/editor
".idea/",
".vscode/",
// Misc
"LICENSE",
".gitignore",
".editorconfig",
".prettierrc",
".eslintrc*",
"*.log",
];
export interface IgnoreFilter {
/** Returns true if the given relative path should be excluded from analysis. */
isIgnored(relativePath: string): boolean;
}
/**
* Creates an IgnoreFilter that merges hardcoded defaults with user-defined
* patterns from .understandignore files.
*
* Pattern load order (later entries can override earlier ones via ! negation):
* 1. Hardcoded defaults
* 2. .understand-anything/.understandignore (if exists)
* 3. .understandignore at project root (if exists)
*/
export function createIgnoreFilter(projectRoot: string): IgnoreFilter {
const ig: Ignore = ignore();
// Layer 1: hardcoded defaults
ig.add(DEFAULT_IGNORE_PATTERNS);
// Layer 2: .understand-anything/.understandignore
const projectIgnorePath = join(projectRoot, ".understand-anything", ".understandignore");
if (existsSync(projectIgnorePath)) {
const content = readFileSync(projectIgnorePath, "utf-8");
ig.add(content);
}
// Layer 3: .understandignore at project root
const rootIgnorePath = join(projectRoot, ".understandignore");
if (existsSync(rootIgnorePath)) {
const content = readFileSync(rootIgnorePath, "utf-8");
ig.add(content);
}
return {
isIgnored(relativePath: string): boolean {
return ig.ignores(relativePath);
},
};
}