From f5682bf2b8660b6911bbcca7d204997bbf37c516 Mon Sep 17 00:00:00 2001 From: thejesh Date: Tue, 16 Jun 2026 11:53:33 -0700 Subject: [PATCH] =?UTF-8?q?feat(core):=20broaden=20.understandignore=20sta?= =?UTF-8?q?rter=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);