diff --git a/docs/superpowers/plans/2026-04-10-understandignore-impl.md b/docs/superpowers/plans/2026-04-10-understandignore-impl.md new file mode 100644 index 0000000..4bc782d --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-understandignore-impl.md @@ -0,0 +1,776 @@ +# .understandignore Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add user-configurable file exclusion via `.understandignore` files using `.gitignore` syntax, with auto-generated starter files and a pre-analysis review pause. + +**Architecture:** An `IgnoreFilter` module in `packages/core` uses the `ignore` npm package to parse `.understandignore` files and filter paths. A companion `IgnoreGenerator` scans the project for common patterns and produces a commented-out starter file. The `project-scanner` agent applies the filter as a second pass after its existing hardcoded exclusions. The `/understand` skill adds a Phase 0.5 that generates the starter file and pauses for user review. + +**Tech Stack:** TypeScript, `ignore` npm package, Vitest + +**Spec:** `docs/superpowers/specs/2026-04-10-understandignore-design.md` + +--- + +## File Structure + +### Core package +- Create: `understand-anything-plugin/packages/core/src/ignore-filter.ts` — parse .understandignore, merge with defaults, filter paths +- Create: `understand-anything-plugin/packages/core/src/ignore-generator.ts` — generate starter .understandignore by scanning project +- Create: `understand-anything-plugin/packages/core/src/__tests__/ignore-filter.test.ts` — filter tests +- Create: `understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts` — generator tests +- Modify: `understand-anything-plugin/packages/core/src/index.ts` — export new modules +- Modify: `understand-anything-plugin/packages/core/package.json` — add `ignore` dependency + +### Agents & skills +- Modify: `understand-anything-plugin/agents/project-scanner.md` — add Layer 2 filtering step +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` — add Phase 0.5 + +--- + +## Task 1: Add `ignore` dependency + +**Files:** +- Modify: `understand-anything-plugin/packages/core/package.json` + +- [ ] **Step 1: Install the `ignore` npm package** + +Run: +```bash +cd understand-anything-plugin && pnpm add --filter @understand-anything/core ignore +``` + +- [ ] **Step 2: Verify it was added** + +Run: `grep ignore understand-anything-plugin/packages/core/package.json` +Expected: `"ignore": "^7.x.x"` (or similar) in dependencies + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/core/package.json understand-anything-plugin/pnpm-lock.yaml +git commit -m "chore(core): add ignore package for .understandignore support" +``` + +--- + +## Task 2: Create IgnoreFilter module with tests (TDD) + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/ignore-filter.ts` +- Create: `understand-anything-plugin/packages/core/src/__tests__/ignore-filter.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `understand-anything-plugin/packages/core/src/__tests__/ignore-filter.test.ts`: + +```typescript +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); + // dist/ is in defaults but negated by user + 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); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @understand-anything/core test -- --run src/__tests__/ignore-filter.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement IgnoreFilter** + +Create `understand-anything-plugin/packages/core/src/ignore-filter.ts`: + +```typescript +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); + }, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @understand-anything/core test -- --run src/__tests__/ignore-filter.test.ts` +Expected: All tests PASS + +- [ ] **Step 5: Build to verify no type errors** + +Run: `pnpm --filter @understand-anything/core build` +Expected: Clean build + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/ignore-filter.ts understand-anything-plugin/packages/core/src/__tests__/ignore-filter.test.ts +git commit -m "feat(core): add IgnoreFilter module with .understandignore parsing and tests" +``` + +--- + +## Task 3: Create IgnoreGenerator module with tests (TDD) + +**Files:** +- Create: `understand-anything-plugin/packages/core/src/ignore-generator.ts` +- Create: `understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts` + +- [ ] **Step 1: Write the failing tests** + +Create `understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { generateStarterIgnoreFile } from "../ignore-generator"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("generateStarterIgnoreFile", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `ignore-gen-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("includes a header comment explaining the file", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain(".understandignore"); + expect(content).toContain("same as .gitignore"); + expect(content).toContain("Built-in defaults"); + }); + + it("all suggestions are commented out", () => { + // Create some directories to trigger suggestions + mkdirSync(join(testDir, "__tests__"), { recursive: true }); + mkdirSync(join(testDir, "docs"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); + // No active (uncommented) patterns + expect(lines).toHaveLength(0); + }); + + it("suggests __tests__ when __tests__ directory exists", () => { + mkdirSync(join(testDir, "__tests__"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# __tests__/"); + }); + + it("suggests docs when docs directory exists", () => { + mkdirSync(join(testDir, "docs"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# docs/"); + }); + + it("suggests test directories when they exist", () => { + mkdirSync(join(testDir, "test"), { recursive: true }); + mkdirSync(join(testDir, "tests"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# test/"); + expect(content).toContain("# tests/"); + }); + + it("suggests fixtures when fixtures directory exists", () => { + mkdirSync(join(testDir, "fixtures"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# fixtures/"); + }); + + it("suggests examples when examples directory exists", () => { + mkdirSync(join(testDir, "examples"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# examples/"); + }); + + it("suggests .storybook when .storybook directory exists", () => { + mkdirSync(join(testDir, ".storybook"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# .storybook/"); + }); + + it("suggests migrations when migrations directory exists", () => { + mkdirSync(join(testDir, "migrations"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# migrations/"); + }); + + it("suggests scripts when scripts directory exists", () => { + mkdirSync(join(testDir, "scripts"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# scripts/"); + }); + + it("always includes generic suggestions", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# *.snap"); + expect(content).toContain("# *.test.*"); + expect(content).toContain("# *.spec.*"); + }); + + it("does not suggest directories that don't exist", () => { + const content = generateStarterIgnoreFile(testDir); + // __tests__ doesn't exist, so it shouldn't be in directory suggestions + // (it may still be in generic test file patterns) + expect(content).not.toContain("# __tests__/"); + expect(content).not.toContain("# .storybook/"); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm --filter @understand-anything/core test -- --run src/__tests__/ignore-generator.test.ts` +Expected: FAIL — module not found + +- [ ] **Step 3: Implement IgnoreGenerator** + +Create `understand-anything-plugin/packages/core/src/ignore-generator.ts`: + +```typescript +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +const HEADER = `# .understandignore — patterns for files/dirs to exclude from analysis +# Syntax: same as .gitignore (globs, # comments, ! negation, trailing / for dirs) +# Lines below are suggestions — uncomment to activate. +# Use ! prefix to force-include something excluded by defaults. +# +# Built-in defaults (always excluded unless negated): +# node_modules/, .git/, dist/, build/, bin/, obj/, *.lock, *.min.js, etc. +# +`; + +/** Directories to check for and suggest excluding. */ +const DETECTABLE_DIRS = [ + { dir: "__tests__", pattern: "__tests__/" }, + { dir: "test", pattern: "test/" }, + { dir: "tests", pattern: "tests/" }, + { dir: "fixtures", pattern: "fixtures/" }, + { dir: "testdata", pattern: "testdata/" }, + { dir: "docs", pattern: "docs/" }, + { dir: "examples", pattern: "examples/" }, + { dir: "scripts", pattern: "scripts/" }, + { dir: "migrations", pattern: "migrations/" }, + { dir: ".storybook", pattern: ".storybook/" }, +]; + +/** Always-included generic suggestions. */ +const GENERIC_SUGGESTIONS = [ + "*.test.*", + "*.spec.*", + "*.snap", +]; + +/** + * Generates a starter .understandignore file by scanning the project root + * for common directories and suggesting them as commented-out exclusions. + * + * All suggestions are commented out — the user must uncomment to activate. + * Returns the file content as a string. + */ +export function generateStarterIgnoreFile(projectRoot: string): string { + const sections: string[] = [HEADER]; + + // Detected directory suggestions + const detected: string[] = []; + for (const { dir, pattern } of DETECTABLE_DIRS) { + if (existsSync(join(projectRoot, dir))) { + detected.push(pattern); + } + } + + if (detected.length > 0) { + sections.push("# --- Detected directories (uncomment to exclude) ---\n"); + for (const pattern of detected) { + sections.push(`# ${pattern}`); + } + sections.push(""); + } + + // Generic suggestions (always included) + sections.push("# --- Test file patterns (uncomment to exclude) ---\n"); + for (const pattern of GENERIC_SUGGESTIONS) { + sections.push(`# ${pattern}`); + } + sections.push(""); + + return sections.join("\n"); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm --filter @understand-anything/core test -- --run src/__tests__/ignore-generator.test.ts` +Expected: All tests PASS + +- [ ] **Step 5: Build** + +Run: `pnpm --filter @understand-anything/core build` +Expected: Clean build + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/ignore-generator.ts understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts +git commit -m "feat(core): add IgnoreGenerator for starter .understandignore file creation" +``` + +--- + +## Task 4: Export new modules from core + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/index.ts` + +- [ ] **Step 1: Add exports** + +Add to the end of `understand-anything-plugin/packages/core/src/index.ts`: + +```typescript +export { + createIgnoreFilter, + DEFAULT_IGNORE_PATTERNS, + type IgnoreFilter, +} from "./ignore-filter.js"; +export { generateStarterIgnoreFile } from "./ignore-generator.js"; +``` + +- [ ] **Step 2: Build and run all tests** + +Run: `pnpm --filter @understand-anything/core build && pnpm --filter @understand-anything/core test -- --run` +Expected: Clean build, all tests pass + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/core/src/index.ts +git commit -m "feat(core): export IgnoreFilter and IgnoreGenerator from core index" +``` + +--- + +## Task 5: Update project-scanner agent + +**Files:** +- Modify: `understand-anything-plugin/agents/project-scanner.md` + +- [ ] **Step 1: Read the current project-scanner.md** + +Read `understand-anything-plugin/agents/project-scanner.md` to understand the current structure. + +- [ ] **Step 2: Add bin/ and obj/ to hardcoded exclusions** + +In Step 2 (Exclusion Filtering), add `bin/` and `obj/` to the "Build output" line: + +Change: +``` +- **Build output:** paths with a directory segment matching `dist/`, `build/`, `out/`, `coverage/`, `.next/`, `.cache/`, `.turbo/`, `target/` (Rust) +``` + +To: +``` +- **Build output:** paths with a directory segment matching `dist/`, `build/`, `out/`, `coverage/`, `.next/`, `.cache/`, `.turbo/`, `target/` (Rust), `bin/` (.NET), `obj/` (.NET) +``` + +- [ ] **Step 3: Add Layer 2 filtering step** + +After Step 2 (Exclusion Filtering), add a new step: + +```markdown +**Step 2.5 -- User-Configured Filtering (.understandignore)** + +After applying the hardcoded exclusion filters above, apply user-configured patterns from `.understandignore`: + +1. Check if `.understand-anything/.understandignore` exists in the project root. If so, read it. +2. Check if `.understandignore` exists in the project root. If so, read it. +3. Parse both files using `.gitignore` syntax (glob patterns, `#` comments, blank lines ignored, `!` prefix for negation, trailing `/` for directories, `**/` for recursive matching). +4. Filter the remaining file list through these patterns. Files matching any pattern are excluded. +5. `!` negation patterns override the hardcoded exclusions from Step 2 (e.g., `!dist/` force-includes dist/). +6. Track the count of files removed by this step as `filteredByIgnore`. + +This filtering must be deterministic (not LLM-based). Use a Node.js script with the `ignore` npm package if implementing programmatically, or apply the patterns manually if the file list is small. +``` + +- [ ] **Step 4: Update scan output schema** + +Find the output JSON schema section and add `filteredByIgnore` field: + +```json +{ + "name": "...", + "description": "...", + "languages": ["..."], + "frameworks": ["..."], + "files": [...], + "totalFiles": 123, + "filteredByIgnore": 5, + "estimatedComplexity": "moderate", + "importMap": {} +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/agents/project-scanner.md +git commit -m "feat(agent): add .understandignore support and bin/obj exclusions to project-scanner" +``` + +--- + +## Task 6: Update /understand skill with Phase 0.5 + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` + +- [ ] **Step 1: Read the current SKILL.md Phase 0 section** + +Read `understand-anything-plugin/skills/understand/SKILL.md` lines 22-80 to understand Phase 0. + +- [ ] **Step 2: Add Phase 0.5 after Phase 0** + +After the Phase 0 section (after the `---` separator before Phase 1), insert: + +```markdown +## Phase 0.5 — Ignore Configuration + +Set up and verify the `.understandignore` file before scanning. + +1. Check if `$PROJECT_ROOT/.understand-anything/.understandignore` exists. +2. **If it does NOT exist**, generate a starter file: + - Run a Node.js script (or inline logic) that scans `$PROJECT_ROOT` for common directories (`__tests__/`, `test/`, `tests/`, `fixtures/`, `testdata/`, `docs/`, `examples/`, `scripts/`, `migrations/`, `.storybook/`) and generates a `.understandignore` file with commented-out suggestions. + - Write the generated content to `$PROJECT_ROOT/.understand-anything/.understandignore`. + - Report to the user: + > "Generated `.understand-anything/.understandignore` with suggested exclusions based on your project structure. Please review it and uncomment any patterns you'd like to exclude from analysis. When ready, confirm to continue." + - **Wait for user confirmation before proceeding.** +3. **If it already exists**, report: + > "Found `.understand-anything/.understandignore`. Review it if needed, then confirm to continue." + - **Wait for user confirmation before proceeding.** +4. After confirmation, proceed to Phase 1. + +**Note:** The `.understandignore` file uses `.gitignore` syntax. The user can add patterns to exclude files from analysis, or use `!` prefix to force-include files excluded by built-in defaults (e.g., `!dist/` to analyze dist/ files). + +--- +``` + +- [ ] **Step 3: Update Phase 1 reporting** + +In the Phase 1 section, after the gate check (~line 114), add a note about reporting ignore stats: + +```markdown +After scanning, if the scan result includes `filteredByIgnore > 0`, report: +> "Scanned {totalFiles} files ({filteredByIgnore} excluded by .understandignore)" +``` + +- [ ] **Step 4: Commit** + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md +git commit -m "feat(skill): add Phase 0.5 for .understandignore setup and review pause" +``` + +--- + +## Task 7: Build, test, and verify end-to-end + +**Files:** +- All modified files + +- [ ] **Step 1: Build core** + +Run: `pnpm --filter @understand-anything/core build` +Expected: Clean build + +- [ ] **Step 2: Run all core tests** + +Run: `pnpm --filter @understand-anything/core test -- --run` +Expected: All tests pass (existing + new ignore-filter + ignore-generator tests) + +- [ ] **Step 3: Build skill package** + +Run: `pnpm --filter @understand-anything/skill build` +Expected: Clean build + +- [ ] **Step 4: Verify files exist** + +Run: +```bash +ls understand-anything-plugin/packages/core/src/ignore-filter.ts understand-anything-plugin/packages/core/src/ignore-generator.ts +``` +Expected: Both files listed + +- [ ] **Step 5: Verify exports work** + +Run: +```bash +node -e "import('@understand-anything/core').then(m => { console.log('IgnoreFilter:', typeof m.createIgnoreFilter); console.log('Generator:', typeof m.generateStarterIgnoreFile); })" +``` +Expected: Both show `function` + +- [ ] **Step 6: Final commit (if any unstaged changes)** + +```bash +git status +# If clean, skip. If changes exist: +git add -A && git commit -m "chore: final verification for .understandignore support" +``` diff --git a/docs/superpowers/specs/2026-04-10-understandignore-design.md b/docs/superpowers/specs/2026-04-10-understandignore-design.md new file mode 100644 index 0000000..2d71f51 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-understandignore-design.md @@ -0,0 +1,258 @@ +# .understandignore Design Spec + +## Overview + +Add user-configurable file exclusion via `.understandignore` files, using `.gitignore` syntax. This makes analysis faster by skipping irrelevant files (vendor code, generated output, test fixtures) without modifying hardcoded defaults. + +## Goals + +- Let users exclude files/directories from analysis via `.understandignore` +- Use `.gitignore` syntax (familiar, no learning curve) +- Keep hardcoded defaults as built-in — `.understandignore` adds patterns on top +- Allow `!` negation to force-include files excluded by defaults +- Auto-generate a commented-out starter file on first run (deterministic code, not LLM) +- Pause before analysis to let user review the ignore file + +## Non-Goals + +- Replacing `.gitignore` — this is analysis-specific +- Per-directory `.understandignore` files (project root and `.understand-anything/` only) +- GUI for editing ignore patterns + +--- + +## IgnoreFilter Module + +New file: `packages/core/src/ignore-filter.ts` + +Uses the [`ignore`](https://www.npmjs.com/package/ignore) npm package for gitignore-compatible pattern matching. + +### API + +```typescript +export interface IgnoreFilter { + isIgnored(relativePath: string): boolean; +} + +export function createIgnoreFilter(projectRoot: string): IgnoreFilter; +``` + +### Behavior + +`createIgnoreFilter` loads patterns in this order (later entries can override earlier ones): + +1. **Hardcoded defaults** — the existing exclusion patterns from project-scanner (node_modules/, .git/, dist/, build/, bin/, obj/, *.lock, *.min.js, etc.) +2. **`.understand-anything/.understandignore`** — project-level, lives alongside the output +3. **`.understandignore`** at project root — alternative location for visibility + +Patterns merge additively. `!` negation in user files can override hardcoded defaults (e.g., `!dist/` force-includes dist/). + +### Hardcoded Default Patterns + +These are the built-in defaults (matching current project-scanner behavior, plus bin/obj for .NET): + +``` +# 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 +``` + +--- + +## Starter File Generator + +New file: `packages/core/src/ignore-generator.ts` + +### API + +```typescript +export function generateStarterIgnoreFile(projectRoot: string): string; +``` + +### Behavior + +- Deterministic code — scans the project directory for common patterns +- Returns the file content as a string (caller writes it to disk) +- All suggestions are **commented out** — user must uncomment to activate +- Header comment explains the file, syntax, and built-in defaults + +### Detection Logic + +| If exists | Suggest | +|-----------|---------| +| `__tests__/` or `*.test.*` files | `# __tests__/`, `# *.test.*`, `# *.spec.*` | +| `fixtures/` or `testdata/` | `# fixtures/`, `# testdata/` | +| `test/` or `tests/` | `# test/`, `# tests/` | +| `.storybook/` | `# .storybook/` | +| `docs/` | `# docs/` | +| `examples/` | `# examples/` | +| `scripts/` | `# scripts/` | +| `migrations/` | `# migrations/` | +| `*.snap` files | `# *.snap` | +| `bin/` (non-.NET, i.e. shell scripts) | `# bin/` | +| `obj/` | `# obj/` | + +### Generated File Format + +``` +# .understandignore — patterns for files/dirs to exclude from analysis +# Syntax: same as .gitignore (globs, # comments, ! negation, trailing / for dirs) +# Lines below are suggestions — uncomment to activate. +# Use ! prefix to force-include something excluded by defaults. +# +# Built-in defaults (always excluded unless negated): +# node_modules/, .git/, dist/, build/, bin/, obj/, *.lock, *.min.js, etc. +# + +# --- Suggested exclusions (uncomment to activate) --- + +# Test files +# __tests__/ +# *.test.* +# *.spec.* + +# Test data +# fixtures/ +# testdata/ + +# Documentation +# docs/ + +# ... (more suggestions based on detection) +``` + +Only generated if `.understand-anything/.understandignore` doesn't already exist. + +--- + +## Skill Integration + +### Phase 0.5: Ignore Setup (new phase in SKILL.md) + +Added between Pre-flight (Phase 0) and SCAN (Phase 1): + +1. Check if `.understand-anything/.understandignore` exists +2. If not, run `generateStarterIgnoreFile(projectRoot)` and write the result to `.understand-anything/.understandignore` +3. Report to user: + - **First run:** "Generated `.understand-anything/.understandignore` with suggested exclusions. Please review it and uncomment any patterns you'd like to exclude. When ready, confirm to continue." + - **Subsequent runs:** "Found `.understand-anything/.understandignore`. Review it if needed, then confirm to continue." +4. Wait for user confirmation before proceeding + +### Phase 1: SCAN changes + +The `project-scanner` agent's scan script is updated to: + +1. Collect files via `git ls-files` (or fallback) +2. Apply agent's hardcoded pattern filter (Layer 1 — existing behavior) +3. Apply `IgnoreFilter` from core (Layer 2 — user patterns) +4. Add `filteredByIgnore` count to scan output +5. Report: "Scanned {totalFiles} files ({filteredByIgnore} excluded by .understandignore)" + +Two-layer filtering: +- **Layer 1:** Agent's hardcoded patterns in the prompt (fast, coarse filter) +- **Layer 2:** `IgnoreFilter` from core (deterministic code, user-configurable) + +--- + +## Project Scanner Agent Update + +Changes to `understand-anything-plugin/agents/project-scanner.md`: + +- After the file list is built and Layer 1 filtering is applied, the agent runs a Node.js script that imports `createIgnoreFilter` from `@understand-anything/core` and filters the remaining paths +- The scan result JSON includes a new `filteredByIgnore: number` field +- Existing hardcoded exclusion patterns in the agent prompt remain for backward compatibility + +--- + +## Testing + +### `packages/core/src/__tests__/ignore-filter.test.ts` + +- Parses basic glob patterns (`*.log`, `dist/`) +- Handles `#` comments and blank lines +- Handles `!` negation (force-include) +- Handles `**/` recursive matching +- Handles trailing `/` for directory-only patterns +- Merges defaults + user patterns correctly +- `!` in user file overrides hardcoded defaults +- Returns `false` for paths not matching any pattern + +### `packages/core/src/__tests__/ignore-generator.test.ts` + +- Generates starter file with header comment +- Detects existing directories and suggests relevant patterns +- All suggestions are commented out (prefixed with `# `) +- Doesn't overwrite existing file +- Includes bin/obj suggestions when relevant + +--- + +## File Structure + +| File | Purpose | +|------|---------| +| `packages/core/src/ignore-filter.ts` | Parse .understandignore, merge with defaults, filter paths | +| `packages/core/src/ignore-generator.ts` | Generate starter file by scanning project structure | +| `packages/core/src/__tests__/ignore-filter.test.ts` | Filter logic tests | +| `packages/core/src/__tests__/ignore-generator.test.ts` | Generator tests | +| `agents/project-scanner.md` | Add Layer 2 filtering via IgnoreFilter | +| `skills/understand/SKILL.md` | Add Phase 0.5 (generate + pause for review) | +| `packages/core/package.json` | Add `ignore` npm dependency | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6efbda..327a918 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.1.0 + ignore: + specifier: ^7.0.5 + version: 7.0.5 tree-sitter-javascript: specifier: ^0.25.0 version: 0.25.0 @@ -1586,6 +1589,10 @@ packages: http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -4121,6 +4128,8 @@ snapshots: http-cache-semantics@4.2.0: {} + ignore@7.0.5: {} + inline-style-parser@0.2.7: {} iron-webcrypto@1.2.1: {} diff --git a/understand-anything-plugin/agents/project-scanner.md b/understand-anything-plugin/agents/project-scanner.md index e8814f9..2ccc2fe 100644 --- a/understand-anything-plugin/agents/project-scanner.md +++ b/understand-anything-plugin/agents/project-scanner.md @@ -39,7 +39,7 @@ Discover all tracked files. In order of preference: Remove ALL files matching these patterns: - **Dependency directories:** paths containing `node_modules/`, `.git/`, `vendor/`, `venv/`, `.venv/`, `__pycache__/` -- **Build output:** paths with a directory segment matching `dist/`, `build/`, `out/`, `coverage/`, `.next/`, `.cache/`, `.turbo/`, `target/` (Rust) — match full directory segments only, not substrings (e.g., `buildSrc/` should NOT be excluded) +- **Build output:** paths with a directory segment matching `dist/`, `build/`, `out/`, `coverage/`, `.next/`, `.cache/`, `.turbo/`, `target/` (Rust), `obj/` (.NET) — match full directory segments only, not substrings (e.g., `buildSrc/` should NOT be excluded). Note: `bin/` is NOT excluded by default because Node.js and Ruby projects use `bin/` for CLI launchers; .NET users can add `bin/` to `.understandignore`. - **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.*` (note: do NOT exclude `*.d.ts` — many projects have hand-written declaration files) @@ -58,6 +58,18 @@ Remove ALL files matching these patterns: **Note on package manifests:** Config files read for framework detection (`package.json`, `tsconfig.json`, `Cargo.toml`, `go.mod`, `pyproject.toml`, etc.) should also appear in the file list with `fileCategory: "config"`. +**Step 2.5 -- User-Configured Filtering (.understandignore)** + +When `.understandignore` files exist, **replace** Step 2's hardcoded filtering with a unified filter that combines defaults and user patterns in a single pass. This ensures `!` negation patterns can override defaults. + +1. Check if `$PROJECT_ROOT/.understand-anything/.understandignore` exists. If so, read it. +2. Check if `$PROJECT_ROOT/.understandignore` exists. If so, read it. +3. If neither file exists, skip this step entirely — Step 2's hardcoded filtering is sufficient. +4. If at least one file exists, re-filter the **original file list from Step 1** (not the Step 2 output) using the `createIgnoreFilter` function from `@understand-anything/core`, which merges hardcoded defaults and user patterns into a single `.gitignore`-compatible matcher. This ensures `!` negation in user files can override hardcoded defaults (e.g., `!dist/` force-includes dist/ files). +5. Track the count of additional files removed beyond Step 2's baseline as `filteredByIgnore`. + +This filtering must be deterministic (not LLM-based). Use a Node.js script with the `ignore` npm package from `@understand-anything/core`. + **Step 3 -- Language Detection** Map file extensions to language identifiers: @@ -217,6 +229,7 @@ The script must write this exact JSON structure to the output file: {"path": "package.json", "language": "json", "sizeLines": 35, "fileCategory": "config"} ], "totalFiles": 42, + "filteredByIgnore": 0, "estimatedComplexity": "moderate", "importMap": { "src/index.ts": ["src/utils.ts", "src/config.ts"], @@ -237,6 +250,7 @@ The script must write this exact JSON structure to the output file: - `files` (object[]) -- every discovered file, sorted by `path` alphabetically - `files[].fileCategory` (string) -- one of: `code`, `config`, `docs`, `infra`, `data`, `script`, `markup` - `totalFiles` (integer) -- must equal `files.length` +- `filteredByIgnore` (integer) -- count of files removed by `.understandignore` patterns in Step 2.5; 0 if no `.understandignore` file exists - `estimatedComplexity` (string) -- one of `small`, `moderate`, `large`, `very-large` - `importMap` (object) -- map from every file path to its list of resolved project-internal import paths; empty array for non-code files and files with no resolved imports; external packages excluded @@ -281,6 +295,7 @@ Then assemble the final output JSON: {"path": "Dockerfile", "language": "dockerfile", "sizeLines": 22, "fileCategory": "infra"} ], "totalFiles": 42, + "filteredByIgnore": 0, "estimatedComplexity": "moderate", "importMap": { "src/index.ts": ["src/utils.ts"] @@ -295,6 +310,7 @@ Then assemble the final output JSON: - `frameworks` (string[]): directly from script output - `files` (object[]): directly from script output, including `fileCategory` per file - `totalFiles` (integer): directly from script output +- `filteredByIgnore` (integer): directly from script output - `estimatedComplexity` (string): directly from script output - `importMap` (object): directly from script output diff --git a/understand-anything-plugin/packages/core/package.json b/understand-anything-plugin/packages/core/package.json index 457aae8..2318ddc 100644 --- a/understand-anything-plugin/packages/core/package.json +++ b/understand-anything-plugin/packages/core/package.json @@ -38,6 +38,7 @@ }, "dependencies": { "fuse.js": "^7.1.0", + "ignore": "^7.0.5", "tree-sitter-javascript": "^0.25.0", "tree-sitter-typescript": "^0.23.2", "web-tree-sitter": "^0.26.6", 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..d7f9626 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/ignore-filter.test.ts @@ -0,0 +1,155 @@ +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 obj for .NET", () => { + expect(DEFAULT_IGNORE_PATTERNS).toContain("obj/"); + }); + + it("does not contain bin (used by Node/Ruby CLI launchers)", () => { + expect(DEFAULT_IGNORE_PATTERNS).not.toContain("bin/"); + }); + + 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("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/__tests__/ignore-generator.test.ts b/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts new file mode 100644 index 0000000..5d47140 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { generateStarterIgnoreFile } from "../ignore-generator"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +describe("generateStarterIgnoreFile", () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `ignore-gen-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it("includes a header comment explaining the file", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain(".understandignore"); + expect(content).toContain("same as .gitignore"); + expect(content).toContain("Built-in defaults"); + }); + + it("all suggestions are commented out", () => { + mkdirSync(join(testDir, "__tests__"), { recursive: true }); + mkdirSync(join(testDir, "docs"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); + expect(lines).toHaveLength(0); + }); + + it("suggests __tests__ when directory exists", () => { + mkdirSync(join(testDir, "__tests__"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# __tests__/"); + }); + + it("suggests docs when directory exists", () => { + mkdirSync(join(testDir, "docs"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# docs/"); + }); + + it("suggests test and tests when they exist", () => { + mkdirSync(join(testDir, "test"), { recursive: true }); + mkdirSync(join(testDir, "tests"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# test/"); + expect(content).toContain("# tests/"); + }); + + it("suggests fixtures when directory exists", () => { + mkdirSync(join(testDir, "fixtures"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# fixtures/"); + }); + + it("suggests examples when directory exists", () => { + mkdirSync(join(testDir, "examples"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# examples/"); + }); + + it("suggests .storybook when directory exists", () => { + mkdirSync(join(testDir, ".storybook"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# .storybook/"); + }); + + it("suggests migrations when directory exists", () => { + mkdirSync(join(testDir, "migrations"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# migrations/"); + }); + + it("suggests scripts when directory exists", () => { + mkdirSync(join(testDir, "scripts"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# scripts/"); + }); + + it("always includes generic test file suggestions", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# *.snap"); + expect(content).toContain("# *.test.*"); + expect(content).toContain("# *.spec.*"); + }); + + it("does not suggest directories that don't exist", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).not.toContain("# __tests__/"); + expect(content).not.toContain("# .storybook/"); + expect(content).not.toContain("# fixtures/"); + }); + + describe(".gitignore integration", () => { + it("includes .gitignore patterns not covered by defaults", () => { + writeFileSync(join(testDir, ".gitignore"), ".env\nsecrets/\n*.pyc\n"); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("From .gitignore"); + expect(content).toContain("# .env"); + expect(content).toContain("# secrets/"); + expect(content).toContain("# *.pyc"); + }); + + it("excludes .gitignore patterns already in defaults", () => { + writeFileSync(join(testDir, ".gitignore"), "node_modules/\ndist/\n.env\n"); + const content = generateStarterIgnoreFile(testDir); + // .env is not in defaults, should appear + expect(content).toContain("# .env"); + // node_modules/ and dist/ are in defaults, should not appear in .gitignore section + const gitignoreSection = content.split("From .gitignore")[1]?.split("---")[0] ?? ""; + expect(gitignoreSection).not.toContain("node_modules"); + expect(gitignoreSection).not.toContain("dist"); + }); + + it("skips .gitignore comments and blank lines", () => { + writeFileSync(join(testDir, ".gitignore"), "# a comment\n\n.env\n \n"); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# .env"); + // Should not include the original comment as a pattern + const gitignoreSection = content.split("From .gitignore")[1]?.split("---")[0] ?? ""; + expect(gitignoreSection).not.toContain("a comment"); + }); + + it("handles .gitignore with trailing-slash normalization for defaults", () => { + // "dist" without trailing slash should still match "dist/" default + writeFileSync(join(testDir, ".gitignore"), "dist\ncoverage\n.env\n"); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("From .gitignore"); + // Extract lines between the .gitignore header and the next section header + const lines = content.split("\n"); + const headerIdx = lines.findIndex((l) => l.includes("From .gitignore")); + const nextSectionIdx = lines.findIndex((l, i) => i > headerIdx && l.startsWith("# ---")); + const sectionLines = lines.slice(headerIdx + 1, nextSectionIdx === -1 ? undefined : nextSectionIdx); + const patterns = sectionLines.filter((l) => l.startsWith("# ") && !l.startsWith("# ---")).map((l) => l.slice(2)); + expect(patterns).toContain(".env"); + expect(patterns).not.toContain("dist"); + expect(patterns).not.toContain("coverage"); + }); + + it("omits .gitignore section when no .gitignore exists", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).not.toContain("From .gitignore"); + }); + + it("omits .gitignore section when all patterns are covered by defaults", () => { + writeFileSync(join(testDir, ".gitignore"), "node_modules/\ndist/\n*.lock\n"); + const content = generateStarterIgnoreFile(testDir); + expect(content).not.toContain("From .gitignore"); + }); + + it("all .gitignore suggestions are commented out", () => { + writeFileSync(join(testDir, ".gitignore"), ".env\nsecrets/\n*.pyc\n"); + const content = generateStarterIgnoreFile(testDir); + const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); + expect(lines).toHaveLength(0); + }); + }); +}); 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..a56d2e3 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/ignore-filter.ts @@ -0,0 +1,111 @@ +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/", + "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); + }, + }; +} diff --git a/understand-anything-plugin/packages/core/src/ignore-generator.ts b/understand-anything-plugin/packages/core/src/ignore-generator.ts new file mode 100644 index 0000000..f0e49ac --- /dev/null +++ b/understand-anything-plugin/packages/core/src/ignore-generator.ts @@ -0,0 +1,102 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { DEFAULT_IGNORE_PATTERNS } from "./ignore-filter.js"; + +const HEADER = `# .understandignore — patterns for files/dirs to exclude from analysis +# Syntax: same as .gitignore (globs, # comments, ! negation, trailing / for dirs) +# Lines below are suggestions — uncomment to activate. +# Use ! prefix to force-include something excluded by defaults. +# +# Built-in defaults (always excluded unless negated): +# node_modules/, .git/, dist/, build/, obj/, *.lock, *.min.js, etc. +# +`; + +const DETECTABLE_DIRS = [ + { dir: "__tests__", pattern: "__tests__/" }, + { dir: "test", pattern: "test/" }, + { dir: "tests", pattern: "tests/" }, + { dir: "fixtures", pattern: "fixtures/" }, + { dir: "testdata", pattern: "testdata/" }, + { dir: "docs", pattern: "docs/" }, + { dir: "examples", pattern: "examples/" }, + { dir: "scripts", pattern: "scripts/" }, + { dir: "migrations", pattern: "migrations/" }, + { dir: ".storybook", pattern: ".storybook/" }, +]; + +const GENERIC_SUGGESTIONS = [ + "*.test.*", + "*.spec.*", + "*.snap", +]; + +/** + * Parses a .gitignore file and returns active patterns (no comments, no blanks). + */ +function parseGitignorePatterns(gitignorePath: string): string[] { + if (!existsSync(gitignorePath)) return []; + const content = readFileSync(gitignorePath, "utf-8"); + return content + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); +} + +/** + * Returns true if a gitignore pattern is already covered by the hardcoded defaults. + * Normalizes trailing slashes for comparison. + */ +function isCoveredByDefaults(pattern: string): boolean { + const normalize = (p: string) => p.replace(/\/+$/, ""); + const normalized = normalize(pattern); + return DEFAULT_IGNORE_PATTERNS.some((d) => normalize(d) === normalized); +} + +/** + * Generates a starter .understandignore file content by scanning the project + * for common directories and reading .gitignore patterns. + * All suggestions are commented out — this is a one-time generation. + */ +export function generateStarterIgnoreFile(projectRoot: string): string { + const sections: string[] = [HEADER]; + + // Section 1: patterns from .gitignore not already in defaults + const gitignorePath = join(projectRoot, ".gitignore"); + const gitignorePatterns = parseGitignorePatterns(gitignorePath).filter( + (p) => !isCoveredByDefaults(p), + ); + + if (gitignorePatterns.length > 0) { + sections.push("# --- From .gitignore (uncomment to exclude) ---\n"); + for (const pattern of gitignorePatterns) { + sections.push(`# ${pattern}`); + } + sections.push(""); + } + + // Section 2: detected directories + const detected: string[] = []; + for (const { dir, pattern } of DETECTABLE_DIRS) { + if (existsSync(join(projectRoot, dir))) { + detected.push(pattern); + } + } + + if (detected.length > 0) { + sections.push("# --- Detected directories (uncomment to exclude) ---\n"); + for (const pattern of detected) { + sections.push(`# ${pattern}`); + } + sections.push(""); + } + + // Section 3: generic test patterns + sections.push("# --- Test file patterns (uncomment to exclude) ---\n"); + for (const pattern of GENERIC_SUGGESTIONS) { + sections.push(`# ${pattern}`); + } + sections.push(""); + + return sections.join("\n"); +} diff --git a/understand-anything-plugin/packages/core/src/index.ts b/understand-anything-plugin/packages/core/src/index.ts index 756284d..0375dad 100644 --- a/understand-anything-plugin/packages/core/src/index.ts +++ b/understand-anything-plugin/packages/core/src/index.ts @@ -114,3 +114,9 @@ export { ShellParser, registerAllParsers, } from "./plugins/parsers/index.js"; +export { + createIgnoreFilter, + DEFAULT_IGNORE_PATTERNS, + type IgnoreFilter, +} from "./ignore-filter.js"; +export { generateStarterIgnoreFile } from "./ignore-generator.js"; diff --git a/understand-anything-plugin/pnpm-lock.yaml b/understand-anything-plugin/pnpm-lock.yaml index 9f2efe4..72c86ba 100644 --- a/understand-anything-plugin/pnpm-lock.yaml +++ b/understand-anything-plugin/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: fuse.js: specifier: ^7.1.0 version: 7.1.0 + ignore: + specifier: ^7.0.5 + version: 7.0.5 tree-sitter-javascript: specifier: ^0.25.0 version: 0.25.0 @@ -1015,6 +1018,10 @@ packages: html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -2203,6 +2210,14 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -2513,6 +2528,8 @@ snapshots: html-url-attributes@3.0.1: {} + ignore@7.0.5: {} + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -3283,7 +3300,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index be26f59..efebf1e 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -77,6 +77,47 @@ Determine whether to run a full analysis or incremental update. --- +## Phase 0.5 — Ignore Configuration + +Set up and verify the `.understandignore` file before scanning. + +1. Check if `$PROJECT_ROOT/.understand-anything/.understandignore` exists. +2. **If it does NOT exist**, generate a starter file: + - Run the following Node.js one-liner in `$PROJECT_ROOT` (reads `.gitignore` and deduplicates against built-in defaults): + ```bash + node -e " + const fs = require('fs'); + const path = require('path'); + const root = process.cwd(); + const defaults = ['node_modules/','node_modules','.git/','vendor/','venv/','.venv/','__pycache__/','dist/','dist','build/','build','out/','coverage/','coverage','.next/','.cache/','.turbo/','target/','obj/','*.lock','package-lock.json','yarn.lock','pnpm-lock.yaml','*.png','*.jpg','*.jpeg','*.gif','*.svg','*.ico','*.woff','*.woff2','*.ttf','*.eot','*.mp3','*.mp4','*.pdf','*.zip','*.tar','*.gz','*.min.js','*.min.css','*.map','*.generated.*','.idea/','.vscode/','LICENSE','.gitignore','.editorconfig','.prettierrc','.eslintrc*','*.log']; + const norm = p => p.replace(/\/+$/, ''); + const defaultSet = new Set(defaults.map(norm)); + const header = '# .understandignore — patterns for files/dirs to exclude from analysis\n# Syntax: same as .gitignore (globs, # comments, ! negation, trailing / for dirs)\n# Lines below are suggestions — uncomment to activate.\n# Use ! prefix to force-include something excluded by defaults.\n#\n# Built-in defaults (always excluded unless negated):\n# node_modules/, .git/, dist/, build/, obj/, *.lock, *.min.js, etc.\n#\n'; + let body = ''; + const gitignorePath = path.join(root, '.gitignore'); + if (fs.existsSync(gitignorePath)) { + const gi = fs.readFileSync(gitignorePath, 'utf-8').split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).filter(p => !defaultSet.has(norm(p))); + if (gi.length) { body += '# --- From .gitignore (uncomment to exclude) ---\n\n' + gi.map(p => '# ' + p).join('\n') + '\n\n'; } + } + const dirs = ['__tests__','test','tests','fixtures','testdata','docs','examples','scripts','migrations','.storybook']; + const found = dirs.filter(d => fs.existsSync(path.join(root, d))); + if (found.length) { body += '# --- Detected directories (uncomment to exclude) ---\n\n' + found.map(d => '# ' + d + '/').join('\n') + '\n\n'; } + body += '# --- Test file patterns (uncomment to exclude) ---\n\n# *.test.*\n# *.spec.*\n# *.snap\n'; + const outDir = path.join(root, '.understand-anything'); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync(path.join(outDir, '.understandignore'), header + body); + " + ``` + - Report to the user: + > Generated `.understand-anything/.understandignore` with suggested exclusions based on your project structure. Please review it and uncomment any patterns you'd like to exclude from analysis. When ready, confirm to continue. + - **Wait for user confirmation before proceeding.** +3. **If it already exists**, report: + > Found `.understand-anything/.understandignore`. Review it if needed, then confirm to continue. + - **Wait for user confirmation before proceeding.** +4. After confirmation, proceed to Phase 1. + +--- + ## Phase 1 — SCAN (Full analysis only) Dispatch a subagent using the `project-scanner` agent definition (at `agents/project-scanner.md`). Append the following additional context: @@ -113,6 +154,9 @@ Store the file list as `$FILE_LIST` with `fileCategory` metadata for use in Phas **Gate check:** If >100 files, inform the user and suggest scoping with a subdirectory argument. Proceed only if user confirms or add guidance that this may take a while. +If the scan result includes `filteredByIgnore > 0`, report: +> Excluded {filteredByIgnore} files via `.understandignore`. + --- ## Phase 2 — ANALYZE