Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,106 @@ 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");
});

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", () => {
it("includes .gitignore patterns not covered by defaults", () => {
writeFileSync(join(testDir, ".gitignore"), ".env\nsecrets/\n*.pyc\n");
Expand Down
120 changes: 93 additions & 27 deletions understand-anything-plugin/packages/core/src/ignore-generator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, 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";

Expand All @@ -12,23 +12,65 @@ 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.
// 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",
"tests",
"fixtures",
"testdata",
"docs",
"examples",
"scripts",
"migrations",
".storybook",
"unittests",
"integrationtests",
];

const GENERIC_SUGGESTIONS = [
"*.test.*",
"*.spec.*",
"*.snap",
// 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",
".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"],
},
];

/**
Expand All @@ -53,6 +95,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: Dirent[];
try {
entries = readdirSync(projectRoot, { withFileTypes: true, encoding: "utf-8" });
} 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.
Expand All @@ -75,14 +144,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) {
Expand All @@ -91,10 +154,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("");

Expand Down
26 changes: 2 additions & 24 deletions understand-anything-plugin/skills/understand/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,31 +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). 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 -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);
"
PLUGIN_ROOT="$PLUGIN_ROOT" node <SKILL_DIR>/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.
Expand Down
66 changes: 66 additions & 0 deletions understand-anything-plugin/skills/understand/generate-ignore.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
#!/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 <projectRoot>
*
* Behaviour:
* - Exits 0 with a stderr notice if the target file already exists.
* - Creates `<projectRoot>/.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.
*
* 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';
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));

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;
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}`);
Loading