mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #466 from thejesh23/fix/understandignore-multi-language-test-patterns
feat(core): broaden .understandignore starter for C#/Java/Go test patterns
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -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.
|
||||
@@ -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) {
|
||||
@@ -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("");
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`);
|
||||
Reference in New Issue
Block a user