From f5682bf2b8660b6911bbcca7d204997bbf37c516 Mon Sep 17 00:00:00 2001 From: thejesh Date: Tue, 16 Jun 2026 11:53:33 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(core):=20broaden=20.understandignore?= =?UTF-8?q?=20starter=20=E2=80=94=20C#/Java/Go=20test=20patterns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect C# project-suffix dirs (Foo.Tests/, Foo.UnitTests/) and PascalCase test dirs (Tests/, UnitTests/, IntegrationTests/) via case-insensitive match; group test-file suggestions by language (JS, C#, Java, Go). Keeps all suggestions commented-out — same opt-in model as today. Updates SKILL.md Phase 0.5 inline generator to stay in sync with the TS module. Refs #76 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/ignore-generator.test.ts | 76 ++++++++++++ .../packages/core/src/ignore-generator.ts | 115 ++++++++++++++---- .../skills/understand/SKILL.md | 25 +++- 3 files changed, 186 insertions(+), 30 deletions(-) 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 5d47140..70b2479 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 @@ -95,6 +95,82 @@ describe("generateStarterIgnoreFile", () => { expect(content).not.toContain("# fixtures/"); }); + describe("multi-language test directory detection", () => { + it("suggests PascalCase Tests/ via case-insensitive match", () => { + mkdirSync(join(testDir, "Tests"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + // On-disk casing is preserved in the suggestion. + expect(content).toContain("# Tests/"); + }); + + it("suggests UnitTests/ via case-insensitive match", () => { + mkdirSync(join(testDir, "UnitTests"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# UnitTests/"); + }); + + it("suggests IntegrationTests/ via case-insensitive match", () => { + mkdirSync(join(testDir, "IntegrationTests"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# IntegrationTests/"); + }); + + it("suggests C# project-suffix .Tests/ directories", () => { + mkdirSync(join(testDir, "MyApp.Tests"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# MyApp.Tests/"); + }); + + it("suggests C# project-suffix .UnitTests/ directories", () => { + mkdirSync(join(testDir, "MyApp.UnitTests"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# MyApp.UnitTests/"); + }); + + it("suggests C# project-suffix .IntegrationTests/ directories", () => { + mkdirSync(join(testDir, "MyApp.IntegrationTests"), { recursive: true }); + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# MyApp.IntegrationTests/"); + }); + + it("ignores files that happen to share a detected name", () => { + writeFileSync(join(testDir, "tests"), "not a directory"); + const content = generateStarterIgnoreFile(testDir); + expect(content).not.toContain("# tests/"); + }); + }); + + describe("language-grouped test file patterns", () => { + it("includes C# / .NET test file patterns", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# C# / .NET"); + expect(content).toContain("# **/*Tests.cs"); + expect(content).toContain("# **/*Test.cs"); + expect(content).toContain("# **/*Fixture.cs"); + expect(content).toContain("# **/*.Tests.csproj"); + }); + + it("includes Java / Kotlin test file patterns", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# Java / Kotlin"); + expect(content).toContain("# **/*Test.java"); + expect(content).toContain("# **/*IT.java"); + expect(content).toContain("# **/*Spec.kt"); + expect(content).toContain("# **/src/test/**"); + }); + + it("includes Go test file patterns", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# Go"); + expect(content).toContain("# **/*_test.go"); + }); + + it("groups patterns under the JS / TS sub-header", () => { + const content = generateStarterIgnoreFile(testDir); + expect(content).toContain("# JS / TS"); + }); + }); + describe(".gitignore integration", () => { it("includes .gitignore patterns not covered by defaults", () => { writeFileSync(join(testDir, ".gitignore"), ".env\nsecrets/\n*.pyc\n"); diff --git a/understand-anything-plugin/packages/core/src/ignore-generator.ts b/understand-anything-plugin/packages/core/src/ignore-generator.ts index f0e49ac..a4918c0 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, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { DEFAULT_IGNORE_PATTERNS } from "./ignore-filter.js"; @@ -12,23 +12,60 @@ const HEADER = `# .understandignore — patterns for files/dirs to exclude from # `; -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/" }, +// Directory names matched case-insensitively against the on-disk entry name. +// Includes JS/TS, Python, and PascalCase variants seen in C#/.NET projects. +const EXACT_DIR_NAMES = [ + "__tests__", + "test", + "tests", + "fixtures", + "testdata", + "docs", + "examples", + "scripts", + "migrations", + ".storybook", + "unittests", + "integrationtests", ]; -const GENERIC_SUGGESTIONS = [ - "*.test.*", - "*.spec.*", - "*.snap", +// Directory-name suffixes matched case-insensitively. Covers C# / .NET +// project-suffix conventions like Foo.Tests, Foo.UnitTests, Foo.IntegrationTests. +const SUFFIX_DIR_GLOBS = [ + ".tests", + ".unittests", + ".integrationtests", +]; + +// Test file patterns grouped by language. Emitted as commented suggestions +// with a sub-header per group. +const TEST_PATTERN_GROUPS: Array<{ label: string; patterns: string[] }> = [ + { + label: "JS / TS", + patterns: ["*.test.*", "*.spec.*", "*.snap"], + }, + { + label: "C# / .NET", + patterns: [ + "**/*Tests.cs", + "**/*Test.cs", + "**/*Fixture.cs", + "**/*.Tests.csproj", + ], + }, + { + label: "Java / Kotlin", + patterns: [ + "**/src/test/**", + "**/*Test.java", + "**/*IT.java", + "**/*Spec.kt", + ], + }, + { + label: "Go", + patterns: ["**/*_test.go"], + }, ]; /** @@ -53,6 +90,33 @@ function isCoveredByDefaults(pattern: string): boolean { return DEFAULT_IGNORE_PATTERNS.some((d) => normalize(d) === normalized); } +/** + * Detects directories under projectRoot that match either an exact name + * (case-insensitive) in EXACT_DIR_NAMES or end with one of SUFFIX_DIR_GLOBS. + * Returns patterns using the directory's actual on-disk casing. + */ +function detectDirectories(projectRoot: string): string[] { + let entries: ReturnType; + try { + entries = readdirSync(projectRoot, { withFileTypes: true }); + } catch { + return []; + } + const matches: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const lower = entry.name.toLowerCase(); + if (EXACT_DIR_NAMES.includes(lower)) { + matches.push(`${entry.name}/`); + continue; + } + if (SUFFIX_DIR_GLOBS.some((suffix) => lower.endsWith(suffix))) { + matches.push(`${entry.name}/`); + } + } + return matches; +} + /** * Generates a starter .understandignore file content by scanning the project * for common directories and reading .gitignore patterns. @@ -75,14 +139,8 @@ export function generateStarterIgnoreFile(projectRoot: string): string { sections.push(""); } - // Section 2: detected directories - const detected: string[] = []; - for (const { dir, pattern } of DETECTABLE_DIRS) { - if (existsSync(join(projectRoot, dir))) { - detected.push(pattern); - } - } - + // Section 2: detected directories (case-insensitive + suffix-glob) + const detected = detectDirectories(projectRoot); if (detected.length > 0) { sections.push("# --- Detected directories (uncomment to exclude) ---\n"); for (const pattern of detected) { @@ -91,10 +149,13 @@ export function generateStarterIgnoreFile(projectRoot: string): string { sections.push(""); } - // Section 3: generic test patterns + // Section 3: test file patterns, grouped by language sections.push("# --- Test file patterns (uncomment to exclude) ---\n"); - for (const pattern of GENERIC_SUGGESTIONS) { - sections.push(`# ${pattern}`); + for (const group of TEST_PATTERN_GROUPS) { + sections.push(`# ${group.label}`); + for (const pattern of group.patterns) { + sections.push(`# ${pattern}`); + } } sections.push(""); diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 10bf9cc..6c1886d 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -217,10 +217,29 @@ Set up and verify the `.understandignore` file before scanning. 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))); + 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'; } - body += '# --- Test file patterns (uncomment to exclude) ---\n\n# *.test.*\n# *.spec.*\n# *.snap\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); From a0155c5b4836d678770c4cb1af92ef0167f2224b Mon Sep 17 00:00:00 2001 From: thejesh Date: Tue, 16 Jun 2026 12:02:53 -0700 Subject: [PATCH 2/3] refactor(skills): extract generate-ignore.mjs from SKILL.md inline one-liner Replaces the duplicated Node.js block in Phase 0.5 with a call into `generateStarterIgnoreFile` via a thin wrapper script, mirroring the scan-project.mjs pattern. Removes ~40 lines of duplicated logic; single source of truth in @understand-anything/core. Also tightens code review nits: - Add 3 tests: stable language-group ordering, all-commented invariant on empty dirs, suffix-glob rejects non-directory entries - Clarify comments on EXACT_DIR_NAMES (ecosystem mix, not Python) and SUFFIX_DIR_GLOBS (unanchored String.endsWith match) - Type detectDirectories' readdirSync result explicitly (Dirent[]) to pin the utf-8 encoding overload Refs #76 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/__tests__/ignore-generator.test.ts | 24 +++++++++ .../packages/core/src/ignore-generator.ts | 17 +++--- .../skills/understand/SKILL.md | 45 +--------------- .../skills/understand/generate-ignore.mjs | 52 +++++++++++++++++++ 4 files changed, 89 insertions(+), 49 deletions(-) create mode 100644 understand-anything-plugin/skills/understand/generate-ignore.mjs 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}`); From 83d3fb6e7aed8e6fd8a77824b2880a0b52b78cc4 Mon Sep 17 00:00:00 2001 From: thejesh Date: Wed, 17 Jun 2026 01:02:17 -0700 Subject: [PATCH 3/3] fix(skills): prefer $PLUGIN_ROOT over relative path in generate-ignore.mjs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses codex review feedback (P2). `resolve(__dirname, '../..')` breaks when `skills/understand/` is copied to a runtime skills directory whose parent is not the plugin checkout — exactly the case SKILL.md Phase 0 warns about and resolves via its multi-candidate $PLUGIN_ROOT search. This script now prefers $PLUGIN_ROOT from the env (validated via package.json presence) and falls back to the existing relative resolution. SKILL.md Phase 0.5 passes the env var in the invocation. Same latent pattern exists in scan-project / compute-batches / extract-import-map / extract-structure / build-fingerprints; hardening those is a separate concern (no behaviour change today for installs that already work). Refs #76 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/understand/SKILL.md | 4 ++-- .../skills/understand/generate-ignore.mjs | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 36560f8..02a2135 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -200,9 +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 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): +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). Pass `$PLUGIN_ROOT` via the env so the script doesn't have to re-derive it from its own path (which breaks for copied skill installs): ```bash - node /generate-ignore.mjs $PROJECT_ROOT + PLUGIN_ROOT="$PLUGIN_ROOT" 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 index b684d8d..049a835 100644 --- a/understand-anything-plugin/skills/understand/generate-ignore.mjs +++ b/understand-anything-plugin/skills/understand/generate-ignore.mjs @@ -17,6 +17,12 @@ * * Mirrors the @understand-anything/core resolution dance used by * scan-project.mjs: workspace-linked package first, plugin-cache dist fallback. + * + * Plugin root resolution: prefer $PLUGIN_ROOT from the environment (set by + * SKILL.md Phase 0 via its multi-candidate search) over the + * `resolve(__dirname, '../..')` heuristic. The relative path breaks when + * `skills/understand/` is copied into a runtime skills directory whose + * parent is not the plugin checkout. */ import { createRequire } from 'node:module'; @@ -25,8 +31,16 @@ 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, '../..'); + +function resolvePluginRoot() { + const envRoot = process.env.PLUGIN_ROOT; + if (envRoot && existsSync(join(envRoot, 'package.json'))) { + return envRoot; + } + return resolve(__dirname, '../..'); +} + +const pluginRoot = resolvePluginRoot(); const require = createRequire(resolve(pluginRoot, 'package.json')); let core;