From 5e86254b7775b9759dbaa05b667e77241885b1bc Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 10 Apr 2026 11:33:10 +0800 Subject: [PATCH] feat(core): add IgnoreFilter module with .understandignore parsing and tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../core/src/__tests__/ignore-filter.test.ts | 153 ++++++++++++++++++ .../packages/core/src/ignore-filter.ts | 112 +++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 understand-anything-plugin/packages/core/src/__tests__/ignore-filter.test.ts create mode 100644 understand-anything-plugin/packages/core/src/ignore-filter.ts diff --git a/understand-anything-plugin/packages/core/src/__tests__/ignore-filter.test.ts b/understand-anything-plugin/packages/core/src/__tests__/ignore-filter.test.ts new file mode 100644 index 0000000..28c8c7e --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/ignore-filter.test.ts @@ -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); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/ignore-filter.ts b/understand-anything-plugin/packages/core/src/ignore-filter.ts new file mode 100644 index 0000000..88a65b9 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/ignore-filter.ts @@ -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); + }, + }; +}