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) <noreply@anthropic.com>
This commit is contained in:
thejesh
2026-06-16 12:02:53 -07:00
co-authored by Claude Opus 4.7
parent f5682bf2b8
commit a0155c5b48
4 changed files with 89 additions and 49 deletions
@@ -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", () => {
@@ -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<typeof readdirSync>;
let entries: Dirent[];
try {
entries = readdirSync(projectRoot, { withFileTypes: true });
entries = readdirSync(projectRoot, { withFileTypes: true, encoding: "utf-8" });
} catch {
return [];
}
@@ -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 <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,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, '../..');
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}`);