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 index 70b2479..1a150be 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/ignore-generator.test.ts @@ -169,6 +169,30 @@ describe("generateStarterIgnoreFile", () => { const content = generateStarterIgnoreFile(testDir); expect(content).toContain("# JS / TS"); }); + + it("emits language groups in stable order: JS, C#, Java, Go", () => { + const content = generateStarterIgnoreFile(testDir); + const jsIdx = content.indexOf("# JS / TS"); + const csIdx = content.indexOf("# C# / .NET"); + const javaIdx = content.indexOf("# Java / Kotlin"); + const goIdx = content.indexOf("# Go"); + expect(jsIdx).toBeGreaterThan(-1); + expect(csIdx).toBeGreaterThan(jsIdx); + expect(javaIdx).toBeGreaterThan(csIdx); + expect(goIdx).toBeGreaterThan(javaIdx); + }); + + it("keeps all suggestions commented even with no detected dirs and no .gitignore", () => { + const content = generateStarterIgnoreFile(testDir); + const uncommented = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); + expect(uncommented).toHaveLength(0); + }); + + it("ignores a file whose name would match a suffix-glob", () => { + writeFileSync(join(testDir, "MyApp.Tests"), "not a directory"); + const content = generateStarterIgnoreFile(testDir); + expect(content).not.toContain("# MyApp.Tests/"); + }); }); describe(".gitignore integration", () => { diff --git a/understand-anything-plugin/packages/core/src/ignore-generator.ts b/understand-anything-plugin/packages/core/src/ignore-generator.ts index a4918c0..1126016 100644 --- a/understand-anything-plugin/packages/core/src/ignore-generator.ts +++ b/understand-anything-plugin/packages/core/src/ignore-generator.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, type Dirent } from "node:fs"; import { join } from "node:path"; import { DEFAULT_IGNORE_PATTERNS } from "./ignore-filter.js"; @@ -13,7 +13,9 @@ const HEADER = `# .understandignore — patterns for files/dirs to exclude from `; // Directory names matched case-insensitively against the on-disk entry name. -// Includes JS/TS, Python, and PascalCase variants seen in C#/.NET projects. +// Mixes ecosystem conventions: __tests__ (JS), test/tests (multi), testdata +// (Go), .storybook (JS), and PascalCase variants (UnitTests/IntegrationTests) +// commonly seen in C#/.NET projects. const EXACT_DIR_NAMES = [ "__tests__", "test", @@ -29,8 +31,11 @@ const EXACT_DIR_NAMES = [ "integrationtests", ]; -// Directory-name suffixes matched case-insensitively. Covers C# / .NET -// project-suffix conventions like Foo.Tests, Foo.UnitTests, Foo.IntegrationTests. +// Directory-name suffixes matched case-insensitively via String.endsWith. +// Primarily intended for C# / .NET project-suffix conventions like Foo.Tests, +// Foo.UnitTests, Foo.IntegrationTests, but note the match is unanchored — +// e.g. a hypothetical `.storybook.tests` would also match. Suggestions stay +// commented-out so the user reviews before activating. const SUFFIX_DIR_GLOBS = [ ".tests", ".unittests", @@ -96,9 +101,9 @@ function isCoveredByDefaults(pattern: string): boolean { * Returns patterns using the directory's actual on-disk casing. */ function detectDirectories(projectRoot: string): string[] { - let entries: ReturnType; + let entries: Dirent[]; try { - entries = readdirSync(projectRoot, { withFileTypes: true }); + entries = readdirSync(projectRoot, { withFileTypes: true, encoding: "utf-8" }); } catch { return []; } diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 6c1886d..36560f8 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -200,50 +200,9 @@ Determine whether to run a full analysis or incremental update. 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): +2. **If it does NOT exist**, generate a starter file by invoking the bundled script (delegates to `generateStarterIgnoreFile` in `@understand-anything/core`, which reads `.gitignore`, deduplicates against built-in defaults, and emits language-grouped test-file suggestions): ```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 exactDirs = ['__tests__','test','tests','fixtures','testdata','docs','examples','scripts','migrations','.storybook','unittests','integrationtests']; - const suffixDirs = ['.tests','.unittests','.integrationtests']; - const found = []; - try { - for (const ent of fs.readdirSync(root, { withFileTypes: true })) { - if (!ent.isDirectory()) continue; - const lower = ent.name.toLowerCase(); - if (exactDirs.includes(lower) || suffixDirs.some(s => lower.endsWith(s))) { - found.push(ent.name); - } - } - } catch {} - if (found.length) { body += '# --- Detected directories (uncomment to exclude) ---\n\n' + found.map(d => '# ' + d + '/').join('\n') + '\n\n'; } - const patternGroups = [ - ['JS / TS', ['*.test.*','*.spec.*','*.snap']], - ['C# / .NET', ['**/*Tests.cs','**/*Test.cs','**/*Fixture.cs','**/*.Tests.csproj']], - ['Java / Kotlin', ['**/src/test/**','**/*Test.java','**/*IT.java','**/*Spec.kt']], - ['Go', ['**/*_test.go']], - ]; - body += '# --- Test file patterns (uncomment to exclude) ---\n\n'; - for (const [label, pats] of patternGroups) { - body += '# ' + label + '\n' + pats.map(p => '# ' + p).join('\n') + '\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); - " + node /generate-ignore.mjs $PROJECT_ROOT ``` - 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. diff --git a/understand-anything-plugin/skills/understand/generate-ignore.mjs b/understand-anything-plugin/skills/understand/generate-ignore.mjs new file mode 100644 index 0000000..b684d8d --- /dev/null +++ b/understand-anything-plugin/skills/understand/generate-ignore.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/** + * generate-ignore.mjs + * + * Writes a starter `.understand-anything/.understandignore` for the target + * project by delegating to `generateStarterIgnoreFile` in + * `@understand-anything/core`. Invoked from SKILL.md Phase 0.5; replaces the + * inline `node -e "…"` block that previously duplicated the generator logic. + * + * Usage: + * node generate-ignore.mjs + * + * Behaviour: + * - Exits 0 with a stderr notice if the target file already exists. + * - Creates `/.understand-anything/` if missing. + * - Emits a one-line stderr summary on success. + * + * Mirrors the @understand-anything/core resolution dance used by + * scan-project.mjs: workspace-linked package first, plugin-cache dist fallback. + */ + +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +// skills/understand/ -> plugin root is two dirs up +const pluginRoot = resolve(__dirname, '../..'); +const require = createRequire(resolve(pluginRoot, 'package.json')); + +let core; +try { + core = await import(pathToFileURL(require.resolve('@understand-anything/core')).href); +} catch { + core = await import(pathToFileURL(resolve(pluginRoot, 'packages/core/dist/index.js')).href); +} + +const { generateStarterIgnoreFile } = core; + +const projectRoot = resolve(process.argv[2] ?? process.cwd()); +const outDir = join(projectRoot, '.understand-anything'); +const outPath = join(outDir, '.understandignore'); + +if (existsSync(outPath)) { + console.error(`generate-ignore: ${outPath} already exists — skipping`); + process.exit(0); +} + +if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); +writeFileSync(outPath, generateStarterIgnoreFile(projectRoot)); +console.error(`generate-ignore: wrote ${outPath}`);