merge: integrate latest main into benchmark branch

This commit is contained in:
KumamuKuma
2026-07-17 11:33:40 +08:00
6 changed files with 166 additions and 24 deletions
@@ -532,6 +532,27 @@ describe('scan-project.mjs — data-dir resolution (.ua vs legacy)', () => {
expect(r.status).toBe(0);
expect(r.output.files.map(file => file.path)).toEqual(['src/index.ts']);
});
it('composes CLI exclusions for benchmark scans regardless of flag order', () => {
projectRoot = setupTree({
'.ua/knowledge-graph.json': '{ "nodes": [] }\n',
'.understand-anything/meta.json': '{ "version": 1 }\n',
'generated/client.ts': 'export const generated = true;\n',
'src/index.ts': 'export const x = 1;\n',
});
for (const args of [
['--exclude', 'generated/', '--exclude-analysis-data'],
['--exclude-analysis-data', '--exclude', 'generated/'],
]) {
const r = runScript(projectRoot, args);
expect(r.status, r.stderr).toBe(0);
expect(r.output.files.map(file => file.path)).toEqual(['src/index.ts']);
expect(r.output.filteredByIgnore).toBe(1);
expect(r.output.contentDigest).toMatch(/^[0-9a-f]{64}$/);
}
});
});
describe('scan-project.mjs — special-file recognition', () => {
@@ -54,6 +54,8 @@ If the manifest is missing or malformed, leave the corresponding field empty rat
Invoke the bundled scan script. It walks the project (preferring `git ls-files`, falling back to a recursive walk for non-git directories), applies `.understandignore` filtering (defaults + user patterns), assigns `language` and `fileCategory` per the canonical tables, counts lines, and writes deterministic JSON. You do not see or maintain those tables — they live in the script.
If the dispatch prompt includes exclude patterns, append `--exclude "<patterns>"` to the invocation (patterns should be comma-separated; the script splits them internally).
Resolve the project's data directory once (the legacy `.understand-anything/` when it already exists, otherwise the new `.ua/`) and reuse `$UA_DIR` for every path below:
```bash
@@ -64,6 +66,15 @@ node $PLUGIN_ROOT/skills/understand/scan-project.mjs \
"$UA_DIR/tmp/ua-scan-files.json"
```
With exclude patterns (add the `--exclude` flag after the output path):
```bash
node $PLUGIN_ROOT/skills/understand/scan-project.mjs \
"$PROJECT_ROOT" \
"$UA_DIR/tmp/ua-scan-files.json" \
--exclude "tests/*,docs/*"
```
Output JSON shape (you will read this verbatim and merge into the final scan-result):
```json
@@ -152,4 +152,56 @@ describe("IgnoreFilter", () => {
expect(filter.isIgnored("src/index.ts")).toBe(false);
});
});
describe("createIgnoreFilter with CLI --exclude patterns", () => {
it("applies CLI exclude patterns alongside defaults", () => {
const filter = createIgnoreFilter(testDir, ["tests/", "e2e/"]);
expect(filter.isIgnored("tests/foo.test.ts")).toBe(true);
expect(filter.isIgnored("e2e/smoke.spec.ts")).toBe(true);
// Defaults still apply
expect(filter.isIgnored("node_modules/foo.js")).toBe(true);
expect(filter.isIgnored("dist/bundle.js")).toBe(true);
// Non-excluded source files pass through
expect(filter.isIgnored("src/index.ts")).toBe(false);
expect(filter.isIgnored("README.md")).toBe(false);
});
it("CLI patterns have highest priority over .understandignore files", () => {
// .understandignore says to include docs/
writeFileSync(
join(testDir, ".understand-anything", ".understandignore"),
"!docs/\n"
);
// CLI --exclude says to exclude docs/
const filter = createIgnoreFilter(testDir, ["docs/"]);
// CLI patterns are added last, so they override the ! negation from .understandignore
expect(filter.isIgnored("docs/README.md")).toBe(true);
});
it("CLI ! negation can re-include files excluded by defaults", () => {
// CLI says to include dist/ even though defaults exclude it
const filter = createIgnoreFilter(testDir, ["!dist/"]);
expect(filter.isIgnored("dist/bundle.js")).toBe(false);
// Other defaults still apply
expect(filter.isIgnored("node_modules/foo.js")).toBe(true);
});
it("CLI patterns combined with .understandignore files all apply", () => {
writeFileSync(
join(testDir, ".understandignore"),
"fixtures/\n"
);
const filter = createIgnoreFilter(testDir, ["e2e/"]);
expect(filter.isIgnored("fixtures/data.json")).toBe(true);
expect(filter.isIgnored("e2e/smoke.spec.ts")).toBe(true);
expect(filter.isIgnored("src/index.ts")).toBe(false);
});
it("empty CLI patterns array has no effect", () => {
const filter = createIgnoreFilter(testDir, []);
expect(filter.isIgnored("node_modules/foo.js")).toBe(true);
expect(filter.isIgnored("src/index.ts")).toBe(false);
expect(filter.isIgnored("docs/README.md")).toBe(false);
});
});
});
@@ -77,15 +77,16 @@ export interface IgnoreFilter {
/**
* Creates an IgnoreFilter that merges hardcoded defaults with user-defined
* patterns from .understandignore files.
* patterns from .understandignore files and CLI-provided exclude patterns.
*
* Pattern load order (later entries can override earlier ones via ! negation):
* 1. Hardcoded defaults
* 2. <ua-dir>/.understandignore (if exists — `.ua/`, or the legacy
* `.understand-anything/` when that directory already exists)
* 3. .understandignore at project root (if exists)
* 4. CLI --exclude patterns (highest priority)
*/
export function createIgnoreFilter(projectRoot: string): IgnoreFilter {
export function createIgnoreFilter(projectRoot: string, extraPatterns: string[] = []): IgnoreFilter {
const ig: Ignore = ignore();
// Layer 1: hardcoded defaults
@@ -105,6 +106,11 @@ export function createIgnoreFilter(projectRoot: string): IgnoreFilter {
ig.add(content);
}
// Layer 4: CLI --exclude patterns (highest priority)
if (extraPatterns.length > 0) {
ig.add(extraPatterns);
}
return {
isIgnored(relativePath: string): boolean {
return ig.ignores(relativePath);
@@ -1,7 +1,7 @@
---
name: understand
description: Analyze a codebase to produce an interactive knowledge graph for understanding architecture, components, and relationships
argument-hint: "[path] [--full|--auto-update|--no-auto-update|--review|--language <lang>]"
argument-hint: ["[path] [--full|--auto-update|--no-auto-update|--review|--language <lang>|--exclude <patterns>]"]
---
# /understand
@@ -16,6 +16,7 @@ Analyze the current codebase and produce a `knowledge-graph.json` file in the pr
- `--no-auto-update` — Disable automatic graph updates (writes `autoUpdate: false` to `$UA_DIR/config.json`)
- `--review` — Run full LLM graph-reviewer instead of inline deterministic validation
- `--language <lang>` — Generate all textual content (summaries, descriptions, tags, titles, languageNotes, languageLesson) in the specified language. Accepts ISO 639-1 codes (`zh`, `ja`, `ko`, `en`, `es`, `fr`, `de`, etc.) or friendly names (`chinese`, `japanese`, `korean`, `english`, `spanish`, etc.). Locale variants supported: `zh-TW`, `zh-HK`, etc. Defaults to `en` (English). Stores preference in `$UA_DIR/config.json` for consistency across incremental updates.
- `--exclude <patterns>` — Comma-separated glob patterns for additional files/directories to exclude from analysis (e.g., `--exclude "tests/*,docs/*"`). These patterns take highest priority over built-in defaults and `.understandignore` rules. Supports gitignore syntax including `!` negation.
- A directory path (e.g. `/path/to/repo` or `../other-project`) — Analyze the given directory instead of the current working directory
---
@@ -162,7 +163,14 @@ Determine whether to run a full analysis or incremental update.
> **Language directive**: Generate all textual content (summaries, descriptions, tags, titles, languageNotes, languageLesson) in **{language}**. Maintain technical accuracy while using natural, native-level phrasing in the target language. Keep technical terms in English when no standard translation exists (e.g., "middleware", "hook", "barrel").
```
4. **Check for subdomain knowledge graphs to merge:**
3.7. **Exclude patterns:**
- Parse `$ARGUMENTS` for `--exclude <patterns>` flag. If found, extract the comma-separated patterns string.
- Split on commas, trim whitespace from each pattern, and filter out empty entries.
- Store the patterns as `$EXCLUDE_PATTERNS` (comma-joined for passing to downstream scripts: `"tests/*,docs/*"`).
- These patterns take highest priority — they are applied on top of default patterns and `.understandignore` rules. Use `!` prefix to force-include files that would otherwise be excluded.
- **Note:** Newly added `--exclude` patterns require a `--full` scan to take effect.
4. **Check for subdomain knowledge graphs to merge:**
List all `*knowledge-graph*.json` files in `$UA_DIR/` **excluding** `knowledge-graph.json` itself (e.g. `frontend-knowledge-graph.json`, `backend-knowledge-graph.json`). If any subdomain graphs exist, run the merge script bundled with this skill (located next to this SKILL.md file — use the skill directory path, not the project root):
```bash
python "<SKILL_DIR>/merge-subdomain-graphs.py" "$PROJECT_ROOT"
@@ -247,6 +255,8 @@ Pass these parameters in the dispatch prompt:
> Scan this project directory to discover all project files (including non-code files like configs, docs, infrastructure), detect languages and frameworks.
> Project root: `$PROJECT_ROOT`
> Write output to: `$UA_DIR/intermediate/scan-result.json`
>
> Exclude patterns (from --exclude CLI flag; pass to scan-project.mjs via --exclude): $EXCLUDE_PATTERNS
After the subagent completes, read `$UA_DIR/intermediate/scan-result.json` to get:
- Project name, description
@@ -261,7 +271,7 @@ Store the file list as `$FILE_LIST` with `fileCategory` metadata for use in Phas
**Gate check:** If >100 files, inform the user and suggest scoping with a subdirectory argument. Proceed only if user confirms or add guidance that this may take a while.
If the scan result includes `filteredByIgnore > 0`, report:
> Excluded {filteredByIgnore} files via `.understandignore`.
> Excluded {filteredByIgnore} files via `.understandignore` and/or `--exclude` rules.
---
@@ -16,9 +16,9 @@
*
* What this script owns:
* - File enumeration (git ls-files preferred, recursive walk fallback)
* - `.understandignore` filtering (delegated to core's createIgnoreFilter,
* which reads the resolved data dir — `.ua/`, or legacy
* `.understand-anything/` when that directory already exists)
* - `.understandignore` and CLI exclusion filtering (delegated to core's
* createIgnoreFilter, which reads the resolved data dir — `.ua/`, or
* legacy `.understand-anything/` when that directory already exists)
* - Per-file language detection (extension + filename table)
* - Per-file category assignment (priority-ordered rules from
* project-scanner.md Step 4)
@@ -26,7 +26,13 @@
* - Complexity estimation (project-scanner.md Step 7 thresholds)
*
* Usage:
* node scan-project.mjs <projectRoot> <outputPath> [--exclude-analysis-data]
* node scan-project.mjs <projectRoot> <outputPath>
* [--exclude <patterns>] [--exclude-analysis-data]
*
* --exclude <patterns> Comma-separated gitignore-style patterns to
* additionally exclude from the scan.
* --exclude-analysis-data Always exclude persistent `.ua/` and legacy
* `.understand-anything/` analysis data.
*
* Output JSON (subset of what project-scanner.md Phase 1 expects — the LLM
* agent merges this with Step A's narrative fields and Step C's importMap to
@@ -582,9 +588,9 @@ function enumerateFiles(projectRoot) {
// Filter accounting
//
// The project-scanner.md contract requires `filteredByIgnore` to count files
// dropped *specifically* by user `.understandignore` patterns (the delta
// beyond what the hardcoded defaults would have removed). We accomplish this
// by building TWO filters:
// dropped specifically by user `.understandignore` or CLI `--exclude`
// patterns (the delta beyond what the hardcoded defaults would have removed).
// We accomplish this by building TWO filters:
// - `defaultOnly`: defaults only, no user patterns
// - `combined`: defaults + user patterns (createIgnoreFilter)
// and counting paths that the combined filter excludes but the defaults-only
@@ -686,20 +692,56 @@ function updateContentDigest(hash, posixPath, contentBytes) {
// ---------------------------------------------------------------------------
async function main() {
const [, , projectRoot, outputPath, ...options] = process.argv;
const args = process.argv.slice(2);
let projectRoot;
let outputPath;
let excludeAnalysisData = false;
const excludePatterns = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--exclude-analysis-data') {
excludeAnalysisData = true;
continue;
}
if (arg === '--exclude') {
const value = args[i + 1];
if (!value || value.startsWith('--')) {
process.stderr.write('scan-project.mjs failed: --exclude requires patterns\n');
process.exit(1);
}
excludePatterns.push(
...value
.split(',')
.map(pattern => pattern.trim())
.filter(Boolean),
);
i++;
continue;
}
if (arg.startsWith('--')) {
process.stderr.write(`scan-project.mjs failed: unknown option: ${arg}\n`);
process.exit(1);
}
if (!projectRoot) {
projectRoot = arg;
continue;
}
if (!outputPath) {
outputPath = arg;
continue;
}
process.stderr.write(`scan-project.mjs failed: unexpected argument: ${arg}\n`);
process.exit(1);
}
if (!projectRoot || !outputPath) {
process.stderr.write(
'Usage: node scan-project.mjs <projectRoot> <outputPath> ' +
'[--exclude-analysis-data]\n',
'[--exclude <patterns>] [--exclude-analysis-data]\n',
);
process.exit(1);
}
const unknownOption = options.find(option => option !== '--exclude-analysis-data');
if (unknownOption) {
process.stderr.write(`scan-project.mjs failed: unknown option: ${unknownOption}\n`);
process.exit(1);
}
const excludeAnalysisData = options.includes('--exclude-analysis-data');
if (!existsSync(projectRoot)) {
process.stderr.write(
@@ -722,10 +764,10 @@ async function main() {
(!rel.startsWith('.ua/') && !rel.startsWith('.understand-anything/')),
);
// 2. Filter via createIgnoreFilter (defaults + user .understandignore).
// 2. Filter via createIgnoreFilter (defaults + .understandignore + CLI excludes).
// Build a defaults-only filter in parallel to count user-driven drops.
const combined = createIgnoreFilter(projectRoot);
const userIgnoresPresent = hasUserIgnoreFile(projectRoot);
const combined = createIgnoreFilter(projectRoot, excludePatterns);
const userIgnoresPresent = hasUserIgnoreFile(projectRoot) || excludePatterns.length > 0;
const defaultsOnly = userIgnoresPresent ? buildDefaultsOnlyFilter() : combined;
let filteredByIgnore = 0;
@@ -739,7 +781,7 @@ async function main() {
// Dropped by combined filter. If defaults-only would have ALSO dropped
// it, this is a baseline default drop — not counted. If defaults-only
// would have KEPT it, this drop is attributable to the user's
// .understandignore content.
// .understandignore content or CLI --exclude patterns.
if (userIgnoresPresent && !defaultsOnly.isIgnored(rel)) {
filteredByIgnore++;
}