Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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):
```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);
"
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
52 changes: 52 additions & 0 deletions understand-anything-plugin/skills/understand/generate-ignore.mjs
Original file line number Diff line number Diff line change
@@ -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 <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.
*/

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, '../..');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the resolved plugin root for copied skill installs

When the skill directory is copied to a runtime skills folder instead of symlinked under the plugin checkout, resolve(__dirname, '../..') points at that skills parent rather than the real plugin root. Phase 0 already supports those installs by resolving PLUGIN_ROOT from several locations, but this new Phase 0.5 helper ignores that value, so first-time runs with no .understandignore fail to resolve @understand-anything/core and then try a nonexistent packages/core/dist/index.js under the skills folder. Use the resolved plugin root or implement the same fallback search here before importing core.

Useful? React with 👍 / 👎.

@thejesh23 thejesh23 Jun 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, addressed in 83d3fb6.

generate-ignore.mjs now prefers $PLUGIN_ROOT from the env (validated by checking for a package.json at that path) and falls back to the existing resolve(__dirname, '../..') heuristic. SKILL.md Phase 0.5 was updated to pass the env var:

PLUGIN_ROOT="$PLUGIN_ROOT" node <SKILL_DIR>/generate-ignore.mjs $PROJECT_ROOT

Smoke-tested both paths locally — the env-set path resolves through Phase 0's multi-candidate value; the fallback path matches prior behavior for standard installs. 753 tests still pass; lint clean.

Note: the same resolve(__dirname, '../..') pattern exists in scan-project.mjs, compute-batches.mjs, extract-import-map.mjs, extract-structure.mjs, and build-fingerprints.mjs. They share the same latent issue but kept out of this PR's scope — happy to file a follow-up to harden them uniformly if the maintainers want. @Lum1104 I will scope out these to address these in follow up PR

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