From 7834f9bc75cadb40da00817340044fff1528d896 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Thu, 7 May 2026 21:02:47 +0800 Subject: [PATCH] fix(file-analyzer): preserve language and fall back when imports unresolved Two bugs surfaced when analyzing Python projects that use absolute imports: - The dispatch prompt (SKILL.md) and file-analyzer agent omitted the per-file `language` field, so `extract-structure.mjs` received null and passed it through to the graph. - `extract-structure.mjs` used `if (importPaths)` to decide whether to trust pre-resolved imports. Empty arrays are truthy, so files where the project scanner could not resolve any imports (e.g. Python absolute imports) clobbered the parser's import count with 0, never falling back to tree-sitter's own analysis. Bumps plugin version to 2.6.1 across the five tracked manifests and adds unit tests for `buildResult` covering language pass-through and the importCount fallback paths. To make the script testable, `buildResult` is now exported and the CLI invocation is guarded so importing the module no longer triggers `main()`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude-plugin/plugin.json | 2 +- .copilot-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- .../.claude-plugin/plugin.json | 2 +- .../agents/file-analyzer.md | 11 ++- understand-anything-plugin/package.json | 2 +- .../skills/understand/SKILL.md | 6 +- .../skills/understand/extract-structure.mjs | 47 +++++++----- .../src/__tests__/extract-structure.test.mjs | 73 +++++++++++++++++++ 9 files changed, 118 insertions(+), 29 deletions(-) create mode 100644 understand-anything-plugin/src/__tests__/extract-structure.test.mjs diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d859288..b0874f0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.6.0", + "version": "2.6.1", "author": { "name": "Lum1104" }, diff --git a/.copilot-plugin/plugin.json b/.copilot-plugin/plugin.json index e7b425f..e65a374 100644 --- a/.copilot-plugin/plugin.json +++ b/.copilot-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.6.0", + "version": "2.6.1", "author": { "name": "Lum1104" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index f222f35..c9b9bdb 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "name": "understand-anything", "displayName": "Understand Anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.6.0", + "version": "2.6.1", "author": { "name": "Lum1104" }, diff --git a/understand-anything-plugin/.claude-plugin/plugin.json b/understand-anything-plugin/.claude-plugin/plugin.json index d859288..b0874f0 100644 --- a/understand-anything-plugin/.claude-plugin/plugin.json +++ b/understand-anything-plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "understand-anything", "description": "AI-powered codebase understanding — analyze, visualize, and explain any project", - "version": "2.6.0", + "version": "2.6.1", "author": { "name": "Lum1104" }, diff --git a/understand-anything-plugin/agents/file-analyzer.md b/understand-anything-plugin/agents/file-analyzer.md index 3889526..e44ff0e 100644 --- a/understand-anything-plugin/agents/file-analyzer.md +++ b/understand-anything-plugin/agents/file-analyzer.md @@ -27,11 +27,20 @@ Execute the pre-built structural extraction script bundled with the Understand-A Create the input file with the batch data. **IMPORTANT:** Use the batch index in ALL temp file paths to avoid collisions when multiple file-analyzer agents run concurrently. +Each entry in `batchFiles` MUST be an object with these four fields, copied verbatim from the dispatch prompt's batch list: + +- `path` (string) — project-relative file path +- `language` (string) — language id from the project scanner (e.g. `"python"`, `"typescript"`); never null +- `sizeLines` (integer) — line count +- `fileCategory` (string) — `code`, `config`, `docs`, `infra`, `data`, `script`, or `markup` + ```bash cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json << 'ENDJSON' { "projectRoot": "", - "batchFiles": [], + "batchFiles": [ + {"path": "", "language": "", "sizeLines": , "fileCategory": ""} + ], "batchImportData": } ENDJSON diff --git a/understand-anything-plugin/package.json b/understand-anything-plugin/package.json index 7269eab..31022e8 100644 --- a/understand-anything-plugin/package.json +++ b/understand-anything-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@understand-anything/skill", - "version": "2.6.0", + "version": "2.6.1", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 5f6ab21..39e543b 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -259,9 +259,9 @@ Fill in batch-specific parameters below and dispatch: > > ``` > -> Files to analyze in this batch: -> 1. `` ( lines, fileCategory: ``) -> 2. `` ( lines, fileCategory: ``) +> Files to analyze in this batch (every entry MUST be passed through to `batchFiles` with all four fields — `path`, `language`, `sizeLines`, `fileCategory`): +> 1. `` ( lines, language: ``, fileCategory: ``) +> 2. `` ( lines, language: ``, fileCategory: ``) > ... After ALL batches complete, run the merge-and-normalize script bundled with this skill (located next to this SKILL.md file — use the skill directory path, not the project root): diff --git a/understand-anything-plugin/skills/understand/extract-structure.mjs b/understand-anything-plugin/skills/understand/extract-structure.mjs index 5208e0b..1423cd4 100644 --- a/understand-anything-plugin/skills/understand/extract-structure.mjs +++ b/understand-anything-plugin/skills/understand/extract-structure.mjs @@ -18,7 +18,7 @@ import { createRequire } from 'node:module'; import { dirname, resolve, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { readFileSync, writeFileSync } from 'node:fs'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -39,19 +39,16 @@ try { const { TreeSitterPlugin, PluginRegistry, builtinLanguageConfigs, registerAllParsers } = core; -// --------------------------------------------------------------------------- -// Argument validation -// --------------------------------------------------------------------------- -const [,, inputPath, outputPath] = process.argv; -if (!inputPath || !outputPath) { - process.stderr.write('Usage: node extract-structure.mjs \n'); - process.exit(1); -} - // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { + const [,, inputPath, outputPath] = process.argv; + if (!inputPath || !outputPath) { + process.stderr.write('Usage: node extract-structure.mjs \n'); + process.exit(1); + } + // Read input const inputRaw = readFileSync(inputPath, 'utf-8'); const input = JSON.parse(inputRaw); @@ -133,9 +130,10 @@ async function main() { } // --------------------------------------------------------------------------- -// Result builder: maps StructuralAnalysis to the expected output schema +// Result builder: maps StructuralAnalysis to the expected output schema. +// Exported for unit tests; pure function, no I/O. // --------------------------------------------------------------------------- -function buildResult(file, totalLines, nonEmptyLines, analysis, callGraph, batchImportData) { +export function buildResult(file, totalLines, nonEmptyLines, analysis, callGraph, batchImportData) { const base = { path: file.path, language: file.language, @@ -244,9 +242,12 @@ function buildResult(file, totalLines, nonEmptyLines, analysis, callGraph, batch // Metrics const metrics = {}; - // Import count from batchImportData (pre-resolved by project scanner) + // Import count from batchImportData (pre-resolved by project scanner). + // Empty arrays are truthy, so explicitly check length so we fall back to the + // parser's own import list when the scanner could not resolve any imports + // (e.g. Python absolute imports the scanner doesn't follow). const importPaths = batchImportData?.[file.path]; - if (importPaths) { + if (importPaths && importPaths.length > 0) { metrics.importCount = importPaths.length; } else if (analysis.imports) { metrics.importCount = analysis.imports.length; @@ -286,11 +287,17 @@ function buildResult(file, totalLines, nonEmptyLines, analysis, callGraph, batch } // --------------------------------------------------------------------------- -// Run +// Run only when executed directly as a CLI; importing the module (e.g. from +// tests) must not trigger main(). // --------------------------------------------------------------------------- -try { - await main(); -} catch (err) { - process.stderr.write(`extract-structure.mjs failed: ${err.message}\n${err.stack}\n`); - process.exit(1); +const isCli = + process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isCli) { + try { + await main(); + } catch (err) { + process.stderr.write(`extract-structure.mjs failed: ${err.message}\n${err.stack}\n`); + process.exit(1); + } } diff --git a/understand-anything-plugin/src/__tests__/extract-structure.test.mjs b/understand-anything-plugin/src/__tests__/extract-structure.test.mjs new file mode 100644 index 0000000..9a848f1 --- /dev/null +++ b/understand-anything-plugin/src/__tests__/extract-structure.test.mjs @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; +import { buildResult } from "../../skills/understand/extract-structure.mjs"; + +const file = (overrides = {}) => ({ + path: "src/foo.py", + language: "python", + fileCategory: "code", + ...overrides, +}); + +const analysis = (overrides = {}) => ({ + functions: [], + classes: [], + imports: [], + exports: [], + ...overrides, +}); + +describe("extract-structure buildResult", () => { + describe("language pass-through", () => { + it("preserves the input language on the output", () => { + const result = buildResult(file({ language: "python" }), 10, 8, analysis(), null, {}); + expect(result.language).toBe("python"); + }); + + it("preserves null when caller did not set a language", () => { + // Documents the failure mode the SKILL.md/file-analyzer.md fix prevents: + // if the dispatch prompt loses `language`, it propagates to the output. + const result = buildResult(file({ language: null }), 10, 8, analysis(), null, {}); + expect(result.language).toBeNull(); + }); + }); + + describe("importCount fallback", () => { + const analysisWithImports = analysis({ + imports: [ + { source: "os", specifiers: [] }, + { source: "sys", specifiers: [] }, + { source: "pathlib", specifiers: [] }, + ], + }); + + it("uses pre-resolved imports when batchImportData has entries", () => { + const batchImportData = { "src/foo.py": ["src/bar.py", "src/baz.py"] }; + const result = buildResult(file(), 10, 8, analysisWithImports, null, batchImportData); + expect(result.metrics.importCount).toBe(2); + }); + + it("falls back to parser imports when batchImportData entry is an empty array", () => { + // Regression test: empty arrays are truthy in JS, so a naive `if (importPaths)` + // would clobber the parser's count with 0. This is the bug Python projects + // using absolute imports (which the project scanner doesn't resolve) hit. + const batchImportData = { "src/foo.py": [] }; + const result = buildResult(file(), 10, 8, analysisWithImports, null, batchImportData); + expect(result.metrics.importCount).toBe(3); + }); + + it("falls back to parser imports when batchImportData has no entry for the file", () => { + const result = buildResult(file(), 10, 8, analysisWithImports, null, {}); + expect(result.metrics.importCount).toBe(3); + }); + + it("falls back to parser imports when batchImportData is undefined", () => { + const result = buildResult(file(), 10, 8, analysisWithImports, null, undefined); + expect(result.metrics.importCount).toBe(3); + }); + + it("reports 0 imports when neither source has any", () => { + const result = buildResult(file(), 10, 8, analysis(), null, { "src/foo.py": [] }); + expect(result.metrics.importCount).toBe(0); + }); + }); +});