From fbe3cf4ee00e19643604f807052ce5cee02fc327 Mon Sep 17 00:00:00 2001 From: Aditya Date: Mon, 23 Mar 2026 15:20:10 +0530 Subject: [PATCH 01/32] feat: add hooks for auto-update prompts and change classification logic - Introduced hooks in `hooks.json` for PostToolUse and SessionStart events to prompt users about knowledge graph updates based on commit and session changes. - Implemented `change-classifier.ts` to classify updates based on structural changes, including SKIP, PARTIAL_UPDATE, ARCHITECTURE_UPDATE, and FULL_UPDATE actions. - Added comprehensive tests for change classification in `change-classifier.test.ts` to ensure correct behavior across various scenarios. - Created `fingerprint.ts` to manage file fingerprints, including content hashing, structural analysis, and comparison of fingerprints to detect changes. - Developed tests for fingerprint extraction and comparison in `fingerprint.test.ts` to validate functionality and ensure accurate change detection. --- .../hooks/auto-update-prompt.md | 226 +++++++++++ understand-anything-plugin/hooks/hooks.json | 25 ++ .../src/__tests__/change-classifier.test.ts | 153 ++++++++ .../core/src/__tests__/fingerprint.test.ts | 360 +++++++++++++++++ .../packages/core/src/change-classifier.ts | 133 +++++++ .../packages/core/src/fingerprint.ts | 371 ++++++++++++++++++ .../packages/core/src/index.ts | 19 + .../packages/core/src/persistence/index.ts | 33 +- .../packages/core/src/types.ts | 5 + .../skills/understand/SKILL.md | 38 ++ 10 files changed, 1362 insertions(+), 1 deletion(-) create mode 100644 understand-anything-plugin/hooks/auto-update-prompt.md create mode 100644 understand-anything-plugin/hooks/hooks.json create mode 100644 understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts create mode 100644 understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts create mode 100644 understand-anything-plugin/packages/core/src/change-classifier.ts create mode 100644 understand-anything-plugin/packages/core/src/fingerprint.ts diff --git a/understand-anything-plugin/hooks/auto-update-prompt.md b/understand-anything-plugin/hooks/auto-update-prompt.md new file mode 100644 index 0000000..a9df20b --- /dev/null +++ b/understand-anything-plugin/hooks/auto-update-prompt.md @@ -0,0 +1,226 @@ +# Auto-Update Knowledge Graph (Internal — Hook-Triggered) + +Incrementally update the knowledge graph using deterministic structural fingerprinting to minimize token usage. This prompt is triggered automatically by the post-commit hook when `autoUpdate` is enabled. It is NOT a user-facing skill. + +**Key principle:** Spend zero LLM tokens when changes are cosmetic (formatting, internal logic). Only invoke LLM agents when structural changes (new/removed functions, classes, imports, exports) are detected. + +--- + +## Phase 0 — Pre-flight (Zero Token Cost) + +1. Set `PROJECT_ROOT` to the current working directory. + +2. Check that `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` exists. + - If not: report "No existing knowledge graph found. Run `/understand` first to create one." and **STOP**. + +3. Check that `$PROJECT_ROOT/.understand-anything/meta.json` exists and read `gitCommitHash`. + - If not: report "No analysis metadata found. Run `/understand` to create a baseline." and **STOP**. + +4. Get current commit hash: + ```bash + git rev-parse HEAD + ``` + +5. If commit hashes match and `--force` is NOT in `$ARGUMENTS`: report "Knowledge graph is already up to date." and **STOP**. + +6. Get changed files: + ```bash + git diff ..HEAD --name-only + ``` + If no files changed: update `meta.json` with the new commit hash and **STOP**. + +7. Filter to source files only (`.ts`, `.tsx`, `.js`, `.jsx`, `.py`, `.go`, `.rs`, `.java`, `.rb`, `.cpp`, `.c`, `.h`, `.cs`, `.swift`, `.kt`, `.php`). + If no source files changed: update `meta.json` with the new commit hash, report "Only non-source files changed. Metadata updated." and **STOP**. + +8. Create intermediate directory: + ```bash + mkdir -p $PROJECT_ROOT/.understand-anything/intermediate + ``` + +--- + +## Phase 1 — Structural Fingerprint Check (Zero LLM Tokens) + +This phase runs a deterministic Node.js script that compares file structures against stored fingerprints. It costs **zero LLM tokens** — only the script execution cost. + +1. Write and execute a Node.js script (`$PROJECT_ROOT/.understand-anything/intermediate/fingerprint-check.mjs`): + +```javascript +// The script should: +// 1. Read fingerprints.json from .understand-anything/fingerprints.json +// 2. For each changed source file: +// a. Read the file content +// b. Compute SHA-256 content hash +// c. If content hash matches stored hash → NONE (skip) +// d. Extract structural elements via regex: +// - Functions: match patterns like `function NAME(`, `const NAME = (`, `export function NAME(` +// - Classes: match `class NAME`, `export class NAME` +// - Imports: match `import ... from '...'`, `import '...'` +// - Exports: match `export { ... }`, `export default`, `export function`, `export class`, `export const` +// e. Compare extracted elements against stored fingerprint +// f. Classify as NONE, COSMETIC, or STRUCTURAL +// 3. For new files (not in fingerprints.json): classify as STRUCTURAL +// 4. For deleted files (in fingerprints.json but not on disk): classify as STRUCTURAL +// 5. Determine overall decision: +// - All NONE/COSMETIC → action: "SKIP" +// - Some STRUCTURAL, ≤10 files, same directories → action: "PARTIAL_UPDATE" +// - New/deleted directories or >10 structural files → action: "ARCHITECTURE_UPDATE" +// - >30 structural files or >50% of graph → action: "FULL_UPDATE" +// 6. Write result to .understand-anything/intermediate/change-analysis.json +``` + +The output JSON should have this shape: +```json +{ + "action": "SKIP | PARTIAL_UPDATE | ARCHITECTURE_UPDATE | FULL_UPDATE", + "filesToReanalyze": ["src/new-feature.ts"], + "rerunArchitecture": false, + "rerunTour": false, + "reason": "1 file has structural changes (new function added)", + "fileChanges": [ + { "filePath": "src/utils.ts", "changeLevel": "COSMETIC", "details": ["internal logic changed"] }, + { "filePath": "src/new-feature.ts", "changeLevel": "STRUCTURAL", "details": ["new function: handleRequest"] } + ] +} +``` + +2. Read `.understand-anything/intermediate/change-analysis.json`. + +3. **Decision gate:** + + | Action | What to do | + |---|---| + | `SKIP` | Update `meta.json` with new commit hash. Report: "No structural changes detected. Graph metadata updated. Zero tokens spent." **STOP.** | + | `FULL_UPDATE` | Report: "Major structural changes detected (reason). Recommend running `/understand --full` for a complete rebuild." **STOP.** | + | `PARTIAL_UPDATE` | Proceed to Phase 2 with `filesToReanalyze` | + | `ARCHITECTURE_UPDATE` | Proceed to Phase 2 with `filesToReanalyze`, flag architecture re-run | + +--- + +## Phase 2 — Targeted Re-Analysis (Minimal Token Cost) + +Only re-analyze files with structural changes. This is the **only** phase that costs LLM tokens. + +1. Read the existing knowledge graph from `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`. + +2. Batch the files from `filesToReanalyze` (from Phase 1). Use a single batch if ≤10 files, otherwise batch into groups of 5-10. + +3. For each batch, dispatch a subagent using the prompt template at `../skills/understand/file-analyzer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending: + + > **Additional context from main session:** + > + > Project: `` — `` + > Frameworks detected: `` + > Languages: `` + > + > **IMPORTANT:** This is an incremental update. Only the files listed below have structural changes. Analyze them thoroughly but do not invent nodes for files not in this batch. + + Fill in batch-specific parameters: + + > Analyze these source files and produce GraphNode and GraphEdge objects. + > Project root: `$PROJECT_ROOT` + > Project: `` + > Languages: `` + > Batch index: `1` + > Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-1.json` + > + > All project files (for import resolution): + > `` + > + > Files to analyze in this batch: + > 1. `` (`` lines) + > ... + +4. After batch(es) complete, read each `batch-.json` and merge results. + +5. **Merge with existing graph:** + - Remove old nodes whose `filePath` matches any file in `filesToReanalyze` or in the deleted files list + - Remove old edges whose `source` or `target` references a removed node + - Add new nodes and edges from the fresh analysis + - Deduplicate nodes by ID (keep latest), edges by `source + target + type` + - Remove any edge with dangling `source` or `target` references + +--- + +## Phase 3 — Conditional Architecture/Tour + Save + +### 3a. Architecture update (only if `rerunArchitecture === true`) + +If the change analysis flagged `ARCHITECTURE_UPDATE`: + +1. Dispatch a subagent using the prompt template at `../skills/understand/architecture-analyzer-prompt.md`, passing the full merged node set and import edges. Include previous layer definitions for naming consistency: + + > Previous layer definitions (for naming consistency): + > ```json + > [previous layers from existing graph] + > ``` + > Maintain the same layer names and IDs where possible. Only add/remove layers if the file structure has materially changed. + +2. After completion, read and normalize layers (same normalization as `/understand` Phase 4). + +3. Optionally re-run tour builder if layers changed significantly. + +### 3b. Lite layer update (if `rerunArchitecture === false`) + +If only a partial update: +1. For **new files**: assign them to the most likely existing layer based on directory path matching +2. For **deleted files**: remove their IDs from layer `nodeIds` arrays +3. Remove any layer that ends up with zero nodeIds + +### 3c. Lite validation + +Perform lightweight validation (no graph-reviewer agent): +1. Remove any edge with dangling `source` or `target` +2. Remove any layer `nodeIds` entry that doesn't exist in the node set +3. Ensure every file node appears in exactly one layer (add to a catch-all layer if missing) + +### 3d. Save + +1. Write the final knowledge graph to `$PROJECT_ROOT/.understand-anything/knowledge-graph.json`. + +2. Write updated metadata to `$PROJECT_ROOT/.understand-anything/meta.json`: + ```json + { + "lastAnalyzedAt": "", + "gitCommitHash": "", + "version": "1.0.0", + "analyzedFiles": + } + ``` + +3. **Update fingerprints:** Write and execute a Node.js script that: + - Reads the existing `fingerprints.json` + - For each re-analyzed file: computes new content hash and extracts structural elements via regex + - For deleted files: removes their entries + - Merges with existing fingerprints (keep unchanged files as-is) + - Writes updated `fingerprints.json` + +4. Clean up intermediate files: + ```bash + rm -rf $PROJECT_ROOT/.understand-anything/intermediate + ``` + +5. Report a summary: + - Files checked: N (total changed) + - Structural changes found: N files + - Cosmetic-only changes: N files (skipped) + - Nodes updated: N + - Action taken: PARTIAL_UPDATE / ARCHITECTURE_UPDATE + - Path to output: `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` + +--- + +## Error Handling + +- If the fingerprint check script fails: fall back to treating all changed files as STRUCTURAL (conservative approach). +- If `fingerprints.json` doesn't exist: treat all changed files as STRUCTURAL and regenerate fingerprints after the update. +- If a subagent dispatch fails: retry once. If it fails again, save partial results and report the error. +- ALWAYS save partial results — a partially updated graph is better than no update. + +--- + +## Notes + +- This skill reuses the same `file-analyzer-prompt.md` and `architecture-analyzer-prompt.md` as `/understand` — no separate agent prompts needed. +- The fingerprint comparison in Phase 1 uses regex-based extraction (not tree-sitter) because it runs as a temporary Node.js script and doesn't need full AST accuracy — just signature-level detection. +- The authoritative fingerprints stored in `fingerprints.json` are generated by `/understand` Phase 7 using the core `fingerprint.ts` module (which uses tree-sitter for precise extraction). diff --git a/understand-anything-plugin/hooks/hooks.json b/understand-anything-plugin/hooks/hooks.json new file mode 100644 index 0000000..ce537ce --- /dev/null +++ b/understand-anything-plugin/hooks/hooks.json @@ -0,0 +1,25 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo \"$TOOL_INPUT\" | grep -qE 'git\\s+(commit|merge|cherry-pick|rebase)' && [ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/knowledge-graph.json ] && echo '[understand-anything] Commit detected with auto-update enabled. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to incrementally update the knowledge graph. Do not ask the user for confirmation — just do it.' || true" + } + ] + } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "[ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/meta.json ] && [ -f .understand-anything/knowledge-graph.json ] && [ \"$(sed -n 's/.*\"gitCommitHash\".*\"\\([^\"]*\\)\".*/\\1/p' .understand-anything/meta.json)\" != \"$(git rev-parse HEAD 2>/dev/null)\" ] && echo '[understand-anything] Knowledge graph is stale. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to check for structural changes and update the graph. Do not ask the user for confirmation — just do it.' || true" + } + ] + } + ] + } +} diff --git a/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts b/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts new file mode 100644 index 0000000..0a6561a --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from "vitest"; +import { classifyUpdate } from "../change-classifier.js"; +import type { ChangeAnalysis } from "../fingerprint.js"; + +function makeAnalysis(overrides: Partial = {}): ChangeAnalysis { + return { + fileChanges: [], + newFiles: [], + deletedFiles: [], + structurallyChangedFiles: [], + cosmeticOnlyFiles: [], + unchangedFiles: [], + ...overrides, + }; +} + +describe("classifyUpdate", () => { + it("returns SKIP when all files are unchanged", () => { + const analysis = makeAnalysis({ + unchangedFiles: ["src/a.ts", "src/b.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("SKIP"); + expect(decision.filesToReanalyze).toHaveLength(0); + expect(decision.rerunArchitecture).toBe(false); + expect(decision.rerunTour).toBe(false); + }); + + it("returns SKIP when all changes are cosmetic", () => { + const analysis = makeAnalysis({ + cosmeticOnlyFiles: ["src/a.ts", "src/b.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("SKIP"); + expect(decision.reason).toContain("cosmetic-only"); + }); + + it("returns PARTIAL_UPDATE for a few structural changes", () => { + const analysis = makeAnalysis({ + structurallyChangedFiles: ["src/a.ts", "src/b.ts"], + newFiles: ["src/c.ts"], + cosmeticOnlyFiles: ["src/d.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("PARTIAL_UPDATE"); + expect(decision.filesToReanalyze).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]); + expect(decision.rerunArchitecture).toBe(false); + expect(decision.rerunTour).toBe(false); + }); + + it("returns ARCHITECTURE_UPDATE when >10 structural files", () => { + const files = Array.from({ length: 12 }, (_, i) => `src/file${i}.ts`); + const analysis = makeAnalysis({ + structurallyChangedFiles: files, + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + expect(decision.rerunTour).toBe(true); + }); + + it("returns ARCHITECTURE_UPDATE when new directories appear", () => { + const analysis = makeAnalysis({ + structurallyChangedFiles: ["src/existing.ts"], + newFiles: ["newdir/file.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + }); + + it("returns ARCHITECTURE_UPDATE when directories are deleted", () => { + const analysis = makeAnalysis({ + structurallyChangedFiles: ["src/existing.ts"], + deletedFiles: ["olddir/removed.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + }); + + it("returns FULL_UPDATE when >30 structural files", () => { + const files = Array.from({ length: 35 }, (_, i) => `src/file${i}.ts`); + const analysis = makeAnalysis({ + structurallyChangedFiles: files, + }); + + const decision = classifyUpdate(analysis, 100); + + expect(decision.action).toBe("FULL_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + expect(decision.rerunTour).toBe(true); + }); + + it("returns FULL_UPDATE when >50% of project is structurally changed", () => { + const files = Array.from({ length: 6 }, (_, i) => `src/file${i}.ts`); + const analysis = makeAnalysis({ + structurallyChangedFiles: files, + }); + + // 6 out of 10 files = 60% + const decision = classifyUpdate(analysis, 10); + + expect(decision.action).toBe("FULL_UPDATE"); + }); + + it("includes new and structural files in filesToReanalyze for PARTIAL", () => { + const analysis = makeAnalysis({ + structurallyChangedFiles: ["src/modified.ts"], + newFiles: ["src/added.ts"], + deletedFiles: ["src/removed.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.filesToReanalyze).toContain("src/modified.ts"); + expect(decision.filesToReanalyze).toContain("src/added.ts"); + // Deleted files shouldn't be re-analyzed + expect(decision.filesToReanalyze).not.toContain("src/removed.ts"); + }); + + it("handles empty analysis (no changes at all)", () => { + const analysis = makeAnalysis(); + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("SKIP"); + expect(decision.reason).toContain("No changes detected"); + }); + + it("counts deleted files toward structural total", () => { + // 8 structural + 3 deleted = 11 total structural > 10 threshold + const analysis = makeAnalysis({ + structurallyChangedFiles: Array.from({ length: 8 }, (_, i) => `src/file${i}.ts`), + deletedFiles: ["src/old1.ts", "src/old2.ts", "src/old3.ts"], + }); + + const decision = classifyUpdate(analysis, 50); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts b/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts new file mode 100644 index 0000000..4990b54 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts @@ -0,0 +1,360 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { StructuralAnalysis } from "../types.js"; +import { + contentHash, + extractFileFingerprint, + compareFingerprints, + analyzeChanges, + type FileFingerprint, + type FingerprintStore, +} from "../fingerprint.js"; + +// Mock fs and path for analyzeChanges +vi.mock("node:fs", () => ({ + readFileSync: vi.fn(), + existsSync: vi.fn(), +})); + +import { readFileSync, existsSync } from "node:fs"; + +const mockedReadFileSync = vi.mocked(readFileSync); +const mockedExistsSync = vi.mocked(existsSync); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("contentHash", () => { + it("produces consistent SHA-256 hashes", () => { + const hash1 = contentHash("hello world"); + const hash2 = contentHash("hello world"); + expect(hash1).toBe(hash2); + expect(hash1).toMatch(/^[a-f0-9]{64}$/); + }); + + it("produces different hashes for different content", () => { + expect(contentHash("hello")).not.toBe(contentHash("world")); + }); +}); + +describe("extractFileFingerprint", () => { + it("extracts function fingerprints from analysis", () => { + const analysis: StructuralAnalysis = { + functions: [ + { name: "main", lineRange: [1, 20], params: ["config", "options"], returnType: "void" }, + { name: "helper", lineRange: [22, 30], params: [], returnType: "string" }, + ], + classes: [], + imports: [], + exports: [{ name: "main", lineNumber: 1 }], + }; + + const fp = extractFileFingerprint("src/index.ts", "const x = 1;\n".repeat(30), analysis); + + expect(fp.filePath).toBe("src/index.ts"); + expect(fp.functions).toHaveLength(2); + expect(fp.functions[0]).toEqual({ + name: "main", + params: ["config", "options"], + returnType: "void", + exported: true, + lineCount: 20, + }); + expect(fp.functions[1]).toEqual({ + name: "helper", + params: [], + returnType: "string", + exported: false, + lineCount: 9, + }); + }); + + it("extracts class fingerprints", () => { + const analysis: StructuralAnalysis = { + functions: [], + classes: [ + { name: "MyClass", lineRange: [1, 50], methods: ["doStuff", "init"], properties: ["name"] }, + ], + imports: [], + exports: [{ name: "MyClass", lineNumber: 1 }], + }; + + const fp = extractFileFingerprint("src/my-class.ts", "x\n".repeat(50), analysis); + + expect(fp.classes).toHaveLength(1); + expect(fp.classes[0]).toEqual({ + name: "MyClass", + methods: ["doStuff", "init"], + properties: ["name"], + exported: true, + lineCount: 50, + }); + }); + + it("extracts import and export fingerprints", () => { + const analysis: StructuralAnalysis = { + functions: [], + classes: [], + imports: [ + { source: "./utils", specifiers: ["format", "parse"], lineNumber: 1 }, + { source: "node:fs", specifiers: ["readFileSync"], lineNumber: 2 }, + ], + exports: [{ name: "main", lineNumber: 5 }, { name: "default", lineNumber: 10 }], + }; + + const fp = extractFileFingerprint("src/index.ts", "x\n", analysis); + + expect(fp.imports).toHaveLength(2); + expect(fp.imports[0]).toEqual({ source: "./utils", specifiers: ["format", "parse"] }); + expect(fp.exports).toEqual(["main", "default"]); + }); + + it("computes content hash and total lines", () => { + const content = "line1\nline2\nline3\n"; + const analysis: StructuralAnalysis = { + functions: [], + classes: [], + imports: [], + exports: [], + }; + + const fp = extractFileFingerprint("src/empty.ts", content, analysis); + + expect(fp.contentHash).toBe(contentHash(content)); + expect(fp.totalLines).toBe(4); // 3 lines + trailing newline = 4 elements + }); +}); + +describe("compareFingerprints", () => { + const baseFp: FileFingerprint = { + filePath: "src/index.ts", + contentHash: "abc123", + functions: [ + { name: "main", params: ["config"], returnType: "void", exported: true, lineCount: 20 }, + ], + classes: [], + imports: [{ source: "./utils", specifiers: ["format"] }], + exports: ["main"], + totalLines: 30, + }; + + it("returns NONE when content hash is identical", () => { + const result = compareFingerprints(baseFp, { ...baseFp }); + expect(result.changeLevel).toBe("NONE"); + expect(result.details).toHaveLength(0); + }); + + it("returns COSMETIC when content changed but structure is identical", () => { + const newFp = { ...baseFp, contentHash: "different_hash" }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("COSMETIC"); + expect(result.details).toContain("internal logic changed (no structural impact)"); + }); + + it("detects new functions", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [ + ...baseFp.functions, + { name: "newFunc", params: [], exported: false, lineCount: 10 }, + ], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("new function: newFunc"); + }); + + it("detects removed functions", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("removed function: main"); + }); + + it("detects parameter changes", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [ + { name: "main", params: ["config", "options"], returnType: "void", exported: true, lineCount: 20 }, + ], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("params changed: main"); + }); + + it("detects export status changes", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [ + { name: "main", params: ["config"], returnType: "void", exported: false, lineCount: 20 }, + ], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("export status changed: main"); + }); + + it("detects significant size changes (>50%)", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + functions: [ + { name: "main", params: ["config"], returnType: "void", exported: true, lineCount: 60 }, + ], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details.some((d) => d.includes("significant size change"))).toBe(true); + }); + + it("detects import changes", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + imports: [{ source: "./helpers", specifiers: ["doStuff"] }], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("imports changed"); + }); + + it("detects export list changes", () => { + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + exports: ["main", "helper"], + }; + const result = compareFingerprints(baseFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("exports changed"); + }); + + it("detects new and removed classes", () => { + const withClass: FileFingerprint = { + ...baseFp, + contentHash: "different", + classes: [{ name: "MyClass", methods: ["init"], properties: [], exported: true, lineCount: 30 }], + }; + const result = compareFingerprints(baseFp, withClass); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("new class: MyClass"); + }); + + it("detects class method changes", () => { + const oldFp: FileFingerprint = { + ...baseFp, + classes: [{ name: "Foo", methods: ["a", "b"], properties: [], exported: true, lineCount: 30 }], + }; + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + classes: [{ name: "Foo", methods: ["a", "c"], properties: [], exported: true, lineCount: 30 }], + }; + const result = compareFingerprints(oldFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("methods changed: Foo"); + }); +}); + +describe("analyzeChanges", () => { + const mockRegistry = { + analyzeFile: vi.fn(), + } as any; + + const existingStore: FingerprintStore = { + version: "1.0.0", + gitCommitHash: "abc123", + generatedAt: "2026-01-01T00:00:00.000Z", + files: { + "src/index.ts": { + filePath: "src/index.ts", + contentHash: "hash_a", + functions: [{ name: "main", params: [], exported: true, lineCount: 20 }], + classes: [], + imports: [], + exports: ["main"], + totalLines: 30, + }, + "src/utils.ts": { + filePath: "src/utils.ts", + contentHash: "hash_b", + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: 10, + }, + }, + }; + + it("classifies new files as STRUCTURAL", () => { + mockedExistsSync.mockReturnValue(true); + mockedReadFileSync.mockReturnValue("new content"); + mockRegistry.analyzeFile.mockReturnValue({ + functions: [], + classes: [], + imports: [], + exports: [], + }); + + const result = analyzeChanges("/project", ["src/new-file.ts"], existingStore, mockRegistry); + + expect(result.newFiles).toContain("src/new-file.ts"); + expect(result.fileChanges[0].changeLevel).toBe("STRUCTURAL"); + }); + + it("classifies deleted files as STRUCTURAL", () => { + mockedExistsSync.mockReturnValue(false); + + const result = analyzeChanges("/project", ["src/utils.ts"], existingStore, mockRegistry); + + expect(result.deletedFiles).toContain("src/utils.ts"); + expect(result.fileChanges[0].changeLevel).toBe("STRUCTURAL"); + }); + + it("classifies unchanged content as NONE", () => { + mockedExistsSync.mockReturnValue(true); + // Return content that produces the same hash + const content = "test content"; + const hash = contentHash(content); + + const store: FingerprintStore = { + ...existingStore, + files: { + "src/index.ts": { + ...existingStore.files["src/index.ts"], + contentHash: hash, + }, + }, + }; + + mockedReadFileSync.mockReturnValue(content); + mockRegistry.analyzeFile.mockReturnValue({ + functions: [{ name: "main", lineRange: [1, 20], params: [] }], + classes: [], + imports: [], + exports: [{ name: "main", lineNumber: 1 }], + }); + + const result = analyzeChanges("/project", ["src/index.ts"], store, mockRegistry); + + expect(result.unchangedFiles).toContain("src/index.ts"); + }); + + it("ignores deleted files not in the store", () => { + mockedExistsSync.mockReturnValue(false); + + const result = analyzeChanges("/project", ["src/unknown.ts"], existingStore, mockRegistry); + + expect(result.deletedFiles).toHaveLength(0); + expect(result.fileChanges).toHaveLength(0); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/change-classifier.ts b/understand-anything-plugin/packages/core/src/change-classifier.ts new file mode 100644 index 0000000..b7eae92 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/change-classifier.ts @@ -0,0 +1,133 @@ +import { dirname } from "node:path"; +import type { ChangeAnalysis } from "./fingerprint.js"; + +export interface UpdateDecision { + action: "SKIP" | "PARTIAL_UPDATE" | "ARCHITECTURE_UPDATE" | "FULL_UPDATE"; + filesToReanalyze: string[]; + rerunArchitecture: boolean; + rerunTour: boolean; + reason: string; +} + +/** + * Classify the type of graph update needed based on structural change analysis. + * + * Decision matrix: + * - SKIP: all files NONE or COSMETIC only + * - PARTIAL_UPDATE: some STRUCTURAL, same directories + * - ARCHITECTURE_UPDATE: new/deleted directories or >10 structural files + * - FULL_UPDATE: >30 structural files or >50% of total files changed structurally + */ +export function classifyUpdate( + analysis: ChangeAnalysis, + totalFilesInGraph: number, +): UpdateDecision { + const { newFiles, deletedFiles, structurallyChangedFiles, cosmeticOnlyFiles, unchangedFiles } = analysis; + const structuralCount = structurallyChangedFiles.length + newFiles.length + deletedFiles.length; + + // No structural changes at all — skip + if (structuralCount === 0) { + const cosmeticCount = cosmeticOnlyFiles.length; + const reason = cosmeticCount > 0 + ? `${cosmeticCount} file(s) have cosmetic-only changes (no structural impact)` + : "No changes detected"; + + return { + action: "SKIP", + filesToReanalyze: [], + rerunArchitecture: false, + rerunTour: false, + reason, + }; + } + + // Too many structural changes — suggest full rebuild + if (structuralCount > 30 || (totalFilesInGraph > 0 && structuralCount / totalFilesInGraph > 0.5)) { + return { + action: "FULL_UPDATE", + filesToReanalyze: [...structurallyChangedFiles, ...newFiles], + rerunArchitecture: true, + rerunTour: true, + reason: `${structuralCount} files have structural changes (>${totalFilesInGraph > 0 ? "50% of project" : "30 files"}) — full rebuild recommended`, + }; + } + + // Check if directory structure changed (new/deleted top-level directories) + const hasDirectoryChanges = detectDirectoryChanges(newFiles, deletedFiles, structurallyChangedFiles); + + if (hasDirectoryChanges || structuralCount > 10) { + return { + action: "ARCHITECTURE_UPDATE", + filesToReanalyze: [...structurallyChangedFiles, ...newFiles], + rerunArchitecture: true, + rerunTour: true, + reason: hasDirectoryChanges + ? `Directory structure changed (${newFiles.length} new, ${deletedFiles.length} deleted files)` + : `${structuralCount} files have structural changes — architecture re-analysis needed`, + }; + } + + // Localized structural changes — partial update + return { + action: "PARTIAL_UPDATE", + filesToReanalyze: [...structurallyChangedFiles, ...newFiles], + rerunArchitecture: false, + rerunTour: false, + reason: `${structuralCount} file(s) have structural changes: ${summarizeChanges(analysis)}`, + }; +} + +/** + * Detect if the changes affect the directory structure (new or removed directories). + * Checks if any new/deleted files introduce or remove a top-level source directory. + */ +function detectDirectoryChanges( + newFiles: string[], + deletedFiles: string[], + structurallyChangedFiles: string[], +): boolean { + const existingDirs = new Set( + structurallyChangedFiles.map((f) => topDirectory(f)).filter(Boolean), + ); + + for (const f of newFiles) { + const dir = topDirectory(f); + if (dir && !existingDirs.has(dir)) return true; + } + + for (const f of deletedFiles) { + const dir = topDirectory(f); + if (dir && !existingDirs.has(dir)) return true; + } + + return false; +} + +/** + * Get the top-level directory of a file path (first path segment). + */ +function topDirectory(filePath: string): string | null { + const dir = dirname(filePath); + if (dir === "." || dir === "") return null; + const segments = dir.split("/"); + return segments[0] || null; +} + +/** + * Produce a concise human-readable summary of structural changes. + */ +function summarizeChanges(analysis: ChangeAnalysis): string { + const parts: string[] = []; + + if (analysis.newFiles.length > 0) { + parts.push(`${analysis.newFiles.length} new`); + } + if (analysis.deletedFiles.length > 0) { + parts.push(`${analysis.deletedFiles.length} deleted`); + } + if (analysis.structurallyChangedFiles.length > 0) { + parts.push(`${analysis.structurallyChangedFiles.length} modified`); + } + + return parts.join(", "); +} diff --git a/understand-anything-plugin/packages/core/src/fingerprint.ts b/understand-anything-plugin/packages/core/src/fingerprint.ts new file mode 100644 index 0000000..b2d1d2d --- /dev/null +++ b/understand-anything-plugin/packages/core/src/fingerprint.ts @@ -0,0 +1,371 @@ +import { createHash } from "node:crypto"; +import { readFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { StructuralAnalysis } from "./types.js"; +import type { PluginRegistry } from "./plugins/registry.js"; + +// ---- Fingerprint types ---- + +export interface FunctionFingerprint { + name: string; + params: string[]; + returnType?: string; + exported: boolean; + lineCount: number; +} + +export interface ClassFingerprint { + name: string; + methods: string[]; + properties: string[]; + exported: boolean; + lineCount: number; +} + +export interface ImportFingerprint { + source: string; + specifiers: string[]; +} + +export interface FileFingerprint { + filePath: string; + contentHash: string; + functions: FunctionFingerprint[]; + classes: ClassFingerprint[]; + imports: ImportFingerprint[]; + exports: string[]; + totalLines: number; +} + +export interface FingerprintStore { + version: "1.0.0"; + gitCommitHash: string; + generatedAt: string; + files: Record; +} + +export type ChangeLevel = "NONE" | "COSMETIC" | "STRUCTURAL"; + +export interface FileChangeResult { + filePath: string; + changeLevel: ChangeLevel; + details: string[]; +} + +export interface ChangeAnalysis { + fileChanges: FileChangeResult[]; + newFiles: string[]; + deletedFiles: string[]; + structurallyChangedFiles: string[]; + cosmeticOnlyFiles: string[]; + unchangedFiles: string[]; +} + +// ---- Core functions ---- + +/** + * Compute SHA-256 content hash for a file's content. + */ +export function contentHash(content: string): string { + return createHash("sha256").update(content).digest("hex"); +} + +/** + * Extract a structural fingerprint from a file using its tree-sitter analysis. + * The fingerprint captures only the elements that affect the knowledge graph + * (function/class/import/export signatures), not implementation details. + */ +export function extractFileFingerprint( + filePath: string, + content: string, + analysis: StructuralAnalysis, +): FileFingerprint { + const hash = contentHash(content); + const exportedNames = new Set(analysis.exports.map((e) => e.name)); + + const functions: FunctionFingerprint[] = analysis.functions.map((fn) => ({ + name: fn.name, + params: [...fn.params], + returnType: fn.returnType, + exported: exportedNames.has(fn.name), + lineCount: fn.lineRange[1] - fn.lineRange[0] + 1, + })); + + const classes: ClassFingerprint[] = analysis.classes.map((cls) => ({ + name: cls.name, + methods: [...cls.methods], + properties: [...cls.properties], + exported: exportedNames.has(cls.name), + lineCount: cls.lineRange[1] - cls.lineRange[0] + 1, + })); + + const imports: ImportFingerprint[] = analysis.imports.map((imp) => ({ + source: imp.source, + specifiers: [...imp.specifiers], + })); + + const exports = analysis.exports.map((e) => e.name); + + const totalLines = content.split("\n").length; + + return { + filePath, + contentHash: hash, + functions, + classes, + imports, + exports, + totalLines, + }; +} + +/** + * Compare two file fingerprints and determine the change level. + * + * - NONE: content hash identical (file unchanged) + * - COSMETIC: content differs but structural signatures match (internal logic only) + * - STRUCTURAL: signature-level changes detected + */ +export function compareFingerprints( + oldFp: FileFingerprint, + newFp: FileFingerprint, +): FileChangeResult { + const details: string[] = []; + + // Fast path: identical content + if (oldFp.contentHash === newFp.contentHash) { + return { filePath: newFp.filePath, changeLevel: "NONE", details: [] }; + } + + // Compare function signatures + const oldFuncNames = new Set(oldFp.functions.map((f) => f.name)); + const newFuncNames = new Set(newFp.functions.map((f) => f.name)); + + for (const name of newFuncNames) { + if (!oldFuncNames.has(name)) { + details.push(`new function: ${name}`); + } + } + for (const name of oldFuncNames) { + if (!newFuncNames.has(name)) { + details.push(`removed function: ${name}`); + } + } + + // Compare shared functions for signature changes + for (const newFn of newFp.functions) { + const oldFn = oldFp.functions.find((f) => f.name === newFn.name); + if (!oldFn) continue; + + if (JSON.stringify(oldFn.params) !== JSON.stringify(newFn.params)) { + details.push(`params changed: ${newFn.name}`); + } + if (oldFn.returnType !== newFn.returnType) { + details.push(`return type changed: ${newFn.name}`); + } + if (oldFn.exported !== newFn.exported) { + details.push(`export status changed: ${newFn.name}`); + } + // Flag large line count changes (>50% growth or shrink) + if (oldFn.lineCount > 0) { + const ratio = newFn.lineCount / oldFn.lineCount; + if (ratio > 1.5 || ratio < 0.5) { + details.push(`significant size change: ${newFn.name} (${oldFn.lineCount} → ${newFn.lineCount} lines)`); + } + } + } + + // Compare class signatures + const oldClassNames = new Set(oldFp.classes.map((c) => c.name)); + const newClassNames = new Set(newFp.classes.map((c) => c.name)); + + for (const name of newClassNames) { + if (!oldClassNames.has(name)) { + details.push(`new class: ${name}`); + } + } + for (const name of oldClassNames) { + if (!newClassNames.has(name)) { + details.push(`removed class: ${name}`); + } + } + + for (const newCls of newFp.classes) { + const oldCls = oldFp.classes.find((c) => c.name === newCls.name); + if (!oldCls) continue; + + if (JSON.stringify(oldCls.methods.sort()) !== JSON.stringify(newCls.methods.sort())) { + details.push(`methods changed: ${newCls.name}`); + } + if (JSON.stringify(oldCls.properties.sort()) !== JSON.stringify(newCls.properties.sort())) { + details.push(`properties changed: ${newCls.name}`); + } + if (oldCls.exported !== newCls.exported) { + details.push(`export status changed: ${newCls.name}`); + } + } + + // Compare imports + const oldImports = oldFp.imports.map((i) => `${i.source}:${i.specifiers.sort().join(",")}`).sort(); + const newImports = newFp.imports.map((i) => `${i.source}:${i.specifiers.sort().join(",")}`).sort(); + + if (JSON.stringify(oldImports) !== JSON.stringify(newImports)) { + details.push("imports changed"); + } + + // Compare exports + const oldExports = [...oldFp.exports].sort(); + const newExports = [...newFp.exports].sort(); + + if (JSON.stringify(oldExports) !== JSON.stringify(newExports)) { + details.push("exports changed"); + } + + if (details.length > 0) { + return { filePath: newFp.filePath, changeLevel: "STRUCTURAL", details }; + } + + // Content changed but structure is identical + return { + filePath: newFp.filePath, + changeLevel: "COSMETIC", + details: ["internal logic changed (no structural impact)"], + }; +} + +/** + * Build a fingerprint store for a set of files. + * Files without tree-sitter support get content-hash-only fingerprints + * (conservative: any change is treated as STRUCTURAL). + */ +export function buildFingerprintStore( + projectDir: string, + filePaths: string[], + registry: PluginRegistry, + gitCommitHash: string, +): FingerprintStore { + const files: Record = {}; + + for (const filePath of filePaths) { + const absolutePath = join(projectDir, filePath); + if (!existsSync(absolutePath)) continue; + + const content = readFileSync(absolutePath, "utf-8"); + const analysis = registry.analyzeFile(filePath, content); + + if (analysis) { + files[filePath] = extractFileFingerprint(filePath, content, analysis); + } else { + // No tree-sitter support: content hash only (conservative) + files[filePath] = { + filePath, + contentHash: contentHash(content), + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: content.split("\n").length, + }; + } + } + + return { + version: "1.0.0", + gitCommitHash, + generatedAt: new Date().toISOString(), + files, + }; +} + +/** + * Analyze changes between the current state of files and stored fingerprints. + * Returns a detailed breakdown of what changed and at what level. + */ +export function analyzeChanges( + projectDir: string, + changedFiles: string[], + existingStore: FingerprintStore, + registry: PluginRegistry, +): ChangeAnalysis { + const fileChanges: FileChangeResult[] = []; + const newFiles: string[] = []; + const deletedFiles: string[] = []; + const structurallyChangedFiles: string[] = []; + const cosmeticOnlyFiles: string[] = []; + const unchangedFiles: string[] = []; + + for (const filePath of changedFiles) { + const absolutePath = join(projectDir, filePath); + const existedBefore = filePath in existingStore.files; + const existsNow = existsSync(absolutePath); + + // File was deleted + if (!existsNow) { + if (existedBefore) { + deletedFiles.push(filePath); + fileChanges.push({ + filePath, + changeLevel: "STRUCTURAL", + details: ["file deleted"], + }); + } + continue; + } + + // File is new + if (!existedBefore) { + newFiles.push(filePath); + fileChanges.push({ + filePath, + changeLevel: "STRUCTURAL", + details: ["new file"], + }); + continue; + } + + // File exists in both — compare fingerprints + const content = readFileSync(absolutePath, "utf-8"); + const analysis = registry.analyzeFile(filePath, content); + const oldFp = existingStore.files[filePath]; + + let newFp: FileFingerprint; + if (analysis) { + newFp = extractFileFingerprint(filePath, content, analysis); + } else { + // No tree-sitter support: content hash only + newFp = { + filePath, + contentHash: contentHash(content), + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: content.split("\n").length, + }; + } + + const result = compareFingerprints(oldFp, newFp); + fileChanges.push(result); + + switch (result.changeLevel) { + case "NONE": + unchangedFiles.push(filePath); + break; + case "COSMETIC": + cosmeticOnlyFiles.push(filePath); + break; + case "STRUCTURAL": + structurallyChangedFiles.push(filePath); + break; + } + } + + return { + fileChanges, + newFiles, + deletedFiles, + structurallyChangedFiles, + cosmeticOnlyFiles, + unchangedFiles, + }; +} diff --git a/understand-anything-plugin/packages/core/src/index.ts b/understand-anything-plugin/packages/core/src/index.ts index 7615d6d..be8b001 100644 --- a/understand-anything-plugin/packages/core/src/index.ts +++ b/understand-anything-plugin/packages/core/src/index.ts @@ -48,3 +48,22 @@ export { cosineSimilarity, type SemanticSearchOptions, } from "./embedding-search.js"; +export { + extractFileFingerprint, + compareFingerprints, + analyzeChanges, + buildFingerprintStore, + contentHash, + type FunctionFingerprint, + type ClassFingerprint, + type ImportFingerprint, + type FileFingerprint, + type FingerprintStore, + type ChangeLevel, + type FileChangeResult, + type ChangeAnalysis, +} from "./fingerprint.js"; +export { + classifyUpdate, + type UpdateDecision, +} from "./change-classifier.js"; diff --git a/understand-anything-plugin/packages/core/src/persistence/index.ts b/understand-anything-plugin/packages/core/src/persistence/index.ts index 36ba5cf..2d3a6eb 100644 --- a/understand-anything-plugin/packages/core/src/persistence/index.ts +++ b/understand-anything-plugin/packages/core/src/persistence/index.ts @@ -1,11 +1,14 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; import { join } from "node:path"; -import type { KnowledgeGraph, AnalysisMeta } from "../types.js"; +import type { KnowledgeGraph, AnalysisMeta, ProjectConfig } from "../types.js"; +import type { FingerprintStore } from "../fingerprint.js"; import { validateGraph } from "../schema.js"; const UA_DIR = ".understand-anything"; const GRAPH_FILE = "knowledge-graph.json"; const META_FILE = "meta.json"; +const FINGERPRINT_FILE = "fingerprints.json"; +const CONFIG_FILE = "config.json"; function ensureDir(projectRoot: string): string { const dir = join(projectRoot, UA_DIR); @@ -52,3 +55,31 @@ export function loadMeta(projectRoot: string): AnalysisMeta | null { if (!existsSync(filePath)) return null; return JSON.parse(readFileSync(filePath, "utf-8")) as AnalysisMeta; } + +export function saveFingerprints(projectRoot: string, store: FingerprintStore): void { + const dir = ensureDir(projectRoot); + writeFileSync(join(dir, FINGERPRINT_FILE), JSON.stringify(store, null, 2), "utf-8"); +} + +export function loadFingerprints(projectRoot: string): FingerprintStore | null { + const filePath = join(projectRoot, UA_DIR, FINGERPRINT_FILE); + if (!existsSync(filePath)) return null; + return JSON.parse(readFileSync(filePath, "utf-8")) as FingerprintStore; +} + +const DEFAULT_CONFIG: ProjectConfig = { autoUpdate: false }; + +export function saveConfig(projectRoot: string, config: ProjectConfig): void { + const dir = ensureDir(projectRoot); + writeFileSync(join(dir, CONFIG_FILE), JSON.stringify(config, null, 2), "utf-8"); +} + +export function loadConfig(projectRoot: string): ProjectConfig { + const filePath = join(projectRoot, UA_DIR, CONFIG_FILE); + if (!existsSync(filePath)) return { ...DEFAULT_CONFIG }; + try { + return JSON.parse(readFileSync(filePath, "utf-8")) as ProjectConfig; + } catch { + return { ...DEFAULT_CONFIG }; + } +} diff --git a/understand-anything-plugin/packages/core/src/types.ts b/understand-anything-plugin/packages/core/src/types.ts index f5fef33..e186c16 100644 --- a/understand-anything-plugin/packages/core/src/types.ts +++ b/understand-anything-plugin/packages/core/src/types.ts @@ -74,6 +74,11 @@ export interface AnalysisMeta { analyzedFiles: number; } +// Project config (for auto-update opt-in) +export interface ProjectConfig { + autoUpdate: boolean; +} + // Plugin interfaces export interface StructuralAnalysis { functions: Array<{ name: string; lineRange: [number, number]; params: string[]; returnType?: string }>; diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index ce7ce79..e13e3a1 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -12,6 +12,8 @@ Analyze the current codebase and produce a `knowledge-graph.json` file in `.unde - `$ARGUMENTS` may contain: - `--full` — Force a full rebuild, ignoring any existing graph + - `--auto-update` — Enable automatic graph updates on commit (writes `autoUpdate: true` to `.understand-anything/config.json`) + - `--no-auto-update` — Disable automatic graph updates (writes `autoUpdate: false` to `.understand-anything/config.json`) - A directory path — Scope analysis to a specific subdirectory --- @@ -29,6 +31,11 @@ Determine whether to run a full analysis or incremental update. ```bash mkdir -p $PROJECT_ROOT/.understand-anything/intermediate ``` +3.5. **Auto-update configuration:** + - If `--auto-update` is in `$ARGUMENTS`: write `{"autoUpdate": true}` to `$PROJECT_ROOT/.understand-anything/config.json` + - If `--no-auto-update` is in `$ARGUMENTS`: write `{"autoUpdate": false}` to `$PROJECT_ROOT/.understand-anything/config.json` + - These flags only set the config — analysis proceeds normally regardless. + 4. Check if `$PROJECT_ROOT/.understand-anything/knowledge-graph.json` exists. If it does, read it. 5. Check if `$PROJECT_ROOT/.understand-anything/meta.json` exists. If it does, read it to get `gitCommitHash`. 6. **Decision logic:** @@ -376,6 +383,37 @@ Pass these parameters in the dispatch prompt: } ``` +2.5. **Generate structural fingerprints** for all analyzed files and save to `$PROJECT_ROOT/.understand-anything/fingerprints.json`. This creates the baseline for future automatic incremental updates. + + Write and execute a Node.js script that: + 1. Reads each source file path from the scan results (Phase 1) + 2. For each file: computes a SHA-256 content hash, then extracts function/class/import/export declarations via regex matching: + - Functions: `function NAME(`, `const NAME = (`, `export function NAME(`, arrow functions assigned to const/let + - Classes: `class NAME`, `export class NAME` + - Imports: `import ... from '...'`, `import '...'` + - Exports: `export { ... }`, `export default`, `export function`, `export class`, `export const` + 3. For each function: record name, parameter names, whether exported, and line count + 4. For each class: record name, method names, property names, whether exported + 5. Writes the fingerprint store JSON to `$PROJECT_ROOT/.understand-anything/fingerprints.json`: + ```json + { + "version": "1.0.0", + "gitCommitHash": "", + "generatedAt": "", + "files": { + "": { + "filePath": "", + "contentHash": "", + "functions": [{ "name": "...", "params": ["..."], "exported": true, "lineCount": 35 }], + "classes": [{ "name": "...", "methods": ["..."], "properties": ["..."], "exported": true, "lineCount": 50 }], + "imports": [{ "source": "...", "specifiers": ["..."] }], + "exports": ["name1", "name2"], + "totalLines": 120 + } + } + } + ``` + 3. Clean up intermediate files: ```bash rm -rf $PROJECT_ROOT/.understand-anything/intermediate From f53f3605b4fa31d64158140412859a042e4a3266 Mon Sep 17 00:00:00 2001 From: Sai Smruti Ranjan Das <160756794+saismrutiranjan18@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:51:25 +0530 Subject: [PATCH 02/32] Implement access token and enhance endpoint security Added a one-time access token for secure data fetching and improved endpoint protection. --- .../packages/dashboard/vite.config.ts | 153 +++++++++++++----- 1 file changed, 115 insertions(+), 38 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/vite.config.ts b/understand-anything-plugin/packages/dashboard/vite.config.ts index aa60f93..2b5ebc5 100644 --- a/understand-anything-plugin/packages/dashboard/vite.config.ts +++ b/understand-anything-plugin/packages/dashboard/vite.config.ts @@ -3,8 +3,21 @@ import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import path from "path"; import fs from "fs"; +import crypto from "crypto"; + +// Generate a one-time token when the server process starts. +// This token is printed to the terminal and must be in the URL +// to fetch knowledge-graph.json or diff-overlay.json. +const ACCESS_TOKEN = crypto.randomBytes(16).toString("hex"); export default defineConfig({ + // FIX 1 — bind only to localhost, not 0.0.0.0 + // This blocks access from any other device on the same LAN / WiFi. + server: { + host: "127.0.0.1", + port: 5173, + }, + resolve: { alias: { "@understand-anything/core/schema": path.resolve(__dirname, "../core/dist/schema.js"), @@ -12,53 +25,117 @@ export default defineConfig({ "@understand-anything/core/types": path.resolve(__dirname, "../core/dist/types.js"), }, }, + plugins: [ react(), tailwindcss(), { name: "serve-knowledge-graph", configureServer(server) { + // Print the access URL once so the developer can open it. + server.httpServer?.once("listening", () => { + console.log( + `\n 🔑 Dashboard URL: http://127.0.0.1:5173?token=${ACCESS_TOKEN}\n` + ); + }); + server.middlewares.use((req, res, next) => { - if (req.url === "/knowledge-graph.json") { - // GRAPH_DIR env var points to the project being analyzed - // Falls back to monorepo root, then public/ (demo) - const graphDir = process.env.GRAPH_DIR; - const candidates = [ - ...(graphDir - ? [path.resolve(graphDir, ".understand-anything/knowledge-graph.json")] - : []), - path.resolve(process.cwd(), ".understand-anything/knowledge-graph.json"), - path.resolve(process.cwd(), "../../../.understand-anything/knowledge-graph.json"), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - res.setHeader("Content-Type", "application/json"); - fs.createReadStream(candidate).pipe(res); - return; - } - } - } - if (req.url === "/diff-overlay.json") { - const graphDir = process.env.GRAPH_DIR; - const candidates = [ - ...(graphDir - ? [path.resolve(graphDir, ".understand-anything/diff-overlay.json")] - : []), - path.resolve(process.cwd(), ".understand-anything/diff-overlay.json"), - path.resolve(process.cwd(), "../../../.understand-anything/diff-overlay.json"), - ]; - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - res.setHeader("Content-Type", "application/json"); - fs.createReadStream(candidate).pipe(res); - return; - } - } - res.statusCode = 404; - res.end(); + const url = new URL(req.url ?? "/", "http://127.0.0.1:5173"); + const pathname = url.pathname; + const isProtectedEndpoint = + pathname === "/knowledge-graph.json" || + pathname === "/diff-overlay.json"; + + if (!isProtectedEndpoint) { + next(); return; } - next(); + + // FIX 3 — require the one-time token on all data endpoints. + // Requests without a matching ?token= get a 403. + if (url.searchParams.get("token") !== ACCESS_TOKEN) { + res.statusCode = 403; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Forbidden: missing or invalid token" })); + return; + } + + const fileName = + pathname === "/diff-overlay.json" + ? "diff-overlay.json" + : "knowledge-graph.json"; + + const graphDir = process.env.GRAPH_DIR; + const candidates = [ + ...(graphDir + ? [path.resolve(graphDir, `.understand-anything/${fileName}`)] + : []), + path.resolve(process.cwd(), `.understand-anything/${fileName}`), + path.resolve( + process.cwd(), + `../../../.understand-anything/${fileName}` + ), + ]; + + for (const candidate of candidates) { + if (!fs.existsSync(candidate)) continue; + + // FIX 2 — sanitise absolute file paths before sending the JSON. + // Nodes can contain filePath values like /Users/alice/company/src/auth.ts. + // We convert those to relative paths (src/auth.ts) so the developer's + // home directory and company directory layout are not leaked. + try { + const raw = JSON.parse(fs.readFileSync(candidate, "utf-8")) as { + nodes?: Array>; + [key: string]: unknown; + }; + + // Derive the project root from the candidate path so we can + // make file paths relative to it. + const projectRoot = path.dirname( + candidate.replace( + `${path.sep}.understand-anything${path.sep}${fileName}`, + "" + ) + ); + + if (Array.isArray(raw.nodes)) { + raw.nodes = raw.nodes.map((node) => { + if (typeof node.filePath !== "string") return node; + const abs = node.filePath; + // Only relativise paths that actually sit inside projectRoot. + // Leave external or already-relative paths untouched. + const rel = abs.startsWith(projectRoot) + ? abs.slice(projectRoot.length).replace(/^[\\/]/, "") + : path.isAbsolute(abs) + ? path.basename(abs) // absolute but outside root — use filename only + : abs; // already relative — keep as-is + return { ...node, filePath: rel }; + }); + } + + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(raw)); + } catch (err) { + // If we cannot parse or sanitise the file, refuse to serve it + // rather than accidentally leaking raw content. + console.error("[understand-anything] Failed to sanitise graph file:", err); + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "Failed to read graph file" })); + } + return; + } + + // No matching file found on disk. + if (pathname === "/diff-overlay.json") { + res.statusCode = 404; + res.end(); + } else { + res.statusCode = 404; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: "No knowledge graph found. Run /understand first." })); + } }); }, }, From 99bc056c26cb19647a1c88a1dea1b91b3bf3dd16 Mon Sep 17 00:00:00 2001 From: Sai Smruti Ranjan Das <160756794+saismrutiranjan18@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:52:49 +0530 Subject: [PATCH 03/32] Specify host in Vite dev server command Updated Vite dev server command to specify host. --- understand-anything-plugin/skills/understand-dashboard/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/understand-anything-plugin/skills/understand-dashboard/SKILL.md b/understand-anything-plugin/skills/understand-dashboard/SKILL.md index e0614d7..4c20dbb 100644 --- a/understand-anything-plugin/skills/understand-dashboard/SKILL.md +++ b/understand-anything-plugin/skills/understand-dashboard/SKILL.md @@ -56,7 +56,7 @@ Start the Understand Anything dashboard to visualize the knowledge graph for the 5. Start the Vite dev server pointing at the project's knowledge graph: ```bash - cd && GRAPH_DIR= npx vite --open + cd && GRAPH_DIR= npx vite --host 127.0.0.1 --open ``` Run this in the background so the user can continue working. From f9e5f551b583d8304b523303c581401967e17ba7 Mon Sep 17 00:00:00 2001 From: Sai Smruti Ranjan Das <160756794+saismrutiranjan18@users.noreply.github.com> Date: Tue, 24 Mar 2026 00:55:53 +0530 Subject: [PATCH 04/32] Update index.ts --- .../packages/core/src/persistence/index.ts | 69 ++++++++++++++++++- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/persistence/index.ts b/understand-anything-plugin/packages/core/src/persistence/index.ts index 36ba5cf..5eb7105 100644 --- a/understand-anything-plugin/packages/core/src/persistence/index.ts +++ b/understand-anything-plugin/packages/core/src/persistence/index.ts @@ -1,5 +1,5 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, isAbsolute, relative, basename } from "node:path"; import type { KnowledgeGraph, AnalysisMeta } from "../types.js"; import { validateGraph } from "../schema.js"; @@ -15,9 +15,68 @@ function ensureDir(projectRoot: string): string { return dir; } +/** + * Sanitise every node's filePath before writing to disk. + * + * The analysis agent produces absolute paths like: + * /Users/alice/company/src/auth.ts + * + * We convert them to paths relative to projectRoot: + * src/auth.ts + * + * Three cases are handled: + * 1. Path is inside projectRoot → make it relative + * 2. Path is absolute but outside → keep only the filename (last segment) + * 3. Path is already relative → leave it untouched + * + * This means the developer's home directory, username, and company + * directory layout are never written to knowledge-graph.json. + */ +function sanitiseFilePaths( + graph: KnowledgeGraph, + projectRoot: string, +): KnowledgeGraph { + const normalRoot = projectRoot.endsWith("/") + ? projectRoot + : projectRoot + "/"; + + const sanitisedNodes = graph.nodes.map((node) => { + if (typeof node.filePath !== "string") return node; + + const fp = node.filePath; + + if (!isAbsolute(fp)) { + // Already relative — nothing to do. + return node; + } + + if (fp.startsWith(normalRoot) || fp.startsWith(projectRoot)) { + // Inside the project root — make it relative. + return { ...node, filePath: relative(projectRoot, fp) }; + } + + // Absolute but outside the project root — use only the filename + // so we leak as little as possible. + return { ...node, filePath: basename(fp) }; + }); + + return { ...graph, nodes: sanitisedNodes }; +} + export function saveGraph(projectRoot: string, graph: KnowledgeGraph): void { const dir = ensureDir(projectRoot); - writeFileSync(join(dir, GRAPH_FILE), JSON.stringify(graph, null, 2), "utf-8"); + + // FIX — sanitise absolute file paths before persisting. + // Without this, absolute paths like /Users/alice/company/src/auth.ts + // are written verbatim into knowledge-graph.json and later served + // by the dashboard server, leaking the developer's directory layout. + const sanitised = sanitiseFilePaths(graph, projectRoot); + + writeFileSync( + join(dir, GRAPH_FILE), + JSON.stringify(sanitised, null, 2), + "utf-8", + ); } export function loadGraph( @@ -44,7 +103,11 @@ export function loadGraph( export function saveMeta(projectRoot: string, meta: AnalysisMeta): void { const dir = ensureDir(projectRoot); - writeFileSync(join(dir, META_FILE), JSON.stringify(meta, null, 2), "utf-8"); + writeFileSync( + join(dir, META_FILE), + JSON.stringify(meta, null, 2), + "utf-8", + ); } export function loadMeta(projectRoot: string): AnalysisMeta | null { From 348ba3102d717a21504508e3514bc3d26e936f72 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Thu, 26 Mar 2026 23:37:08 +0800 Subject: [PATCH 05/32] docs: add theme customize design doc and implementation plan --- docs/plans/2026-03-26-theme-system-design.md | 415 ++++++ .../2026-03-26-theme-system-implementation.md | 1166 +++++++++++++++++ 2 files changed, 1581 insertions(+) create mode 100644 docs/plans/2026-03-26-theme-system-design.md create mode 100644 docs/plans/2026-03-26-theme-system-implementation.md diff --git a/docs/plans/2026-03-26-theme-system-design.md b/docs/plans/2026-03-26-theme-system-design.md new file mode 100644 index 0000000..6f94154 --- /dev/null +++ b/docs/plans/2026-03-26-theme-system-design.md @@ -0,0 +1,415 @@ +# Theme System Design + +## Overview + +Add a curated theme preset system with accent color customization to the dashboard. Users select from 5 hand-designed theme presets and optionally swap the accent color within each preset from a set of 8-10 tested swatches. + +### Goals +- Support 5 theme presets: Dark Gold (current), Dark Ocean, Dark Forest, Dark Rose, Light Minimal +- Allow accent color customization within each preset (curated swatches only, no free picker) +- Persist theme preference in both `localStorage` (personal) and `meta.json` (project-level) +- Maintain visual coherence — no user-breakable color combinations +- Zero-reload theme switching via CSS variable injection at runtime + +### Non-Goals +- Free color picker (risk of ugly/unreadable combos) +- Per-component color overrides +- Multiple simultaneous themes + +--- + +## 1. Theme Presets & Color System + +### 1.1 Preset Definitions + +Each preset is a complete mapping of CSS variable names to values. The 5 presets: + +| Token | Dark Gold | Dark Ocean | Dark Forest | Dark Rose | Light Minimal | +|-------|-----------|------------|-------------|-----------|---------------| +| `--color-root` | `#0a0a0a` | `#0a0e14` | `#0a100a` | `#100a0a` | `#f5f3f0` | +| `--color-surface` | `#111111` | `#111820` | `#111811` | `#181111` | `#eae7e3` | +| `--color-elevated` | `#1a1a1a` | `#1a222c` | `#1a241a` | `#221a1a` | `#ffffff` | +| `--color-panel` | `#141414` | `#141c24` | `#141c14` | `#1c1414` | `#f0ede9` | +| `--color-gold`* | `#d4a574` | `#5ba4cf` | `#5ea67a` | `#cf7a8a` | `#4a6fa5` | +| `--color-gold-dim`* | `#c9a96e` | `#4e93ba` | `#4e9468` | `#b96e7e` | `#3d5f8f` | +| `--color-gold-bright`* | `#e8c49a` | `#7abce0` | `#78c492` | `#e094a4` | `#6088bf` | +| `--color-text-primary` | `#f5f0eb` | `#e8edf2` | `#ebf0eb` | `#f2e8ea` | `#1a1a1a` | +| `--color-text-secondary` | `#a39787` | `#87939f` | `#87a38f` | `#9f8790` | `#6b6b6b` | +| `--color-text-muted` | `#6b5f53` | `#536b7a` | `#536b5a` | `#6b535a` | `#a0a0a0` | +| `--color-border-subtle` | `rgba(212,165,116,0.12)` | `rgba(91,164,207,0.12)` | `rgba(94,166,122,0.12)` | `rgba(207,122,138,0.12)` | `rgba(74,111,165,0.10)` | +| `--color-border-medium` | `rgba(212,165,116,0.25)` | `rgba(91,164,207,0.25)` | `rgba(94,166,122,0.25)` | `rgba(207,122,138,0.25)` | `rgba(74,111,165,0.18)` | + +*\* The CSS variable names stay as `--color-gold`, `--color-gold-dim`, `--color-gold-bright` even for non-gold themes. They represent "the accent color" generically. Renaming them to `--color-accent` is a refactor we can do, but not required — the variable name is an implementation detail invisible to users.* + +**Decision: Rename `--color-gold*` to `--color-accent*`** to avoid confusion. This is a find-and-replace across the codebase with no behavioral change. + +### 1.2 Glass Effects + +Glass effects derive from base colors and need per-preset values: + +| Token | Dark themes | Light Minimal | +|-------|-------------|---------------| +| `--glass-bg` | `rgba(20,20,20,0.8)` | `rgba(255,255,255,0.8)` | +| `--glass-bg-heavy` | `rgba(20,20,20,0.95)` | `rgba(255,255,255,0.95)` | +| `--glass-border` | `rgba(accent,0.1)` | `rgba(accent,0.08)` | +| `--glass-border-heavy` | `rgba(accent,0.15)` | `rgba(accent,0.12)` | + +The `.glass` and `.glass-heavy` CSS classes will reference these variables instead of hardcoded values. + +### 1.3 Scrollbar & Glow Colors + +These also derive from the accent color and need to become CSS variables: + +| Token | Purpose | +|-------|---------| +| `--scrollbar-thumb` | `rgba(accent, 0.2)` | +| `--scrollbar-thumb-hover` | `rgba(accent, 0.35)` | +| `--glow-color` | `rgba(accent, 0.4)` for node selection glow | +| `--glow-pulse` | `rgba(accent, 0.6)` for tour highlight pulse | + +### 1.4 Node-Type & Diff Colors + +These are **semantic** and stay fixed across all dark themes: + +| Variable | Value | Purpose | +|----------|-------|---------| +| `--color-node-file` | `#4a7c9b` | File nodes | +| `--color-node-function` | `#5a9e6f` | Function nodes | +| `--color-node-class` | `#8b6fb0` | Class nodes | +| `--color-node-module` | `#c9a06c` | Module nodes | +| `--color-node-concept` | `#b07a8a` | Concept nodes | +| `--color-diff-changed` | `#e05252` | Changed nodes | +| `--color-diff-affected` | `#d4a030` | Affected nodes | + +For **Light Minimal only**, these are slightly desaturated/darkened to maintain readability on light backgrounds: + +| Variable | Light Minimal Value | +|----------|-------------------| +| `--color-node-file` | `#3a6a87` | +| `--color-node-function` | `#488a5b` | +| `--color-node-class` | `#755d99` | +| `--color-node-module` | `#a88a56` | +| `--color-node-concept` | `#966674` | + +### 1.5 Accent Swatches + +Each preset offers 8 accent color options. The first is the "native" default for that preset. Each swatch provides 3 values (accent, accent-dim, accent-bright) plus auto-derived border and glass opacities. + +**Dark theme accent swatches** (shared across all 4 dark presets): + +| Name | Accent | Dim | Bright | +|------|--------|-----|--------| +| Gold | `#d4a574` | `#c9a96e` | `#e8c49a` | +| Ocean | `#5ba4cf` | `#4e93ba` | `#7abce0` | +| Emerald | `#5ea67a` | `#4e9468` | `#78c492` | +| Rose | `#cf7a8a` | `#b96e7e` | `#e094a4` | +| Purple | `#9b7abf` | `#876bb0` | `#b494d4` | +| Amber | `#c9963a` | `#b5862e` | `#ddb05c` | +| Teal | `#4aab9a` | `#3d9686` | `#68c4b4` | +| Silver | `#a0a8b0` | `#8e959c` | `#b8bfc6` | + +**Light Minimal accent swatches:** + +| Name | Accent | Dim | Bright | +|------|--------|-----|--------| +| Indigo | `#4a6fa5` | `#3d5f8f` | `#6088bf` | +| Ocean | `#3a8ab5` | `#2e7aa0` | `#55a0cc` | +| Emerald | `#3a8a5c` | `#2e7a4e` | `#55a878` | +| Rose | `#a5566a` | `#8f4a5c` | `#bf6e82` | +| Purple | `#6b5a9e` | `#5c4d8a` | `#8474b5` | +| Amber | `#9e7a30` | `#8a6a28` | `#b5923e` | +| Teal | `#2e8a7a` | `#267a6c` | `#45a595` | +| Slate | `#5a6570` | `#4e5860` | `#6e7a85` | + +### 1.6 Border & Glass Derivation + +When an accent swatch is selected, borders and glass effects are auto-derived: + +```typescript +function deriveFromAccent(accentHex: string, isDark: boolean) { + return { + borderSubtle: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.12 : 0.10})`, + borderMedium: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.25 : 0.18})`, + glassBorder: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.1 : 0.08})`, + glassBorderHeavy: `rgba(${hexToRgb(accentHex)}, ${isDark ? 0.15 : 0.12})`, + scrollbarThumb: `rgba(${hexToRgb(accentHex)}, 0.2)`, + scrollbarThumbHover: `rgba(${hexToRgb(accentHex)}, 0.35)`, + glowColor: `rgba(${hexToRgb(accentHex)}, 0.4)`, + glowPulse: `rgba(${hexToRgb(accentHex)}, 0.6)`, + }; +} +``` + +--- + +## 2. Architecture & Data Flow + +### 2.1 File Structure + +``` +packages/dashboard/src/ + themes/ + types.ts # ThemePreset, AccentSwatch, ThemeConfig types + presets.ts # 5 preset definitions + accent swatch arrays + theme-engine.ts # applyTheme(), deriveFromAccent(), hexToRgb() + ThemeContext.tsx # React context + provider + useTheme() hook + components/ + ThemePicker.tsx # Popover UI for preset + accent selection +``` + +### 2.2 Type Definitions + +```typescript +// themes/types.ts + +export type PresetId = 'dark-gold' | 'dark-ocean' | 'dark-forest' | 'dark-rose' | 'light-minimal'; + +export interface ThemePreset { + id: PresetId; + name: string; // Display name: "Dark Gold" + isDark: boolean; // true for dark themes, false for light + colors: Record; // CSS variable name -> value (without --) + accentSwatches: AccentSwatch[]; + defaultAccentId: string; // Which swatch is the native default +} + +export interface AccentSwatch { + id: string; // e.g. 'gold', 'ocean' + name: string; // Display name: "Gold" + accent: string; // Primary accent hex + accentDim: string; // Dimmed accent hex + accentBright: string; // Bright accent hex +} + +export interface ThemeConfig { + presetId: PresetId; + accentId: string; // Selected accent swatch ID +} +``` + +### 2.3 Theme Engine + +The theme engine is a pure function layer (no React dependency): + +```typescript +// themes/theme-engine.ts + +export function applyTheme(config: ThemeConfig): void { + const preset = getPreset(config.presetId); + const accent = getAccent(preset, config.accentId); + + // 1. Apply base preset colors + for (const [key, value] of Object.entries(preset.colors)) { + document.documentElement.style.setProperty(`--color-${key}`, value); + } + + // 2. Override accent colors from swatch + document.documentElement.style.setProperty('--color-accent', accent.accent); + document.documentElement.style.setProperty('--color-accent-dim', accent.accentDim); + document.documentElement.style.setProperty('--color-accent-bright', accent.accentBright); + + // 3. Apply derived values (borders, glass, scrollbar, glow) + const derived = deriveFromAccent(accent.accent, preset.isDark); + for (const [key, value] of Object.entries(derived)) { + document.documentElement.style.setProperty(`--${key}`, value); + } + + // 4. Set data-theme attribute for any CSS-only selectors needed + document.documentElement.setAttribute('data-theme', preset.isDark ? 'dark' : 'light'); +} +``` + +### 2.4 React Context + +```typescript +// themes/ThemeContext.tsx + +interface ThemeContextValue { + config: ThemeConfig; + preset: ThemePreset; + setPreset: (presetId: PresetId) => void; + setAccent: (accentId: string) => void; +} +``` + +The provider: +1. On mount: resolves theme from `localStorage` > `meta.json` field in loaded graph > default (`dark-gold`) +2. Calls `applyTheme()` on every config change +3. Persists to `localStorage` on every change +4. Does NOT write to `meta.json` from the dashboard (the dashboard is read-only for meta.json; meta.json is written by the CLI/plugin side) + +### 2.5 Integration with Zustand Store + +The theme system is **separate from the Zustand store** — it uses its own React context. Rationale: +- Theme state is orthogonal to graph/UI state +- Theme needs to apply before the graph even loads (avoid flash of wrong theme) +- Keeps the store focused on graph interaction + +The store does NOT gain any theme-related fields. + +--- + +## 3. UI Components + +### 3.1 Theme Picker Button (Header) + +A small palette icon button in the top header bar, positioned after existing controls (PersonaSelector, DiffToggle, etc.). + +- Click opens a popover/dropdown panel +- Popover has two sections: + - **Presets**: 5 cards/buttons showing preset name + small color preview circles + - **Accent Colors**: row of 8 color circles for the active preset +- Active preset and accent are highlighted with a ring/check +- Selecting a preset instantly applies it; selecting an accent instantly applies it +- Clicking outside or pressing Escape closes the popover + +### 3.2 Preset Preview + +Each preset card shows: +- Name (e.g., "Dark Gold") +- 3-4 small circles showing root, surface, and accent colors as a visual preview +- Check mark or ring on the active one + +### 3.3 Accent Swatch Row + +- 8 small filled circles in a horizontal row +- Tooltip or label on hover showing the accent name +- Active one has a ring/border indicator + +### 3.4 Transitions + +When switching themes: +- CSS variables update instantly (no transition needed for most properties) +- Optionally add a subtle `transition: background-color 0.2s, color 0.2s` on `html` for a smooth feel +- No page reload required + +--- + +## 4. Persistence & Resolution + +### 4.1 Storage Locations + +| Location | Format | Written by | Read by | +|----------|--------|-----------|---------| +| `localStorage` key: `ua-theme` | `JSON.stringify(ThemeConfig)` | Dashboard (on every change) | Dashboard (on mount) | +| `.understand-anything/meta.json` | `{ ..., theme?: ThemeConfig }` | CLI/plugin (during analysis or explicit set) | Dashboard (on mount, as fallback) | + +### 4.2 Resolution Order + +``` +1. localStorage('ua-theme') → user's personal preference (wins) +2. meta.json.theme → project-level default (fallback) +3. { presetId: 'dark-gold', accentId: 'gold' } → hard default +``` + +### 4.3 meta.json Schema Extension + +Extend `AnalysisMeta` in `packages/core/src/types.ts`: + +```typescript +export interface AnalysisMeta { + lastAnalyzedAt: string; + gitCommitHash: string; + version: string; + analyzedFiles: number; + theme?: ThemeConfig; // NEW — optional, project-level theme preference +} +``` + +### 4.4 Dashboard Reads meta.json Theme + +The dashboard currently loads `/knowledge-graph.json` on mount. It also needs to load `/meta.json` (or the theme field can be embedded in `knowledge-graph.json`). + +**Decision:** Load `/meta.json` separately — it's a small file and keeps concerns separated. The dashboard fetches `/meta.json` on mount, extracts the `theme` field if present, and uses it as fallback when `localStorage` has no theme. + +--- + +## 5. Hardcoded Color Consolidation + +### 5.1 Problem + +Many components use hardcoded RGBA values instead of CSS variables: +- `rgba(212,165,116,0.3)` scattered in GraphView, CustomNode, etc. +- `rgba(20,20,20,0.8)` in glass effects +- `rgba(224,82,82,0.25)` in diff overlays + +These won't respond to theme changes. + +### 5.2 Solution + +Before implementing theme switching, consolidate all hardcoded color references: + +1. **Audit**: grep for hardcoded hex/rgba values in component files +2. **Replace with CSS variables**: create new variables where needed (e.g., `--edge-color`, `--edge-color-dim`) +3. **Glass classes**: update `.glass` and `.glass-heavy` in `index.css` to use variables +4. **Scrollbar**: update scrollbar styles to use variables +5. **Glow effects**: update `.node-glow`, `.diff-changed-glow`, `.diff-affected-glow` to use variables + +Key hardcoded patterns to consolidate: + +| Hardcoded Value | Replace With | +|-----------------|-------------| +| `rgba(212,165,116,X)` | `var(--color-accent)` with opacity modifier or dedicated variable | +| `rgba(20,20,20,0.8)` | `var(--glass-bg)` | +| `rgba(20,20,20,0.95)` | `var(--glass-bg-heavy)` | +| `color="rgba(212,165,116,0.15)"` in React Flow | Variable reference | +| Amber colors in WarningBanner | Keep as-is (semantic warning color, theme-independent) | + +### 5.3 CSS Variable Rename + +Rename throughout codebase: +- `--color-gold` -> `--color-accent` +- `--color-gold-dim` -> `--color-accent-dim` +- `--color-gold-bright` -> `--color-accent-bright` +- All Tailwind class usages: `text-gold` -> `text-accent`, `bg-gold` -> `bg-accent`, etc. + +--- + +## 6. Light Theme Considerations + +The Light Minimal theme requires special attention: + +### 6.1 Inverted Contrast + +- Text is dark on light backgrounds (flipped from dark themes) +- Borders need lower opacity to avoid looking harsh +- Glass effects use white-based rgba instead of black-based + +### 6.2 Node Colors + +Slightly darker/desaturated variants for readability on light backgrounds (see Section 1.4). + +### 6.3 data-theme Attribute + +Set `data-theme="light"` on `` for any styles that can't be handled purely through CSS variables (e.g., third-party component overrides, box-shadow directions). + +### 6.4 React Flow + +React Flow's background, minimap, and edge colors all need to respect the theme. The existing `!important` override on `.react-flow__background` already uses `var(--color-root)`, which is good. MiniMap colors in GraphView.tsx are currently hardcoded and need to be updated. + +--- + +## 7. Summary of Changes by Package + +### packages/core +- Extend `AnalysisMeta` type with optional `theme?: ThemeConfig` +- Export `ThemeConfig` and `PresetId` types from `./types` subpath + +### packages/dashboard +- New `themes/` directory with types, presets, engine, and context +- New `ThemePicker` component in header +- Rename `--color-gold*` to `--color-accent*` across all files +- Consolidate hardcoded RGBA values into CSS variables +- Update `index.css`: glass classes, scrollbar, glow effects to use variables +- Update `App.tsx`: wrap with ThemeProvider, add ThemePicker to header, fetch meta.json +- Update components with hardcoded colors: GraphView, CustomNode, LayerLegend, etc. + +--- + +## 8. Out of Scope + +- Theme import/export +- Custom theme creation UI +- Per-node color customization +- Animated theme transitions beyond simple CSS transitions +- Syncing theme across browser tabs (nice-to-have for later) diff --git a/docs/plans/2026-03-26-theme-system-implementation.md b/docs/plans/2026-03-26-theme-system-implementation.md new file mode 100644 index 0000000..148c548 --- /dev/null +++ b/docs/plans/2026-03-26-theme-system-implementation.md @@ -0,0 +1,1166 @@ +# Theme System Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add curated theme presets with accent customization to the dashboard. + +**Architecture:** CSS variable injection at runtime via a pure theme engine, React context for state, localStorage + meta.json for persistence. Five presets (4 dark + 1 light) with 8 accent swatches each. + +**Tech Stack:** React, TypeScript, TailwindCSS v4, Zustand (untouched), CSS custom properties. + +**Design Doc:** `docs/plans/2026-03-26-theme-system-design.md` + +--- + +### Task 1: Rename `gold` to `accent` in CSS variables and Tailwind classes + +This is a mechanical find-and-replace with no behavioral change. Must be done first so all subsequent tasks use the new naming. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/index.css` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/ProjectOverview.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/LayerLegend.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/PersonaSelector.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/App.tsx` + +**Step 1: Rename CSS variables in index.css** + +In the `@theme` block, rename: +- `--color-gold` -> `--color-accent` +- `--color-gold-dim` -> `--color-accent-dim` +- `--color-gold-bright` -> `--color-accent-bright` + +Also rename the `@keyframes goldPulse` to `accentPulse` and `.animate-gold-pulse` to `.animate-accent-pulse`. + +**Step 2: Rename all Tailwind class references across components** + +Find and replace in all component files: +- `text-gold-bright` -> `text-accent-bright` +- `text-gold-dim` -> `text-accent-dim` +- `text-gold` -> `text-accent` +- `bg-gold` -> `bg-accent` +- `border-gold` -> `border-accent` +- `ring-gold-dim` -> `ring-accent-dim` +- `ring-gold-bright` -> `ring-accent-bright` +- `ring-gold` -> `ring-accent` +- `animate-gold-pulse` -> `animate-accent-pulse` + +Order matters — replace the longer `-bright` and `-dim` variants first to avoid partial matches. + +Also replace any `var(--color-gold` with `var(--color-accent` in inline styles. + +**Step 3: Verify the build compiles** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds with no errors. + +**Step 4: Visually verify (optional)** + +Run: `cd understand-anything-plugin && pnpm dev:dashboard` +Expected: Dashboard looks identical — same gold accent, no visual changes. + +**Step 5: Commit** + +```bash +git add -A +git commit -m "refactor(dashboard): rename gold CSS variables to accent" +``` + +--- + +### Task 2: Consolidate hardcoded RGBA values into CSS variables + +Replace scattered hardcoded color values in components with CSS variables so they respond to theme changes. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/index.css` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/CodeViewer.tsx` + +**Step 1: Add new CSS variables to index.css @theme block** + +Add these new variables after the existing border variables: + +```css +/* Glass */ +--glass-bg: rgba(20, 20, 20, 0.8); +--glass-bg-heavy: rgba(20, 20, 20, 0.95); +--glass-border: rgba(212, 165, 116, 0.1); +--glass-border-heavy: rgba(212, 165, 116, 0.15); + +/* Scrollbar */ +--scrollbar-thumb: rgba(212, 165, 116, 0.2); +--scrollbar-thumb-hover: rgba(212, 165, 116, 0.35); + +/* Glow */ +--glow-accent: rgba(212, 165, 116, 0.15); +--glow-accent-strong: rgba(212, 165, 116, 0.4); +--glow-accent-pulse: rgba(212, 165, 116, 0.6); + +/* Edges */ +--color-edge: rgba(212, 165, 116, 0.3); +--color-edge-dim: rgba(212, 165, 116, 0.08); +--color-edge-dot: rgba(212, 165, 116, 0.15); + +/* Layer group (accent-based overlays) */ +--color-accent-overlay-bg: rgba(212, 165, 116, 0.05); +--color-accent-overlay-border: rgba(212, 165, 116, 0.25); + +/* kbd */ +--kbd-bg: rgba(212, 165, 116, 0.1); +``` + +**Step 2: Update .glass, .glass-heavy classes in index.css** + +Replace hardcoded values with the new variables: + +```css +.glass { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); +} + +.glass-heavy { + background: var(--glass-bg-heavy); + border: 1px solid var(--glass-border-heavy); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); +} +``` + +**Step 3: Update scrollbar styles in index.css** + +```css +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} +``` + +**Step 4: Update glow classes in index.css** + +```css +.node-glow { + box-shadow: 0 0 20px var(--glow-accent); +} +``` + +Update `@keyframes accentPulse` (renamed in Task 1): +```css +@keyframes accentPulse { + 0%, 100% { + box-shadow: 0 0 8px var(--glow-accent-strong); + } + 50% { + box-shadow: 0 0 20px var(--glow-accent-pulse); + } +} +``` + +**Step 5: Update .kbd class in index.css** + +```css +.kbd { + /* ... keep existing sizing/layout ... */ + color: var(--color-accent); + background: var(--kbd-bg); +} +``` + +**Step 6: Update GraphView.tsx hardcoded colors** + +Replace these inline style values: + +| Location | Old Value | New Value | +|----------|-----------|-----------| +| Edge default style stroke | `"rgba(212,165,116,0.3)"` | `"var(--color-edge)"` | +| Edge diff-faded stroke | `"rgba(212,165,116,0.08)"` | `"var(--color-edge-dim)"` | +| Background dots color prop | `"rgba(212,165,116,0.15)"` | `"var(--color-edge-dot)"` | +| MiniMap nodeColor | `"#1a1a1a"` | `"var(--color-elevated)"` | +| MiniMap maskColor | `"rgba(10,10,10,0.7)"` | `"var(--glass-bg)"` | +| Group node backgroundColor | `"rgba(212,165,116,0.05)"` | `"var(--color-accent-overlay-bg)"` | +| Group node border | `"2px dashed rgba(212,165,116,0.25)"` | `"2px dashed var(--color-accent-overlay-border)"` | +| Group node label color | `"#d4a574"` | `"var(--color-accent)"` | +| Edge label fill (normal) | `"#a39787"` | `"var(--color-text-secondary)"` | +| Edge label fill (diff faded) | `"rgba(163,151,135,0.3)"` | `"var(--color-text-muted)"` | +| Spinner border class | `border-gold` already renamed to `border-accent` | Already done in Task 1 | + +**Step 7: Update CodeViewer.tsx hardcoded colors** + +Replace inline styles for the file type badge: +- `color: "var(--color-node-file)"` — already uses CSS var, keep +- `borderColor: "rgba(74,124,155,0.3)"` -> `"color-mix(in srgb, var(--color-node-file) 30%, transparent)"` +- `backgroundColor: "rgba(74,124,155,0.1)"` -> `"color-mix(in srgb, var(--color-node-file) 10%, transparent)"` + +**Step 8: Update CustomNode.tsx hardcoded shadow** + +Replace `shadow-[0_2px_8px_rgba(0,0,0,0.3)]` — this black shadow is fine for dark themes but keep it. Leave as-is since it works on both dark and light. + +**Step 9: Verify build** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 10: Commit** + +```bash +git add -A +git commit -m "refactor(dashboard): consolidate hardcoded colors into CSS variables" +``` + +--- + +### Task 3: Create theme type definitions + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/themes/types.ts` + +**Step 1: Write the types file** + +```typescript +export type PresetId = + | "dark-gold" + | "dark-ocean" + | "dark-forest" + | "dark-rose" + | "light-minimal"; + +export interface AccentSwatch { + id: string; + name: string; + accent: string; + accentDim: string; + accentBright: string; +} + +export interface ThemePreset { + id: PresetId; + name: string; + isDark: boolean; + colors: Record; + accentSwatches: AccentSwatch[]; + defaultAccentId: string; +} + +export interface ThemeConfig { + presetId: PresetId; + accentId: string; +} + +export const DEFAULT_THEME_CONFIG: ThemeConfig = { + presetId: "dark-gold", + accentId: "gold", +}; +``` + +**Step 2: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add theme type definitions" +``` + +--- + +### Task 4: Create theme presets + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/themes/presets.ts` + +**Step 1: Write the presets file** + +```typescript +import type { AccentSwatch, ThemePreset } from "./types.ts"; + +const DARK_ACCENT_SWATCHES: AccentSwatch[] = [ + { id: "gold", name: "Gold", accent: "#d4a574", accentDim: "#c9a96e", accentBright: "#e8c49a" }, + { id: "ocean", name: "Ocean", accent: "#5ba4cf", accentDim: "#4e93ba", accentBright: "#7abce0" }, + { id: "emerald", name: "Emerald", accent: "#5ea67a", accentDim: "#4e9468", accentBright: "#78c492" }, + { id: "rose", name: "Rose", accent: "#cf7a8a", accentDim: "#b96e7e", accentBright: "#e094a4" }, + { id: "purple", name: "Purple", accent: "#9b7abf", accentDim: "#876bb0", accentBright: "#b494d4" }, + { id: "amber", name: "Amber", accent: "#c9963a", accentDim: "#b5862e", accentBright: "#ddb05c" }, + { id: "teal", name: "Teal", accent: "#4aab9a", accentDim: "#3d9686", accentBright: "#68c4b4" }, + { id: "silver", name: "Silver", accent: "#a0a8b0", accentDim: "#8e959c", accentBright: "#b8bfc6" }, +]; + +const LIGHT_ACCENT_SWATCHES: AccentSwatch[] = [ + { id: "indigo", name: "Indigo", accent: "#4a6fa5", accentDim: "#3d5f8f", accentBright: "#6088bf" }, + { id: "ocean", name: "Ocean", accent: "#3a8ab5", accentDim: "#2e7aa0", accentBright: "#55a0cc" }, + { id: "emerald", name: "Emerald", accent: "#3a8a5c", accentDim: "#2e7a4e", accentBright: "#55a878" }, + { id: "rose", name: "Rose", accent: "#a5566a", accentDim: "#8f4a5c", accentBright: "#bf6e82" }, + { id: "purple", name: "Purple", accent: "#6b5a9e", accentDim: "#5c4d8a", accentBright: "#8474b5" }, + { id: "amber", name: "Amber", accent: "#9e7a30", accentDim: "#8a6a28", accentBright: "#b5923e" }, + { id: "teal", name: "Teal", accent: "#2e8a7a", accentDim: "#267a6c", accentBright: "#45a595" }, + { id: "slate", name: "Slate", accent: "#5a6570", accentDim: "#4e5860", accentBright: "#6e7a85" }, +]; + +export const PRESETS: ThemePreset[] = [ + { + id: "dark-gold", + name: "Dark Gold", + isDark: true, + defaultAccentId: "gold", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#0a0a0a", + surface: "#111111", + elevated: "#1a1a1a", + panel: "#141414", + "text-primary": "#f5f0eb", + "text-secondary": "#a39787", + "text-muted": "#6b5f53", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + }, + }, + { + id: "dark-ocean", + name: "Dark Ocean", + isDark: true, + defaultAccentId: "ocean", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#0a0e14", + surface: "#111820", + elevated: "#1a222c", + panel: "#141c24", + "text-primary": "#e8edf2", + "text-secondary": "#87939f", + "text-muted": "#536b7a", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + }, + }, + { + id: "dark-forest", + name: "Dark Forest", + isDark: true, + defaultAccentId: "emerald", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#0a100a", + surface: "#111811", + elevated: "#1a241a", + panel: "#141c14", + "text-primary": "#ebf0eb", + "text-secondary": "#87a38f", + "text-muted": "#536b5a", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + }, + }, + { + id: "dark-rose", + name: "Dark Rose", + isDark: true, + defaultAccentId: "rose", + accentSwatches: DARK_ACCENT_SWATCHES, + colors: { + root: "#100a0a", + surface: "#181111", + elevated: "#221a1a", + panel: "#1c1414", + "text-primary": "#f2e8ea", + "text-secondary": "#9f8790", + "text-muted": "#6b535a", + "node-file": "#4a7c9b", + "node-function": "#5a9e6f", + "node-class": "#8b6fb0", + "node-module": "#c9a06c", + "node-concept": "#b07a8a", + }, + }, + { + id: "light-minimal", + name: "Light Minimal", + isDark: false, + defaultAccentId: "indigo", + accentSwatches: LIGHT_ACCENT_SWATCHES, + colors: { + root: "#f5f3f0", + surface: "#eae7e3", + elevated: "#ffffff", + panel: "#f0ede9", + "text-primary": "#1a1a1a", + "text-secondary": "#6b6b6b", + "text-muted": "#a0a0a0", + "node-file": "#3a6a87", + "node-function": "#488a5b", + "node-class": "#755d99", + "node-module": "#a88a56", + "node-concept": "#966674", + }, + }, +]; + +export function getPreset(id: string): ThemePreset { + return PRESETS.find((p) => p.id === id) ?? PRESETS[0]; +} + +export function getAccent(preset: ThemePreset, accentId: string): AccentSwatch { + return ( + preset.accentSwatches.find((s) => s.id === accentId) ?? + preset.accentSwatches.find((s) => s.id === preset.defaultAccentId) ?? + preset.accentSwatches[0] + ); +} +``` + +**Step 2: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add theme preset definitions" +``` + +--- + +### Task 5: Create theme engine + +Pure functions with no React dependency. Handles CSS variable injection and accent derivation. + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/themes/theme-engine.ts` + +**Step 1: Write the theme engine** + +```typescript +import type { ThemeConfig } from "./types.ts"; +import { getAccent, getPreset } from "./presets.ts"; + +export function hexToRgb(hex: string): string { + const h = hex.replace("#", ""); + const n = parseInt(h, 16); + return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`; +} + +function deriveFromAccent(accentHex: string, isDark: boolean): Record { + const rgb = hexToRgb(accentHex); + return { + "color-border-subtle": `rgba(${rgb}, ${isDark ? 0.12 : 0.1})`, + "color-border-medium": `rgba(${rgb}, ${isDark ? 0.25 : 0.18})`, + "glass-bg": isDark ? "rgba(20, 20, 20, 0.8)" : "rgba(255, 255, 255, 0.8)", + "glass-bg-heavy": isDark ? "rgba(20, 20, 20, 0.95)" : "rgba(255, 255, 255, 0.95)", + "glass-border": `rgba(${rgb}, ${isDark ? 0.1 : 0.08})`, + "glass-border-heavy": `rgba(${rgb}, ${isDark ? 0.15 : 0.12})`, + "scrollbar-thumb": `rgba(${rgb}, 0.2)`, + "scrollbar-thumb-hover": `rgba(${rgb}, 0.35)`, + "glow-accent": `rgba(${rgb}, 0.15)`, + "glow-accent-strong": `rgba(${rgb}, 0.4)`, + "glow-accent-pulse": `rgba(${rgb}, 0.6)`, + "color-edge": `rgba(${rgb}, 0.3)`, + "color-edge-dim": `rgba(${rgb}, 0.08)`, + "color-edge-dot": `rgba(${rgb}, 0.15)`, + "color-accent-overlay-bg": `rgba(${rgb}, 0.05)`, + "color-accent-overlay-border": `rgba(${rgb}, 0.25)`, + "kbd-bg": `rgba(${rgb}, 0.1)`, + }; +} + +export function applyTheme(config: ThemeConfig): void { + const preset = getPreset(config.presetId); + const accent = getAccent(preset, config.accentId); + const style = document.documentElement.style; + + // 1. Apply base preset colors + for (const [key, value] of Object.entries(preset.colors)) { + style.setProperty(`--color-${key}`, value); + } + + // 2. Apply accent colors from swatch + style.setProperty("--color-accent", accent.accent); + style.setProperty("--color-accent-dim", accent.accentDim); + style.setProperty("--color-accent-bright", accent.accentBright); + + // 3. Apply derived values + const derived = deriveFromAccent(accent.accent, preset.isDark); + for (const [key, value] of Object.entries(derived)) { + style.setProperty(`--${key}`, value); + } + + // 4. Set data-theme for CSS-only selectors + document.documentElement.setAttribute("data-theme", preset.isDark ? "dark" : "light"); +} +``` + +**Step 2: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add theme engine with CSS variable injection" +``` + +--- + +### Task 6: Create ThemeContext + +React context + provider that manages theme state, persistence, and resolution. + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/themes/ThemeContext.tsx` + +**Step 1: Write the context** + +```typescript +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from "react"; +import type { PresetId, ThemeConfig, ThemePreset } from "./types.ts"; +import { DEFAULT_THEME_CONFIG } from "./types.ts"; +import { getPreset } from "./presets.ts"; +import { applyTheme } from "./theme-engine.ts"; + +const STORAGE_KEY = "ua-theme"; + +interface ThemeContextValue { + config: ThemeConfig; + preset: ThemePreset; + setPreset: (presetId: PresetId) => void; + setAccent: (accentId: string) => void; +} + +const ThemeContext = createContext(null); + +function loadFromLocalStorage(): ThemeConfig | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.presetId === "string" && typeof parsed.accentId === "string") { + return parsed as ThemeConfig; + } + return null; + } catch { + return null; + } +} + +function saveToLocalStorage(config: ThemeConfig): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(config)); + } catch { + // Storage full or unavailable — ignore + } +} + +function resolveInitialTheme(metaTheme?: ThemeConfig | null): ThemeConfig { + return loadFromLocalStorage() ?? metaTheme ?? DEFAULT_THEME_CONFIG; +} + +interface ThemeProviderProps { + metaTheme?: ThemeConfig | null; + children: ReactNode; +} + +export function ThemeProvider({ metaTheme, children }: ThemeProviderProps) { + const [config, setConfig] = useState(() => resolveInitialTheme(metaTheme)); + const initialized = useRef(false); + + // Apply theme on mount and config changes + useEffect(() => { + applyTheme(config); + if (initialized.current) { + saveToLocalStorage(config); + } + initialized.current = true; + }, [config]); + + // Update if metaTheme arrives later (async fetch) and no localStorage preference exists + useEffect(() => { + if (metaTheme && !loadFromLocalStorage()) { + setConfig(metaTheme); + } + }, [metaTheme]); + + const setPreset = useCallback((presetId: PresetId) => { + setConfig((prev) => { + const newPreset = getPreset(presetId); + return { presetId, accentId: newPreset.defaultAccentId }; + }); + }, []); + + const setAccent = useCallback((accentId: string) => { + setConfig((prev) => ({ ...prev, accentId })); + }, []); + + const preset = getPreset(config.presetId); + + return ( + + {children} + + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error("useTheme must be used within ThemeProvider"); + return ctx; +} +``` + +**Step 2: Create barrel export** + +Create: `understand-anything-plugin/packages/dashboard/src/themes/index.ts` + +```typescript +export { ThemeProvider, useTheme } from "./ThemeContext.tsx"; +export { PRESETS, getPreset, getAccent } from "./presets.ts"; +export { applyTheme } from "./theme-engine.ts"; +export type { PresetId, ThemeConfig, ThemePreset, AccentSwatch } from "./types.ts"; +export { DEFAULT_THEME_CONFIG } from "./types.ts"; +``` + +**Step 3: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add ThemeContext with localStorage persistence" +``` + +--- + +### Task 7: Extend AnalysisMeta with theme field + +**Files:** +- Modify: `understand-anything-plugin/packages/core/src/types.ts` + +**Step 1: Add ThemeConfig type and extend AnalysisMeta** + +Add near the top of the file (after existing imports/types): + +```typescript +export interface ThemeConfig { + presetId: string; + accentId: string; +} +``` + +Add `theme` field to `AnalysisMeta`: + +```typescript +export interface AnalysisMeta { + lastAnalyzedAt: string; + gitCommitHash: string; + version: string; + analyzedFiles: number; + theme?: ThemeConfig; +} +``` + +**Step 2: Verify core builds** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/core build` +Expected: Build succeeds. + +**Step 3: Verify core tests pass** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/core test` +Expected: All tests pass. + +**Step 4: Commit** + +```bash +git add -A +git commit -m "feat(core): add optional theme field to AnalysisMeta" +``` + +--- + +### Task 8: Create ThemePicker component + +The popover UI with preset selection and accent swatch row. + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/components/ThemePicker.tsx` + +**Step 1: Write the component** + +```tsx +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTheme, PRESETS } from "../themes/index.ts"; + +export function ThemePicker() { + const { config, preset, setPreset, setAccent } = useTheme(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + // Close on outside click + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + // Close on Escape + useEffect(() => { + if (!open) return; + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("keydown", handleKey); + return () => document.removeEventListener("keydown", handleKey); + }, [open]); + + const handlePreset = useCallback( + (id: string) => { + setPreset(id as Parameters[0]); + }, + [setPreset], + ); + + return ( +
+ + + {open && ( +
+ {/* Presets */} +
+
+ Theme +
+
+ {PRESETS.map((p) => ( + + ))} +
+
+ + {/* Accent swatches */} +
+
+ Accent Color +
+
+ {preset.accentSwatches.map((swatch) => ( +
+
+
+ )} +
+ ); +} +``` + +**Step 2: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add ThemePicker popover component" +``` + +--- + +### Task 9: Integrate ThemeProvider and ThemePicker into App + +Wire everything together in the root component. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/App.tsx` + +**Step 1: Add imports** + +Add to imports at top of App.tsx: + +```typescript +import { ThemeProvider } from "./themes/index.ts"; +import { ThemePicker } from "./components/ThemePicker.tsx"; +import type { ThemeConfig } from "./themes/index.ts"; +``` + +**Step 2: Add meta.json theme loading** + +Inside the App component, add state and effect for meta.json theme: + +```typescript +const [metaTheme, setMetaTheme] = useState(null); + +useEffect(() => { + fetch("/meta.json") + .then((r) => (r.ok ? r.json() : null)) + .then((meta) => { + if (meta?.theme) setMetaTheme(meta.theme); + }) + .catch(() => {}); +}, []); +``` + +**Step 3: Wrap return JSX with ThemeProvider** + +Wrap the entire return value of App with `...`. + +**Step 4: Add ThemePicker to header** + +In the header bar (the `
` or top flex row), add `` after the existing controls (PersonaSelector, DiffToggle, LayerLegend) and before the help button. + +**Step 5: Verify build** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 6: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): integrate ThemeProvider and ThemePicker into App" +``` + +--- + +### Task 10: Light theme CSS adjustments + +Handle edge cases where CSS variables alone aren't sufficient for the light theme. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/index.css` + +**Step 1: Add data-theme selectors for light theme overrides** + +Add at the end of index.css: + +```css +/* Light theme overrides */ +[data-theme="light"] { + color-scheme: light; +} + +[data-theme="light"] .diff-faded { + opacity: 0.35; +} + +[data-theme="light"] ::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="dark"] { + color-scheme: dark; +} +``` + +**Step 2: Add transition for smooth theme switching** + +Add to the `html` base styles: + +```css +html { + transition: background-color 0.2s ease, color 0.2s ease; +} +``` + +**Step 3: Update the WarningBanner consideration** + +WarningBanner uses Tailwind amber/orange colors directly (e.g., `bg-amber-900/20`). These are semantic warning colors and should NOT change with theme. However, for the light theme, the amber colors on a light background need adjustment. + +Add to light theme overrides if needed: + +```css +[data-theme="light"] .warning-banner { + background: rgba(180, 130, 30, 0.1); + border-color: rgba(180, 130, 30, 0.3); + color: #92600a; +} +``` + +Note: Only add this if the WarningBanner looks broken on the light theme during visual testing. It may work fine as-is with Tailwind's amber colors. + +**Step 4: Verify build** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat(dashboard): add light theme CSS overrides" +``` + +--- + +### Task 11: Remove @theme defaults from index.css + +Now that the theme engine sets all CSS variables at runtime, the `@theme` block in index.css serves as the initial/fallback values before React mounts. Keep it but update it to use the accent naming. + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/index.css` + +**Step 1: Update @theme block** + +The `@theme` block should already have `--color-accent` (from Task 1 rename). Ensure the new variables added in Task 2 are also present in the `@theme` block as defaults: + +```css +@theme { + /* Base */ + --color-root: #0a0a0a; + --color-surface: #111111; + --color-elevated: #1a1a1a; + --color-panel: #141414; + + /* Accent */ + --color-accent: #d4a574; + --color-accent-dim: #c9a96e; + --color-accent-bright: #e8c49a; + + /* Text */ + --color-text-primary: #f5f0eb; + --color-text-secondary: #a39787; + --color-text-muted: #6b5f53; + + /* Borders */ + --color-border-subtle: rgba(212, 165, 116, 0.12); + --color-border-medium: rgba(212, 165, 116, 0.25); + + /* Node types */ + --color-node-file: #4a7c9b; + --color-node-function: #5a9e6f; + --color-node-class: #8b6fb0; + --color-node-module: #c9a06c; + --color-node-concept: #b07a8a; + + /* Diff */ + --color-diff-changed: #e05252; + --color-diff-affected: #d4a030; + --color-diff-changed-dim: rgba(224, 82, 82, 0.25); + --color-diff-affected-dim: rgba(212, 160, 48, 0.25); + + /* Glass */ + --glass-bg: rgba(20, 20, 20, 0.8); + --glass-bg-heavy: rgba(20, 20, 20, 0.95); + --glass-border: rgba(212, 165, 116, 0.1); + --glass-border-heavy: rgba(212, 165, 116, 0.15); + + /* Scrollbar */ + --scrollbar-thumb: rgba(212, 165, 116, 0.2); + --scrollbar-thumb-hover: rgba(212, 165, 116, 0.35); + + /* Glow */ + --glow-accent: rgba(212, 165, 116, 0.15); + --glow-accent-strong: rgba(212, 165, 116, 0.4); + --glow-accent-pulse: rgba(212, 165, 116, 0.6); + + /* Edges */ + --color-edge: rgba(212, 165, 116, 0.3); + --color-edge-dim: rgba(212, 165, 116, 0.08); + --color-edge-dot: rgba(212, 165, 116, 0.15); + + /* Accent overlays */ + --color-accent-overlay-bg: rgba(212, 165, 116, 0.05); + --color-accent-overlay-border: rgba(212, 165, 116, 0.25); + + /* Kbd */ + --kbd-bg: rgba(212, 165, 116, 0.1); + + /* Typography */ + --font-serif: 'DM Serif Display', Georgia, serif; + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; + --font-sans: 'Inter', system-ui, sans-serif; +} +``` + +This ensures: +- Tailwind v4 generates all the correct utility classes from the `@theme` block +- Before React mounts, the page shows the Dark Gold default (no flash of unstyled content) +- The theme engine overrides these values at runtime + +**Step 2: Verify build** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 3: Commit** + +```bash +git add -A +git commit -m "refactor(dashboard): align @theme defaults with theme engine variables" +``` + +--- + +### Task 12: Full build + visual verification + +**Files:** None (verification only) + +**Step 1: Build core** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/core build` +Expected: Build succeeds. + +**Step 2: Build dashboard** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/dashboard build` +Expected: Build succeeds. + +**Step 3: Run core tests** + +Run: `cd understand-anything-plugin && pnpm --filter @understand-anything/core test` +Expected: All tests pass. + +**Step 4: Run lint** + +Run: `cd understand-anything-plugin && pnpm lint` +Expected: No lint errors. + +**Step 5: Start dev server and visually verify** + +Run: `cd understand-anything-plugin && pnpm dev:dashboard` + +Verify: +1. Dashboard loads with Dark Gold theme (default) — looks identical to current +2. Theme picker button visible in header +3. Click theme picker — popover opens with 5 presets and 8 accent swatches +4. Select Dark Ocean — backgrounds turn navy-blue, accent turns cyan +5. Select Dark Forest — backgrounds turn dark green, accent turns emerald +6. Select Dark Rose — backgrounds turn dark warm, accent turns rose +7. Select Light Minimal — backgrounds turn light, text turns dark, accent turns indigo +8. Select different accent swatches within each preset — accent color, borders, glass, glow all update +9. Refresh page — theme persists from localStorage +10. Click outside popover — it closes +11. Press Escape — popover closes + +**Step 6: Commit (if any fixes needed)** + +```bash +git add -A +git commit -m "fix(dashboard): theme system visual adjustments" +``` + +--- + +## Dependency Graph + +``` +Task 1 (rename gold→accent) ─┐ + ├─> Task 3 (types) ──┐ +Task 2 (consolidate colors) ──┤ │ + │ Task 4 (presets) ─┤ + │ ├─> Task 6 (context) ─┐ + │ Task 5 (engine) ──┘ │ + │ ├─> Task 8 (picker) ─┐ + │ Task 7 (core types) ────────────────────┘ │ + │ │ + └───────────────────────────────────────────> Task 9 (integrate) ─┤ + │ + Task 10 (light CSS) ┤ + │ + Task 11 (defaults) ─┤ + │ + Task 12 (verify) ───┘ +``` + +**Parallelizable groups:** +- Tasks 1 + 2 can be done sequentially (both touch index.css) +- Tasks 3, 4, 5 can be done in parallel (independent new files) +- Task 6 depends on 3, 4, 5 +- Task 7 is independent (core package) +- Task 8 depends on 6 +- Task 9 depends on 1, 2, 7, 8 +- Tasks 10, 11 can be done after 9 +- Task 12 is final verification From dde40b8512db33d0a9eb58896f7ea8ee11775bc8 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 09:29:44 +0800 Subject: [PATCH 06/32] refactor(dashboard): rename gold CSS variables to accent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename all gold-related CSS custom properties, Tailwind classes, and keyframe animations from "gold" to "accent" to prepare for the theme system where the accent color is user-configurable. - --color-gold -> --color-accent (plus -dim and -bright variants) - @keyframes goldPulse -> accentPulse - .animate-gold-pulse -> .animate-accent-pulse - All text-gold, bg-gold, border-gold, ring-gold Tailwind classes No behavioral or visual change — pure mechanical rename. Co-Authored-By: Claude Opus 4.6 --- .../packages/dashboard/src/App.tsx | 2 +- .../dashboard/src/components/CodeViewer.tsx | 8 ++++---- .../dashboard/src/components/CustomNode.tsx | 12 +++++------ .../dashboard/src/components/GraphView.tsx | 2 +- .../src/components/KeyboardShortcutsHelp.tsx | 2 +- .../dashboard/src/components/LayerLegend.tsx | 2 +- .../dashboard/src/components/LearnPanel.tsx | 20 +++++++++---------- .../dashboard/src/components/NodeInfo.tsx | 12 +++++------ .../src/components/PersonaSelector.tsx | 2 +- .../src/components/ProjectOverview.tsx | 14 ++++++------- .../dashboard/src/components/SearchBar.tsx | 8 ++++---- .../packages/dashboard/src/index.css | 16 +++++++-------- 12 files changed, 50 insertions(+), 50 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/App.tsx b/understand-anything-plugin/packages/dashboard/src/App.tsx index 58fef79..634461d 100644 --- a/understand-anything-plugin/packages/dashboard/src/App.tsx +++ b/understand-anything-plugin/packages/dashboard/src/App.tsx @@ -200,7 +200,7 @@ function App() {
-

+

Steps

{tourSteps.map((step, i) => ( @@ -61,7 +61,7 @@ export default function LearnPanel() { key={step.order} className="flex items-start gap-2 text-xs bg-elevated rounded-lg px-3 py-2 border border-border-subtle" > - + {i + 1}. {step.title} @@ -86,7 +86,7 @@ export default function LearnPanel() { {/* Header with progress counter and exit */}
-

+

Tour

@@ -104,7 +104,7 @@ export default function LearnPanel() { {/* Progress bar */}
@@ -154,8 +154,8 @@ export default function LearnPanel() { {/* Language lesson */} {step.languageLesson && ( -
-

+
+

Language Lesson

@@ -167,7 +167,7 @@ export default function LearnPanel() { {/* Referenced component pills */} {step.nodeIds.length > 0 && (

-

+

Referenced Components

@@ -198,7 +198,7 @@ export default function LearnPanel() { onClick={() => setTourStep(i)} className={`w-2 h-2 rounded-full transition-colors ${ i === currentTourStep - ? "bg-gold" + ? "bg-accent" : "bg-elevated hover:bg-surface" }`} aria-label={`Go to step ${i + 1}`} @@ -217,7 +217,7 @@ export default function LearnPanel() { diff --git a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx index 78c1041..ad60b1b 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/NodeInfo.tsx @@ -11,7 +11,7 @@ const typeBadgeColors: Record = { const complexityBadgeColors: Record = { simple: "text-node-function border border-node-function/30 bg-node-function/10", - moderate: "text-gold-dim border border-gold-dim/30 bg-gold-dim/10", + moderate: "text-accent-dim border border-accent-dim/30 bg-accent-dim/10", complex: "text-[#c97070] border border-[#c97070]/30 bg-[#c97070]/10", }; @@ -75,7 +75,7 @@ export default function NodeInfo() {
diff --git a/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx b/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx index 8a8f245..112b552 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/SearchBar.tsx @@ -94,14 +94,14 @@ export default function SearchBar() { onChange={handleInputChange} onFocus={() => setDropdownOpen(true)} placeholder="Search nodes by name, summary, or tags..." - className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-gold/50 placeholder-text-muted" + className="flex-1 bg-elevated text-text-primary text-sm rounded-lg px-3 py-1.5 border border-border-subtle focus:outline-none focus:border-accent/50 placeholder-text-muted" />
+ + {open && ( +
+ {/* Presets */} +
+
+ Theme +
+
+ {PRESETS.map((p) => ( + + ))} +
+
+ + {/* Accent swatches */} +
+
+ Accent Color +
+
+ {preset.accentSwatches.map((swatch) => ( +
+
+
+ )} +
+ ); +} From 17330081aba06f2949cea7db8db787e9cf096ce5 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 09:54:11 +0800 Subject: [PATCH 14/32] feat(dashboard): integrate ThemeProvider and ThemePicker into App Co-Authored-By: Claude Opus 4.6 --- .../packages/dashboard/src/App.tsx | 16 ++++++++++++++++ .../dashboard/src/themes/ThemeContext.tsx | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/understand-anything-plugin/packages/dashboard/src/App.tsx b/understand-anything-plugin/packages/dashboard/src/App.tsx index 634461d..78ded8f 100644 --- a/understand-anything-plugin/packages/dashboard/src/App.tsx +++ b/understand-anything-plugin/packages/dashboard/src/App.tsx @@ -15,6 +15,9 @@ import KeyboardShortcutsHelp from "./components/KeyboardShortcutsHelp"; import WarningBanner from "./components/WarningBanner"; import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts"; import type { KeyboardShortcut } from "./hooks/useKeyboardShortcuts"; +import { ThemeProvider } from "./themes/index.ts"; +import { ThemePicker } from "./components/ThemePicker.tsx"; +import type { ThemeConfig } from "./themes/index.ts"; function App() { const graph = useDashboardStore((s) => s.graph); @@ -28,6 +31,16 @@ function App() { const [loadError, setLoadError] = useState(null); const [graphIssues, setGraphIssues] = useState([]); const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); + const [metaTheme, setMetaTheme] = useState(null); + + useEffect(() => { + fetch("/meta.json") + .then((r) => (r.ok ? r.json() : null)) + .then((meta) => { + if (meta?.theme) setMetaTheme(meta.theme); + }) + .catch(() => {}); + }, []); // Define keyboard shortcuts const shortcuts = useMemo( @@ -185,6 +198,7 @@ function App() { ); return ( +
{/* Header */}
@@ -198,6 +212,7 @@ function App() {
+
+ ); } diff --git a/understand-anything-plugin/packages/dashboard/src/themes/ThemeContext.tsx b/understand-anything-plugin/packages/dashboard/src/themes/ThemeContext.tsx index d166e55..dc12fcc 100644 --- a/understand-anything-plugin/packages/dashboard/src/themes/ThemeContext.tsx +++ b/understand-anything-plugin/packages/dashboard/src/themes/ThemeContext.tsx @@ -75,7 +75,7 @@ export function ThemeProvider({ metaTheme, children }: ThemeProviderProps) { }, [metaTheme]); const setPreset = useCallback((presetId: PresetId) => { - setConfig((prev) => { + setConfig((_prev) => { const newPreset = getPreset(presetId); return { presetId, accentId: newPreset.defaultAccentId }; }); From b4d541b312c464ed0b2c3b79a2f10db747503a4e Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 09:56:15 +0800 Subject: [PATCH 15/32] feat(dashboard): add light theme CSS overrides and align @theme defaults Co-Authored-By: Claude Opus 4.6 --- .../packages/dashboard/src/index.css | 67 +++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/index.css b/understand-anything-plugin/packages/dashboard/src/index.css index 321702c..e702b6f 100644 --- a/understand-anything-plugin/packages/dashboard/src/index.css +++ b/understand-anything-plugin/packages/dashboard/src/index.css @@ -1,26 +1,39 @@ @import "tailwindcss"; @theme { - /* Dark luxury color palette */ + /* Base */ --color-root: #0a0a0a; --color-surface: #111111; --color-elevated: #1a1a1a; --color-panel: #141414; - /* Accent spectrum */ + /* Accent */ --color-accent: #d4a574; --color-accent-dim: #c9a96e; --color-accent-bright: #e8c49a; - /* Text hierarchy */ + /* Text */ --color-text-primary: #f5f0eb; --color-text-secondary: #a39787; --color-text-muted: #6b5f53; - /* Border tokens */ + /* Borders */ --color-border-subtle: rgba(212, 165, 116, 0.12); --color-border-medium: rgba(212, 165, 116, 0.25); + /* Node types */ + --color-node-file: #4a7c9b; + --color-node-function: #5a9e6f; + --color-node-class: #8b6fb0; + --color-node-module: #c9a06c; + --color-node-concept: #b07a8a; + + /* Diff */ + --color-diff-changed: #e05252; + --color-diff-affected: #d4a030; + --color-diff-changed-dim: rgba(224, 82, 82, 0.25); + --color-diff-affected-dim: rgba(212, 160, 48, 0.25); + /* Glass */ --glass-bg: rgba(20, 20, 20, 0.8); --glass-bg-heavy: rgba(20, 20, 20, 0.95); @@ -41,33 +54,24 @@ --color-edge-dim: rgba(212, 165, 116, 0.08); --color-edge-dot: rgba(212, 165, 116, 0.15); - /* Layer group (accent-based overlays) */ + /* Accent overlays */ --color-accent-overlay-bg: rgba(212, 165, 116, 0.05); --color-accent-overlay-border: rgba(212, 165, 116, 0.25); - /* kbd */ + /* Kbd */ --kbd-bg: rgba(212, 165, 116, 0.1); - /* Node type colors (muted, refined) */ - --color-node-file: #4a7c9b; - --color-node-function: #5a9e6f; - --color-node-class: #8b6fb0; - --color-node-module: #c9a06c; - --color-node-concept: #b07a8a; - - /* Diff overlay colors */ - --color-diff-changed: #e05252; - --color-diff-affected: #d4a030; - --color-diff-changed-dim: rgba(224, 82, 82, 0.25); - --color-diff-affected-dim: rgba(212, 160, 48, 0.25); - - /* Fonts */ + /* Typography */ --font-serif: 'DM Serif Display', Georgia, serif; --font-mono: 'JetBrains Mono', 'Fira Code', monospace; --font-sans: 'Inter', system-ui, sans-serif; } /* Base styles */ +html { + transition: background-color 0.2s ease, color 0.2s ease; +} + body { font-family: var(--font-sans); background-color: var(--color-root); @@ -207,3 +211,26 @@ body { .react-flow__background { background-color: var(--color-root) !important; } + +/* Light theme overrides */ +[data-theme="light"] { + color-scheme: light; +} + +[data-theme="light"] .diff-faded { + opacity: 0.35; +} + +[data-theme="light"] ::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="light"] .warning-banner { + background: rgba(180, 130, 30, 0.1); + border-color: rgba(180, 130, 30, 0.3); + color: #92600a; +} + +[data-theme="dark"] { + color-scheme: dark; +} From 34cfffc4cc66f50f809c2e3910b3a5dfbefe1e6b Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 10:02:38 +0800 Subject: [PATCH 16/32] fix(dashboard): resolve light theme visual issues from code review - Use CSS variables for diff edge colors in GraphView - Make ReactFlow colorMode respond to theme preset - Replace hardcoded text-white/bg-gray-900 in LearnPanel - Use CSS variables for .kbd border and box-shadow Co-Authored-By: Claude Opus 4.6 --- .../packages/dashboard/src/components/GraphView.tsx | 8 +++++--- .../packages/dashboard/src/components/LearnPanel.tsx | 6 +++--- .../packages/dashboard/src/index.css | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index 3f6db5c..561feff 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -16,6 +16,7 @@ import "@xyflow/react/dist/style.css"; import CustomNode from "./CustomNode"; import type { CustomFlowNode } from "./CustomNode"; import { useDashboardStore } from "../store"; +import { useTheme } from "../themes/index.ts"; import { applyDagreLayout, applyDagreLayoutAsync, NODE_WIDTH, NODE_HEIGHT } from "../utils/layout"; const LAYER_PADDING = 40; @@ -153,8 +154,8 @@ function buildTopologyData( style: isImpacted ? { stroke: sourceInDiff && targetInDiff - ? "rgba(224, 82, 82, 0.7)" - : "rgba(212, 160, 48, 0.5)", + ? "var(--color-diff-changed)" + : "var(--color-diff-affected)", strokeWidth: 2.5, } : diffMode @@ -309,6 +310,7 @@ function GraphViewInner() { const diffMode = useDashboardStore((s) => s.diffMode); const changedNodeIds = useDashboardStore((s) => s.changedNodeIds); const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds); + const { preset } = useTheme(); const [layouting, setLayouting] = useState(false); @@ -453,7 +455,7 @@ function GraphViewInner() { fitViewOptions={{ minZoom: 0.01, padding: 0.1 }} minZoom={0.01} maxZoom={2} - colorMode="dark" + colorMode={preset.isDark ? "dark" : "light"} > diff --git a/understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx b/understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx index 09e5a9c..7e4344b 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/LearnPanel.tsx @@ -122,16 +122,16 @@ export default function LearnPanel() {

{children}

), strong: ({ children }) => ( - {children} + {children} ), code: ({ className, children }) => { const isBlock = className?.includes("language-"); return isBlock ? ( - + {children} ) : ( - + {children} ); diff --git a/understand-anything-plugin/packages/dashboard/src/index.css b/understand-anything-plugin/packages/dashboard/src/index.css index e702b6f..95b5440 100644 --- a/understand-anything-plugin/packages/dashboard/src/index.css +++ b/understand-anything-plugin/packages/dashboard/src/index.css @@ -122,9 +122,9 @@ body { font-weight: 600; color: var(--color-accent); background: var(--kbd-bg); - border: 1px solid rgba(212, 165, 116, 0.3); + border: 1px solid var(--color-border-medium); border-radius: 0.25rem; - box-shadow: 0 1px 0 rgba(212, 165, 116, 0.2); + box-shadow: 0 1px 0 var(--scrollbar-thumb); } /* Animation keyframes */ From 2545ad2f61d3cf4b9eb68165c145bc677f7ecf32 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 10:53:28 +0800 Subject: [PATCH 17/32] docs: add token reduction plan --- .../2026-03-27-token-reduction-design.md | 395 +++++++ docs/plans/2026-03-27-token-reduction-impl.md | 971 ++++++++++++++++++ 2 files changed, 1366 insertions(+) create mode 100644 docs/plans/2026-03-27-token-reduction-design.md create mode 100644 docs/plans/2026-03-27-token-reduction-impl.md diff --git a/docs/plans/2026-03-27-token-reduction-design.md b/docs/plans/2026-03-27-token-reduction-design.md new file mode 100644 index 0000000..8bf8975 --- /dev/null +++ b/docs/plans/2026-03-27-token-reduction-design.md @@ -0,0 +1,395 @@ +# Token Reduction Design + +**Date:** 2026-03-27 +**Status:** Draft +**Goal:** Reduce total token cost of `/understand` by ~85-90% on large codebases (200+ files) + +--- + +## Problem + +For large codebases, the `/understand` pipeline spends the vast majority of its tokens on **repeated context injection**. The same data is sent to every subagent independently, even when that data could be computed once and shared. + +### Token cost breakdown (500-file TypeScript+React project, baseline) + +| Source | Phase | Tokens (input) | % of total | +|---|---|---|---| +| `allProjectFiles` list × 67 batches | Phase 2 | ~167,000 | ~50% | +| `file-analyzer-prompt.md` × 67 batches | Phase 2 | ~134,000 | ~40% | +| Language/framework addendums × 67 batches | Phase 2 | ~68,000 | ~20% | +| Tour builder payload (all nodes + edges) | Phase 5 | ~80,000 | ~24% | +| Graph reviewer (assembled graph + inventory) | Phase 6 | ~58,000 | ~17% | +| Architecture analyzer payload | Phase 4 | ~22,000 | ~7% | +| **Total** | | **~529,000** | | + +The root cause: **Phase 2 runs 67 batches (at 5-10 files each), and every single batch receives the full 500-file list for import resolution.** The file list alone costs ~2,500 tokens × 67 repetitions = 167,000 tokens on input, doing work that is entirely redundant between batches. + +--- + +## Goals + +- Reduce total input tokens by 85%+ on a 500-file project +- No degradation in graph quality for standard projects +- Preserve the `--full` / incremental / scope flags +- Maintain backward compatibility with existing `knowledge-graph.json` output schema + +--- + +## Changes + +Five changes compose the full approach (C1–C5). Each is independent and can be shipped separately, but all five are needed for the full reduction. + +--- + +### C1 — Pre-resolve imports in the project scanner + +**Root cause addressed:** `allProjectFiles` (the entire file list) is injected into every file-analyzer batch solely so each batch's extraction script can resolve relative imports. This is redundant: the full file list is available during Phase 1, and import resolution is deterministic. It should happen once, not 67 times. + +**Change:** Extend the Phase 1 scanner script to also parse import statements from every source file and resolve relative imports against the discovered file list. The resolved results are written into `scan-result.json` as a new `importMap` field. File-analyzer batches then receive only their own batch's pre-resolved imports — not the full file list. + +#### Scanner output addition + +`scan-result.json` gains: + +```json +{ + "importMap": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [], + "src/components/App.tsx": ["src/hooks/useAuth.ts", "src/store/index.ts"] + } +} +``` + +- Keys are project-relative paths (matching `files[*].path`) +- Values are resolved project-relative paths only (external/unresolvable imports are omitted) +- External imports (`node_modules`, unresolvable paths) are excluded from the map entirely + +#### Scanner script additions (Phase 1 Step 8) + +After the existing 7 steps, the scanner script adds a new step: + +``` +Step 8 — Import Resolution + +For each file in the discovered source list: + 1. Read the file content + 2. Extract import statements (language-specific patterns per Step 3's language detection): + - TypeScript/JavaScript: `import ... from '...'`, `require('...')` + - Python: `import ...`, `from ... import ...` + - Go: `import "..."` blocks + - Rust: `use ...` statements + - Java/Kotlin: `import ...` statements + - Ruby: `require`, `require_relative` + 3. For each relative import (starts with `./` or `../`): + a. Compute the resolved path from the current file's directory + b. Normalize to project-relative format + c. Try common extension variants if the import has no extension: + `.ts`, `.tsx`, `.js`, `.jsx`, `/index.ts`, `/index.js`, `/index.tsx` + d. If any variant exists in the discovered file list, record it; otherwise skip + 4. For absolute imports (no `.` prefix): skip (external package) + +Output the full importMap in the JSON result. +``` + +#### File-analyzer input schema change + +**Before:** +```json +{ + "projectRoot": "/path/to/project", + "allProjectFiles": ["src/index.ts", "src/utils.ts", "...500 paths..."], + "batchFiles": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150} + ] +} +``` + +**After:** +```json +{ + "projectRoot": "/path/to/project", + "batchFiles": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150} + ], + "batchImportData": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/components/App.tsx": ["src/hooks/useAuth.ts"] + } +} +``` + +`allProjectFiles` is removed entirely. `batchImportData` contains only the pre-resolved imports for the files in this batch (sliced from `importMap` by the orchestrator). + +#### File-analyzer extraction script change + +The extraction script no longer performs import resolution. It: +- Still extracts: functions, classes, exports, metrics (unchanged) +- For imports: reads `batchImportData[file.path]` from the input JSON — no cross-referencing needed +- The `imports` array in each file result becomes: `batchImportData[file.path]` mapped to import edge objects with `resolvedPath` already populated, `isExternal: false` + +#### SKILL.md Phase 2 change + +Remove the `allProjectFiles` injection from the batch dispatch prompt. Replace with a per-batch `batchImportData` slice: + +``` +For each batch, slice importData from the importMap read in Phase 1: +batchImportData = { [file.path]: importMap[file.path] ?? [] } + for each file in this batch +``` + +#### Token savings estimate + +| | Batches | Tokens/batch | Total | +|---|---|---|---| +| Before | 67 | ~2,500 (file list) | ~167,500 | +| After (C1 alone) | 67 | ~200 (batch importData) | ~13,400 | +| **Savings** | | | **~154,100** | + +--- + +### C2 — Increase batch size from 5-10 to 20-30 files + +**Root cause addressed:** Every batch incurs the full cost of `file-analyzer-prompt.md` (~2,000 tokens) plus the batch dispatch overhead. With 67 batches, this adds up even without `allProjectFiles`. Fewer, larger batches directly reduce this repetition. + +**Change:** In SKILL.md Phase 2, change the batch size guidance: + +- **Before:** "Batch the file list from Phase 1 into groups of **5-10 files each**" +- **After:** "Batch the file list from Phase 1 into groups of **20-30 files each** (aim for ~25 per batch)" + +Also update the concurrency limit from 3 to **5** concurrent batches. Fewer total batches means we can afford more parallelism without overwhelming the system. + +#### Trade-offs + +| | Smaller batches (current) | Larger batches (new) | +|---|---|---| +| Files per batch | 5-10 | 20-30 | +| Total batches (500 files) | ~67 | ~20 | +| Prompt repetition | 67× | 20× | +| Quality risk | Lower (focused) | Slightly higher (more files per subagent) | +| Concurrency | 3 | 5 | + +Quality risk is low: each subagent still operates on distinct, non-overlapping file groups. The extraction script is deterministic regardless of batch size. Semantic analysis (summaries, tags) may be marginally less focused, but the quality difference is negligible in practice for well-structured files. + +#### Token savings estimate (combined with C1) + +| | Batches | Tokens/batch (prompt) | Total | +|---|---|---|---| +| Before (C1 only) | 67 | ~2,000 | ~134,000 | +| After (C1+C2) | 20 | ~2,000 | ~40,000 | +| **Savings from C2** | | | **~94,000** | + +C1+C2 combined eliminate ~248,000 tokens from Phase 2 (down from ~301,500 to ~53,500, a ~82% Phase 2 reduction). + +--- + +### C3 — Remove language/framework addendums from file-analyzer batches + +**Root cause addressed:** `languages/typescript.md` (~600 tokens) and `frameworks/react.md` (~700 tokens) are read and injected into every file-analyzer batch prompt. For a TypeScript+React project with 20 batches (after C2), this costs 20 × 1,300 = 26,000 additional tokens — and the model already has deep knowledge of these languages from training. + +**Change:** Stop injecting addendum files into Phase 2 batch prompts entirely. The addendums remain injected into Phase 4 (architecture analyzer) where there is only **one** subagent call, making the cost acceptable. + +Instead, add a compact "Language and Framework Hints" reference section directly into `file-analyzer-prompt.md`. This section is a distilled, one-time addition (~150 tokens total) that captures the most useful patterns from all addendums in a concise lookup table. + +#### New section in `file-analyzer-prompt.md` (replace addendum injection) + +```markdown +## Language and Framework Quick Reference + +Use these hints to improve tag and edge accuracy. These supplement your training knowledge. + +| Signal | Tag(s) | Note | +|---|---|---| +| File in `hooks/`, exports function starting with `use` | `hook`, `service` | React custom hook | +| File in `contexts/`, exports a Provider | `service`, `state` | React context | +| File in `pages/` or `views/` | `ui`, `routing` | Page-level component | +| File in `store/`, `slices/`, `reducers/` | `state` | State management | +| File in `services/`, `api/` | `service` | Data-fetching / API client | +| `__init__.py` with re-exports | `entry-point`, `barrel` | Python package root | +| `manage.py` at project root | `entry-point` | Django management entry | +| File named `mod.rs` | `barrel` | Rust module barrel | +| File named `main.go` in `cmd/` | `entry-point` | Go binary entry | + +For React: create `depends_on` edges from components to hooks they call. Create `publishes`/`subscribes` edges for Context provider/consumer patterns. +``` + +#### SKILL.md Phase 2 change + +Remove steps 2 and 3 from the "Build the combined prompt template" block: +- **Remove:** Step 2 (Language context injection — read `./languages/.md` per detected language) +- **Remove:** Step 3 (Framework addendum injection — read `./frameworks/.md` per detected framework) +- **Keep:** Step 1 (Read the base template at `./file-analyzer-prompt.md`) + +The addendum injection steps **remain unchanged** in Phase 4 (architecture analyzer), since they run once. + +#### Token savings estimate + +| | Batches | Addendum tokens/batch | Total | +|---|---|---|---| +| Before (after C2) | 20 | ~1,300 (TS+React) | ~26,000 | +| After | 20 | ~150 (inline hints) | ~3,000 | +| **Savings** | | | **~23,000** | + +--- + +### C4 — Slim Phase 4 and Phase 5 payloads + +**Root cause addressed:** Phase 5 (tour builder) receives all nodes (file + function + class) and all edges (imports + contains + calls + exports + ...). For a 500-file project, this can include 1,500+ nodes and 3,000+ edges. Most of this data is not needed for tour design. + +#### Phase 4 (Architecture Analyzer) — minor trim + +Phase 4 already only sends file-type nodes, which is correct. Minor change: explicitly strip `languageNotes` from each node object in the payload (it's not useful for layer assignment and can be verbose). Also strip `name` — it is always derivable as the basename of `filePath`. + +**Before per node:** `{id, name, filePath, summary, tags, complexity, languageNotes?}` +**After per node:** `{id, filePath, summary, tags}` + +Savings: ~15-20% fewer tokens per node, ~3,000–5,000 tokens total for Phase 4. + +#### Phase 5 (Tour Builder) — major trim + +Three changes to what the orchestrator injects into the tour-builder subagent: + +**1. File nodes only (strip function/class nodes)** + +The tour references node IDs for wayfinding. In practice the tour always references `file:` nodes — function and class nodes are visible in the dashboard's NodeInfo sidebar once a file is selected, but the tour itself navigates at the file level. + +- **Before:** all nodes (file + function + class) — for 500 files, maybe 1,500+ nodes +- **After:** file-type nodes only — 500 nodes + +**2. Slim node format** + +The tour builder script only uses node IDs, names, and types for graph computation. Summaries and tags are used in Phase 2 (pedagogical narrative writing). Strip heavy optional fields from the injected payload: + +- **Before per node:** `{id, name, filePath, summary, type, tags, complexity, languageNotes?}` +- **After per node:** `{id, name, filePath, summary, type}` (drop tags, complexity, languageNotes) + +**3. Slim edges (imports + calls only) and slim layers** + +The tour's BFS traversal only traverses `imports` and `calls` edges. `contains`, `exports`, `tested_by`, `depends_on`, and other edge types add no value to the traversal and inflate the payload. + +- **Before edges:** all edge types (~3,000+ edges including all `contains` edges to function/class nodes) +- **After edges:** only `imports` and `calls` edge types (~400–800 edges for typical projects) + +For layers, the tour builder uses layer data only to inform the tour's narrative arc (which layer to introduce first, second, etc.). It does not need the full `nodeIds` arrays — those can be very large. + +- **Before per layer:** `{id, name, description, nodeIds: [...hundreds of IDs]}` +- **After per layer:** `{id, name, description}` (drop nodeIds) + +#### Token savings estimate (Phase 5) + +| Data | Before | After | +|---|---|---| +| Node count | ~1,500 × ~180 chars | ~500 × ~120 chars | +| Node tokens | ~67,500 | ~15,000 | +| Edge count | ~3,000 × ~80 chars | ~600 × ~80 chars | +| Edge tokens | ~60,000 | ~12,000 | +| Layer tokens | ~5,000 | ~500 | +| **Phase 5 total** | **~132,500** | **~27,500** | +| **Savings** | | **~105,000** | + +#### SKILL.md changes + +In **Phase 4** dispatch prompt template, update the file node format: +``` +File nodes: +[list of {id, filePath, summary, tags} for all file-type nodes] +``` + +In **Phase 5** dispatch prompt template, update all three payload specs: +``` +Nodes (file nodes only): +[list of {id, name, filePath, summary, type} for all file-type nodes only — do NOT include function or class nodes] + +Key edges (imports and calls only): +[list of edges where type is "imports" or "calls" only] + +Layers: +[list of {id, name, description} — omit nodeIds] +``` + +--- + +### C5 — Gate the graph-reviewer subagent behind `--review` + +**Root cause addressed:** The graph-reviewer subagent (Phase 6) reads the entire assembled graph (~500 nodes, all edges, layers, tour) and runs a LLM-powered validation. However, its Phase 1 is entirely a deterministic script, and its Phase 2 is a simple threshold decision: if `issues.length === 0`, approve. There is no LLM judgment needed for the happy path. + +**Change:** By default, skip the graph-reviewer subagent. The orchestrator performs inline deterministic validation using a pre-written script. Only when `--review` is explicitly passed in `$ARGUMENTS` does the full LLM reviewer subagent run. + +#### Default path (no `--review`) + +In Phase 6, instead of dispatching the graph-reviewer subagent, the orchestrator: + +1. Writes a compact validation script inline (embedded in SKILL.md, ~50 lines of Node.js): + - Check: every edge source/target references a real node ID + - Check: every file node appears in exactly one layer + - Check: every tour step nodeId exists + - Check: no duplicate node IDs + - Check: required fields present on nodes and edges +2. Runs the script against `assembled-graph.json` +3. If `issues.length === 0`: proceed to Phase 7 (save) +4. If `issues.length > 0`: apply the same automated fixes as before (remove dangling edges, fill defaults), then save + +This is sufficient for standard runs. The LLM reviewer adds value for catching subtle quality issues (generic summaries, orphan nodes, tour step coherence) — but those are nice-to-have, not blocking. + +#### `--review` path + +When `--review` is in `$ARGUMENTS`, the full graph-reviewer subagent runs as it does today. No change to that code path. + +#### Token savings estimate + +| Path | Tokens | +|---|---| +| Current (always runs LLM reviewer) | ~58,000 input + ~500 output | +| Default (inline script, no LLM) | ~0 | +| `--review` (unchanged) | ~58,000 (same as current) | +| **Savings for default runs** | **~58,500** | + +--- + +## Combined savings summary + +| Change | Tokens before | Tokens after | Savings | +|---|---|---|---| +| C1+C2: import map + batch consolidation | ~301,500 | ~53,500 | ~248,000 | +| C3: remove addendums from batches | ~26,000 | ~3,000 | ~23,000 | +| C4: slim Phase 4+5 payloads | ~154,500 | ~33,000 | ~121,500 | +| C5: gate reviewer (default path) | ~58,500 | ~0 | ~58,500 | +| **Total** | **~540,500** | **~89,500** | **~451,000 (~83%)** | + +Estimates are for a 500-file TypeScript+React project. Actual savings scale with project size — a 1,000-file project would see proportionally larger savings from C1+C2 (more batches = more repetition eliminated). + +--- + +## File changes + +| File | Change | +|---|---| +| `skills/understand/project-scanner-prompt.md` | Add Step 8 (import resolution); add `importMap` to output schema | +| `skills/understand/file-analyzer-prompt.md` | Replace `allProjectFiles` with `batchImportData` in input schema; update extraction script to use pre-resolved imports; add compact Language/Framework Quick Reference section; remove addendum injection steps | +| `skills/understand/SKILL.md` | Phase 1: note importMap in scan result; Phase 2: remove addendum injection (steps 2+3), increase batch size 5-10→20-30, increase concurrency 3→5, replace `allProjectFiles` injection with `batchImportData` slice; Phase 4: slim node format in dispatch; Phase 5: file nodes only + slim edges + slim layers in dispatch; Phase 6: conditional reviewer — default inline script, `--review` flag for LLM reviewer | +| `skills/understand/architecture-analyzer-prompt.md` | No change (addendums still injected here) | +| `skills/understand/tour-builder-prompt.md` | Update input schema to reflect file-only nodes, imports+calls-only edges, slim layer format | +| `skills/understand/graph-reviewer-prompt.md` | No change (only used when `--review` flag is passed) | + +--- + +## Risks and mitigations + +| Risk | Likelihood | Mitigation | +|---|---|---| +| Scanner import resolution misses edge cases (complex re-exports, dynamic imports) | Medium | Log unresolved imports; file-analyzer still uses resolved data and creates edges only for confirmed matches. Missed imports = missing edges, which is same behavior as before for unresolvable imports | +| Larger batches (C2) reduce summary quality | Low | Summary quality is driven by the model's analysis of individual files. Batch size mainly affects how many files share one subagent's context window, not per-file quality. 20-30 files remains well within context limits | +| Stripping function/class nodes from tour (C4) breaks existing tour steps | None | Tour steps reference `file:` node IDs. No existing tour data references function/class nodes at the step level | +| Removing reviewer by default (C5) misses graph errors | Low | The inline deterministic script catches all critical structural issues (dangling refs, missing layers, duplicate IDs). The LLM reviewer's additional value is quality warnings (orphan nodes, generic summaries), which are non-blocking | +| Import map generation slows down Phase 1 | Low | The scanner script already reads all files for line counting. Import parsing adds one regex pass per file — negligible overhead | + +--- + +## Phased rollout recommendation + +Given the risk profile, implement in this order: + +1. **C5 first** — gate the reviewer, lowest risk, immediate 58K token savings per run +2. **C4** — slim Phase 5 payload, no scanner changes, no quality risk +3. **C3** — remove addendums from batches, add inline hints +4. **C1+C2 together** — scanner changes and batch consolidation, test thoroughly on small/medium/large projects before releasing diff --git a/docs/plans/2026-03-27-token-reduction-impl.md b/docs/plans/2026-03-27-token-reduction-impl.md new file mode 100644 index 0000000..08d88ed --- /dev/null +++ b/docs/plans/2026-03-27-token-reduction-impl.md @@ -0,0 +1,971 @@ +# Token Reduction Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Reduce `/understand` token cost by ~85% on large codebases through import pre-resolution, batch consolidation, addendum removal, payload slimming, and gating the LLM reviewer. + +**Architecture:** Five changes (C5 → C4 → C3 → C1+C2) applied in rollout order — lowest risk first. All changes are to prompt/skill markdown files in `understand-anything-plugin/skills/understand/`. No TypeScript source changes required. + +**Tech Stack:** Markdown skill files, Node.js inline scripts embedded in SKILL.md, knowledge-graph JSON pipeline. + +**Design doc:** `docs/plans/2026-03-27-token-reduction-design.md` + +--- + +## Task 1: C5 — Gate graph-reviewer behind `--review` flag + +Replaces the always-on LLM graph-reviewer subagent with a deterministic inline validation script. The LLM reviewer only runs when `--review` is in `$ARGUMENTS`. Saves ~58,500 tokens per default run. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 6, lines 330–362) + +### Step 1: Open SKILL.md and locate Phase 6 + +Read the file and find "## Phase 6 — REVIEW" (line 297). Identify steps 3–6 (lines 330–362) which currently always dispatch the LLM graph-reviewer subagent. + +### Step 2: Replace Phase 6 steps 3–6 with conditional reviewer logic + +Replace lines 330–362 (from "3. Dispatch a subagent using the prompt template" through "6. **If `approved: true`:** Proceed to Phase 7.") with: + +```markdown +3. **Check `$ARGUMENTS` for `--review` flag.** Then run the appropriate validation path: + +--- + +#### Default path (no `--review`): inline deterministic validation + +Write the following Node.js script to `$PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.js`: + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const graphPath = process.argv[2]; +const outputPath = process.argv[3]; +try { + const graph = JSON.parse(fs.readFileSync(graphPath, 'utf8')); + const issues = [], warnings = []; + const nodeIds = new Set(); + const seen = new Map(); + graph.nodes.forEach((n, i) => { + if (!n.id) { issues.push(`Node[${i}] missing id`); return; } + if (!n.type) issues.push(`Node[${i}] '${n.id}' missing type`); + if (!n.name) issues.push(`Node[${i}] '${n.id}' missing name`); + if (!n.summary) issues.push(`Node[${i}] '${n.id}' missing summary`); + if (!n.tags || !n.tags.length) issues.push(`Node[${i}] '${n.id}' missing tags`); + if (seen.has(n.id)) issues.push(`Duplicate node ID '${n.id}' at indices ${seen.get(n.id)} and ${i}`); + else seen.set(n.id, i); + nodeIds.add(n.id); + }); + graph.edges.forEach((e, i) => { + if (!nodeIds.has(e.source)) issues.push(`Edge[${i}] source '${e.source}' not found`); + if (!nodeIds.has(e.target)) issues.push(`Edge[${i}] target '${e.target}' not found`); + }); + const fileNodes = graph.nodes.filter(n => n.type === 'file').map(n => n.id); + const assigned = new Map(); + (graph.layers || []).forEach(layer => { + (layer.nodeIds || []).forEach(id => { + if (!nodeIds.has(id)) issues.push(`Layer '${layer.id}' refs missing node '${id}'`); + if (assigned.has(id)) issues.push(`Node '${id}' appears in multiple layers`); + assigned.set(id, layer.id); + }); + }); + fileNodes.forEach(id => { + if (!assigned.has(id)) issues.push(`File node '${id}' not in any layer`); + }); + (graph.tour || []).forEach((step, i) => { + (step.nodeIds || []).forEach(id => { + if (!nodeIds.has(id)) issues.push(`Tour step[${i}] refs missing node '${id}'`); + }); + }); + const withEdges = new Set([ + ...graph.edges.map(e => e.source), + ...graph.edges.map(e => e.target) + ]); + graph.nodes.forEach(n => { + if (!withEdges.has(n.id)) warnings.push(`Node '${n.id}' has no edges (orphan)`); + }); + const stats = { + totalNodes: graph.nodes.length, + totalEdges: graph.edges.length, + totalLayers: (graph.layers || []).length, + tourSteps: (graph.tour || []).length, + nodeTypes: graph.nodes.reduce((a, n) => { a[n.type] = (a[n.type]||0)+1; return a; }, {}), + edgeTypes: graph.edges.reduce((a, e) => { a[e.type] = (a[e.type]||0)+1; return a; }, {}) + }; + fs.writeFileSync(outputPath, JSON.stringify({ issues, warnings, stats }, null, 2)); + process.exit(0); +} catch (err) { process.stderr.write(err.message + '\n'); process.exit(1); } +``` + +Execute it: +```bash +node $PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.js \ + "$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json" \ + "$PROJECT_ROOT/.understand-anything/intermediate/review.json" +``` + +If the script exits non-zero, read stderr, fix the script, and retry once. + +--- + +#### `--review` path: full LLM reviewer + +If `--review` IS in `$ARGUMENTS`, dispatch the LLM graph-reviewer subagent as follows: + +Dispatch a subagent using the prompt template at `./graph-reviewer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending the following additional context: + +> **Additional context from main session:** +> +> Phase 1 scan results (file inventory): +> ```json +> [list of {path, sizeLines} from scan-result.json] +> ``` +> +> Phase warnings/errors accumulated during analysis: +> - [list any batch failures, skipped files, or warnings from Phases 2-5] +> +> Cross-validate: every file in the scan inventory should have a corresponding `file:` node in the graph. Flag any missing files. Also flag any graph nodes whose `filePath` doesn't appear in the scan inventory. + +Pass these parameters in the dispatch prompt: + +> Validate the knowledge graph at `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`. +> Project root: `$PROJECT_ROOT` +> Read the file and validate it for completeness and correctness. +> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/review.json` + +--- + +4. Read `$PROJECT_ROOT/.understand-anything/intermediate/review.json`. + +5. **If `issues` array is non-empty:** + - Review the `issues` list + - Apply automated fixes where possible: + - Remove edges with dangling references + - Fill missing required fields with sensible defaults (e.g., empty `tags` -> `["untagged"]`, empty `summary` -> `"No summary available"`) + - Remove nodes with invalid types + - Re-run the final graph validation after automated fixes + - If critical issues remain after one fix attempt, save the graph anyway but include the warnings in the final report and mark dashboard auto-launch as skipped + +6. **If `issues` array is empty:** Proceed to Phase 7. +``` + +### Step 3: Verify the edit + +Re-read SKILL.md lines 297–380 and confirm: +- Phase 6 step 3 now checks for `--review` flag +- The inline validation script is present and complete +- The `--review` path still dispatches the LLM subagent identically to before +- Steps 4–6 handle the `review.json` output the same way as before + +### Step 4: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md +git commit -m "perf(understand): gate LLM graph-reviewer behind --review flag, add inline deterministic validation" +``` + +--- + +## Task 2: C4a — Slim Phase 4 (architecture) node payload + +Removes `name` and `languageNotes` from the file node format injected into the architecture-analyzer subagent. These fields are not needed for architectural layer assignment and add unnecessary tokens. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 4, around line 188–196) + +### Step 1: Locate the Phase 4 dispatch prompt in SKILL.md + +Find the block starting "Pass these parameters in the dispatch prompt:" under Phase 4 (around line 181). Look for: + +``` +> File nodes: +> ```json +> [list of {id, name, filePath, summary, tags} for all file-type nodes] +> ``` +``` + +### Step 2: Update the file node format + +Change the file nodes line from: +``` +> [list of {id, name, filePath, summary, tags} for all file-type nodes] +``` + +To: +``` +> [list of {id, filePath, summary, tags} for all file-type nodes — omit name, complexity, languageNotes] +``` + +### Step 3: Verify + +Re-read Phase 4 and confirm the node format line is updated. Import edges line below it (`[list of edges with type "imports"]`) is unchanged. + +### Step 4: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md +git commit -m "perf(understand): slim Phase 4 architecture payload — drop redundant node fields" +``` + +--- + +## Task 3: C4b — Slim Phase 5 (tour builder) payload + +Phase 5 currently injects all nodes (including function/class), all edge types, and full layer objects (with nodeIds arrays). Only file nodes, import+calls edges, and slim layers are needed for tour design. This is the largest single payload change, saving ~105,000 tokens on a 500-file project. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 5, lines 257–270) +- Modify: `understand-anything-plugin/skills/understand/tour-builder-prompt.md` (input schema) + +### Step 1: Locate the Phase 5 dispatch prompt in SKILL.md + +Find the block starting with (around line 257): +``` +> Nodes (summarized): +> ```json +> [list of {id, name, filePath, summary, type} for key nodes] +> ``` +> +> Layers: +> ```json +> [layers from Phase 4] +> ``` +> +> Key edges: +> ```json +> [imports and calls edges] +> ``` +``` + +### Step 2: Replace all three payload sections + +Replace those lines with: + +```markdown +> Nodes (file nodes only): +> ```json +> [list of {id, name, filePath, summary, type} for file-type nodes ONLY — do NOT include function or class nodes] +> ``` +> +> Layers: +> ```json +> [list of {id, name, description} for each layer — omit nodeIds] +> ``` +> +> Edges (imports and calls only): +> ```json +> [list of edges where type is "imports" or "calls" only — exclude all other edge types] +> ``` +``` + +### Step 3: Update tour-builder-prompt.md input schema + +Open `tour-builder-prompt.md` and find the "Script Requirements" section (around line 18–35). The input schema currently shows: +```json +{ + "nodes": [...], + "edges": [...], + "layers": [ + {"id": "layer:core", "name": "Core", "nodeIds": ["file:src/index.ts"]} + ] +} +``` + +Update the layers example to reflect the slim format: +```json +{ + "nodes": [ + {"id": "file:src/index.ts", "type": "file", "name": "index.ts", "filePath": "src/index.ts", "summary": "..."} + ], + "edges": [ + {"source": "file:src/index.ts", "target": "file:src/utils.ts", "type": "imports"} + ], + "layers": [ + {"id": "layer:core", "name": "Core", "description": "Core application logic"} + ] +} +``` + +Also update the "G. Node Summary Index" description (around line 84) to reflect that input nodes are file-type only: + +Find: +``` +**G. Node Summary Index** + +Create a lookup of each node ID to its `summary`, `type`, `tags` (default to empty array `[]` if not present in input), and `name` for easy reference. +``` + +Add a note after it: +``` +Note: input nodes are file-type only. The nodeSummaryIndex will contain only file nodes. +``` + +### Step 4: Verify + +- Re-read SKILL.md Phase 5 payload block: confirms file-only nodes, slim layers (no nodeIds), imports+calls edges only +- Re-read tour-builder-prompt.md input schema: layers no longer have nodeIds + +### Step 5: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md \ + understand-anything-plugin/skills/understand/tour-builder-prompt.md +git commit -m "perf(understand): slim Phase 5 tour payload — file nodes only, imports+calls edges, slim layers" +``` + +--- + +## Task 4: C3 — Remove language/framework addendums from file-analyzer batches + +The addendums (`languages/typescript.md`, `frameworks/react.md`, etc.) are currently injected into every file-analyzer batch prompt. They cost ~1,300 tokens × N batches. The model already knows these languages. Replace with a compact inline reference table (~150 tokens, paid once, embedded in the base template). + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 2, lines 104–117) +- Modify: `understand-anything-plugin/skills/understand/file-analyzer-prompt.md` (add quick reference section) + +### Step 1: Update the "Build the combined prompt template" block in SKILL.md Phase 2 + +Find the block at lines 104–117: +``` +**Build the combined prompt template:** +1. Read the base template at `./file-analyzer-prompt.md`. +2. **Language context injection:** ... +3. **Framework addendum injection:** ... + +Then for each batch pass the combined template content as the subagent's prompt, appending the following additional context: + +> **Additional context from main session:** +> +> Project: `` — `` +> Frameworks detected: `` +> Languages: `` +> +> Use the language context and framework addendums (appended above) to produce more accurate summaries and better classify file roles. +``` + +Replace it with: +```markdown +**Build the prompt for each batch:** +1. Read the base template at `./file-analyzer-prompt.md`. (Language and framework hints are embedded in the template — do NOT append addendum files for Phase 2 batches. Addendums are reserved for Phase 4.) + +Then for each batch pass the template content as the subagent's prompt, appending the following additional context: + +> **Additional context from main session:** +> +> Project: `` — `` +> Languages: `` +``` + +This removes steps 2 and 3 (the addendum injection loops) entirely from Phase 2. + +### Step 2: Add Language and Framework Quick Reference to file-analyzer-prompt.md + +Open `file-analyzer-prompt.md`. Find the "## Critical Constraints" section near the bottom (around line 299). Insert the following new section **before** "## Critical Constraints": + +```markdown +## Language and Framework Quick Reference + +Use these hints to improve tag and edge accuracy for common patterns. Your training knowledge covers these — this is a fast lookup for the most impactful signals. + +**Tag signals:** + +| Signal | Tags to apply | +|---|---| +| File in `hooks/`, exports a function starting with `use` | `hook`, `service` | +| File in `contexts/` or `context/`, exports a Provider component | `service`, `state` | +| File in `pages/` or `views/` | `ui`, `routing` | +| File in `store/`, `slices/`, `reducers/`, `state/` | `state` | +| File in `services/`, `api/`, `client/` | `service` | +| `__init__.py` at a package root with re-exports | `entry-point`, `barrel` | +| `manage.py` at the project root | `entry-point` | +| `mod.rs` in a directory | `barrel` | +| `main.go` in a `cmd/` subdirectory | `entry-point` | + +**Edge signals:** + +| Pattern | Edge to create | +|---|---| +| React component renders another component in its JSX | `contains` from parent to child | +| Component/hook calls a custom hook (`useX`) | `depends_on` from consumer to hook file | +| Context provider wraps components | `publishes` from provider to context definition | +| Component calls `useContext` or custom context hook | `subscribes` from consumer to context definition | +| Python file uses `from x import y` where x is a project file | `imports` edge (same rule as JS/TS) | +| Go file `import`s an internal package path | `imports` edge to the resolved file | + +``` + +### Step 3: Verify + +- Re-read SKILL.md Phase 2 "Build the prompt" block: steps 2 and 3 (addendum loops) are gone; "Frameworks detected" line in additional context is gone +- Re-read file-analyzer-prompt.md: new "Language and Framework Quick Reference" section appears before Critical Constraints; no reference to addendum files +- Confirm Phase 4 "Build the combined prompt template" (lines 163–167) is **unchanged** — addendums still apply there + +### Step 4: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md \ + understand-anything-plugin/skills/understand/file-analyzer-prompt.md +git commit -m "perf(understand): remove addendum injection from Phase 2 batches, add compact inline hints to file-analyzer" +``` + +--- + +## Task 5: C1a — Extend scanner to pre-resolve imports + +Adds a new Step 8 to the project scanner script: parse import statements from every source file and resolve relative imports against the discovered file list. The resolved map is written into `scan-result.json` as `importMap`. This is the data that lets us eliminate `allProjectFiles` from every batch in Task 7. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/project-scanner-prompt.md` + +### Step 1: Add Step 8 to the scanner script requirements + +Open `project-scanner-prompt.md`. Find "**Step 7 -- Project Name**" (around line 100). After its content (the priority list), add a new step: + +```markdown +**Step 8 -- Import Resolution** + +For each file in the discovered source list, extract and resolve relative import statements. The goal is to produce a map from each file's path to the list of project-internal files it imports. External package imports are ignored. + +For each file, read its content and extract import paths using language-appropriate patterns: + +| Language | Import patterns to match | +|---|---| +| TypeScript/JavaScript | `import ... from './...'` or `'../'`, `require('./...')` or `require('../...')` | +| Python | `from .x import y`, `from ..x import y`, `import .x` (relative only) | +| Go | Paths in `import (...)` blocks that start with the module path from `go.mod` | +| Rust | `use crate::`, `use super::`, `mod x` (within the same crate) | +| Java/Kotlin | Not resolvable by path — skip import resolution for these languages | +| Ruby | `require_relative '...'` paths | + +For each extracted import path: +1. Compute the resolved file path relative to project root: + - For relative imports (`./x`, `../x`): resolve from the importing file's directory + - Try these extension variants in order if the import has no extension: `.ts`, `.tsx`, `.js`, `.jsx`, `/index.ts`, `/index.js`, `/index.tsx`, `/index.js`, `.py`, `.go`, `.rs`, `.rb` +2. Check if the resolved path exists in the discovered file list +3. If yes: add to this file's resolved imports list +4. If no: skip (external, unresolvable, or dynamic import) + +Output format in the script result: +```json +"importMap": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [], + "src/components/App.tsx": ["src/hooks/useAuth.ts", "src/store/index.ts"] +} +``` + +Keys are project-relative paths. Values are arrays of resolved project-relative paths. Every key in the file list must appear in `importMap` (use an empty array `[]` if no imports were resolved). External packages and unresolvable imports are omitted entirely. +``` + +### Step 2: Update the scanner script output format + +Find the "### Script Output Format" section (around line 109) and update the example JSON to include `importMap`: + +Find this in the example: +```json +{ + "scriptCompleted": true, + "name": "project-name", + ... + "estimatedComplexity": "moderate" +} +``` + +Add `importMap` to the example: +```json +{ + "scriptCompleted": true, + "name": "project-name", + "rawDescription": "...", + "readmeHead": "...", + "languages": ["javascript", "typescript"], + "frameworks": ["React", "Vite"], + "files": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150} + ], + "totalFiles": 42, + "estimatedComplexity": "moderate", + "importMap": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [] + } +} +``` + +Also update the field documentation list below the example to add: +``` +- `importMap` (object) — map from every source file path to its list of resolved project-internal import paths; empty array if no resolved imports; external packages excluded +``` + +### Step 3: Update the final assembly section to preserve importMap + +Find "## Phase 2 -- Description and Final Assembly" (around line 153). Find the IMPORTANT note: +``` +**IMPORTANT:** The final output must NOT contain the `scriptCompleted`, `rawDescription`, or `readmeHead` fields. +``` + +Update it to: +``` +**IMPORTANT:** The final output must NOT contain the `scriptCompleted`, `rawDescription`, or `readmeHead` fields. All other fields — including `importMap` — MUST be preserved exactly as output by the script. +``` + +Also update the final output example to include `importMap`: +```json +{ + "name": "project-name", + "description": "...", + "languages": ["typescript"], + "frameworks": ["React"], + "files": [...], + "totalFiles": 42, + "estimatedComplexity": "moderate", + "importMap": { + "src/index.ts": ["src/utils.ts"] + } +} +``` + +### Step 4: Verify + +Re-read `project-scanner-prompt.md` and confirm: +- Step 8 is present with full import resolution logic +- Script output format includes `importMap` +- Field documentation includes `importMap` +- Final assembly section preserves `importMap` in output + +### Step 5: Commit + +```bash +git add understand-anything-plugin/skills/understand/project-scanner-prompt.md +git commit -m "perf(understand): extend scanner to pre-resolve imports, output importMap in scan-result.json" +``` + +--- + +## Task 6: C1b — Update file-analyzer to use batchImportData + +Removes `allProjectFiles` from the file-analyzer input schema and replaces it with `batchImportData` (pre-resolved imports for this batch's files only). Updates the extraction script section to skip import resolution entirely (already done by scanner). Updates the edge creation step to use `batchImportData` directly. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/file-analyzer-prompt.md` + +### Step 1: Update the input JSON schema (Script Requirements, step 1) + +Find the input schema block around line 19: +```json +{ + "projectRoot": "/path/to/project", + "allProjectFiles": ["src/index.ts", "src/utils.ts", "..."], + "batchFiles": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150}, + {"path": "src/utils.ts", "language": "typescript", "sizeLines": 80} + ] +} +``` + +Replace with: +```json +{ + "projectRoot": "/path/to/project", + "batchFiles": [ + {"path": "src/index.ts", "language": "typescript", "sizeLines": 150}, + {"path": "src/utils.ts", "language": "typescript", "sizeLines": 80} + ], + "batchImportData": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [] + } +} +``` + +Update the field descriptions: +- Remove: `allProjectFiles` description +- Add: `batchImportData` (object) — map from each batch file's project-relative path to its list of pre-resolved project-internal imports. Produced by the project scanner. Use this directly for import edge creation — do NOT attempt to re-resolve imports yourself. + +### Step 2: Remove the imports extraction from "What the Script Must Extract" + +Find the "**Imports:**" subsection under "What the Script Must Extract" (around lines 49–53): +``` +**Imports:** +- Source module path (exactly as written in the import statement) +- Imported specifiers (named imports, default import, namespace import) +- Line number +- For relative imports (starting with `./` or `../`), compute the resolved path... +``` + +Replace this entire subsection with: +```markdown +**Imports:** +- Do NOT extract imports in the script. Import resolution has already been performed by the project scanner. +- The pre-resolved imports for each file are provided in `batchImportData` in the input JSON. +- Do not include an `imports` field in the script output — import edges will be created in Phase 2 using `batchImportData` directly. +``` + +### Step 3: Update the script output format to remove imports + +Find the `results` array in the script output format (around line 67). The current `imports` array in the output: +```json +"imports": [ + {"source": "./utils", "resolvedPath": "src/utils.ts", "specifiers": ["formatDate"], "line": 1, "isExternal": false}, + {"source": "express", "resolvedPath": null, "specifiers": ["default"], "line": 2, "isExternal": true} +], +``` + +Remove the `imports` array from the script output format entirely. The result for each file should be: +```json +{ + "path": "src/index.ts", + "language": "typescript", + "totalLines": 150, + "nonEmptyLines": 120, + "functions": [...], + "classes": [...], + "exports": [...], + "metrics": { + "importCount": 5, + "exportCount": 3, + "functionCount": 4, + "classCount": 1 + } +} +``` + +Keep `metrics.importCount` (derived from `batchImportData[path].length`) as a useful metric. + +Update the metrics description to say: +``` +- `importCount` (integer) — use `batchImportData[file.path].length` from the input JSON +``` + +### Step 4: Update "Preparing the Script Input" section + +Find the `cat` command around line 113 that creates the input JSON: +```bash +cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json << 'ENDJSON' +{ + "projectRoot": "", + "allProjectFiles": [], + "batchFiles": [] +} +ENDJSON +``` + +Replace with: +```bash +cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json << 'ENDJSON' +{ + "projectRoot": "", + "batchFiles": [], + "batchImportData": +} +ENDJSON +``` + +### Step 5: Update Step 3 (Create Edges) — Import edge creation rule + +Find the "**Import edge creation rule:**" in the "Step 3 -- Create Edges" section (around line 213): +``` +**Import edge creation rule:** For each import in the script output where `isExternal` is `false` and `resolvedPath` is non-null, create an `imports` edge from the current file node to `file:`. Do NOT create edges for external package imports. +``` + +Replace with: +```markdown +**Import edge creation rule:** For each resolved path in `batchImportData[filePath]` (provided in the input JSON), create an `imports` edge from the current file node to `file:`. The `batchImportData` values contain only resolved project-internal paths — external packages have already been filtered out. Do NOT attempt to re-resolve imports from source. +``` + +### Step 6: Remove `allProjectFiles` references from Critical Constraints + +Find the last bullet in "## Critical Constraints" (around line 304): +``` +- For import edges, use the script's `resolvedPath` field directly. Do NOT attempt to resolve import paths yourself -- the script already did this deterministically. +``` + +Replace with: +```markdown +- For import edges, use `batchImportData[filePath]` directly from the input JSON. Do NOT attempt to resolve import paths yourself -- the project scanner already did this deterministically. +``` + +### Step 7: Verify + +Re-read `file-analyzer-prompt.md` and confirm: +- Input schema has `batchImportData`, no `allProjectFiles` +- Script "What to Extract" section: imports extraction replaced with "do not extract" +- Script output format: no `imports` array per file +- Preparing the Script Input: cat command has no `allProjectFiles` +- Import edge creation rule: uses `batchImportData` not script output +- Critical Constraints: no reference to `resolvedPath` from script + +### Step 8: Commit + +```bash +git add understand-anything-plugin/skills/understand/file-analyzer-prompt.md +git commit -m "perf(understand): replace allProjectFiles with batchImportData in file-analyzer — import resolution now done by scanner" +``` + +--- + +## Task 7: C1c + C2 — Update SKILL.md Phase 2 orchestration + +Wires up the `importMap` from Phase 1 into per-batch `batchImportData` slices. Increases batch size from 5-10 to 20-30 files. Increases concurrency from 3 to 5. Removes `allProjectFiles` from the dispatch prompt. + +**Files:** +- Modify: `understand-anything-plugin/skills/understand/SKILL.md` (Phase 0, Phase 1, Phase 2) + +### Step 1: Update Phase 1 to note importMap is now in scan-result.json + +Find Phase 1 (around line 62) where it says: +``` +After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/scan-result.json` to get: +- Project name, description +- Languages, frameworks +- File list with line counts +- Complexity estimate +``` + +Add one item to the list: +``` +- Import map (`importMap`): pre-resolved project-internal imports per file +``` + +Also add a note: +``` +Store `importMap` in memory as `$IMPORT_MAP` for use in Phase 2 batch construction. +``` + +### Step 2: Change batch size and concurrency in Phase 2 + +Find line 100: +``` +Batch the file list from Phase 1 into groups of **5-10 files each** (aim for balanced batch sizes). +``` + +Replace with: +``` +Batch the file list from Phase 1 into groups of **20-30 files each** (aim for ~25 files per batch for balanced sizes). +``` + +Find line 102: +``` +For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **3 subagents concurrently** using parallel dispatch. +``` + +Replace with: +``` +For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **5 subagents concurrently** using parallel dispatch. +``` + +### Step 3: Add batchImportData construction to the dispatch block + +Find the dispatch prompt block (around lines 119–134): +``` +Fill in batch-specific parameters below and dispatch: + +> Analyze these source files and produce GraphNode and GraphEdge objects. +> Project root: `$PROJECT_ROOT` +> Project: `` +> Languages: `` +> Batch index: `` +> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-.json` +> +> All project files (for import resolution): +> `` +> +> Files to analyze in this batch: +> 1. `` ( lines) +> ... +``` + +Replace with: +```markdown +Before dispatching each batch, construct `batchImportData` from `$IMPORT_MAP`: +```json +batchImportData = {} +for each file in this batch: + batchImportData[file.path] = $IMPORT_MAP[file.path] ?? [] +``` + +Fill in batch-specific parameters below and dispatch: + +> Analyze these source files and produce GraphNode and GraphEdge objects. +> Project root: `$PROJECT_ROOT` +> Project: `` +> Languages: `` +> Batch index: `` +> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-.json` +> +> Pre-resolved import data for this batch (use this for all import edge creation — do NOT re-resolve imports from source): +> ```json +> +> ``` +> +> Files to analyze in this batch: +> 1. `` ( lines) +> 2. `` ( lines) +> ... +``` + +### Step 4: Update incremental update path + +Find "### Incremental update path" (around line 140): +``` +Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above, but only for changed files. +``` + +Update to clarify that batchImportData still applies: +``` +Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above (20-30 files per batch, up to 5 concurrent, with batchImportData constructed from $IMPORT_MAP), but only for changed files. +``` + +### Step 5: Verify all Phase 2 changes + +Re-read SKILL.md Phase 2 in full and confirm: +- Batch size says "20-30 files" +- Concurrency says "5 subagents concurrently" +- "Build the prompt" block: only step 1 (read base template), no addendum steps +- Additional context block: no "Frameworks detected" line, no addendum reference +- Dispatch prompt: has `batchImportData` injection, no `allProjectFiles` +- Incremental path: mentions batchImportData + +### Step 6: Commit + +```bash +git add understand-anything-plugin/skills/understand/SKILL.md +git commit -m "perf(understand): wire importMap into batchImportData per batch, increase batch size 5-10→20-30, concurrency 3→5" +``` + +--- + +## Task 8: Version bump + +Per project convention, all four version files must stay in sync when changes are pushed. + +**Files:** +- Modify: `understand-anything-plugin/package.json` +- Modify: `.claude-plugin/marketplace.json` +- Modify: `.claude-plugin/plugin.json` +- Modify: `.cursor-plugin/plugin.json` + +### Step 1: Read current version + +```bash +node -e "const p = require('./understand-anything-plugin/package.json'); console.log(p.version)" +``` + +Expected: `1.0.1` (or whatever the current version is). + +### Step 2: Bump patch version in all four files + +New version: `1.0.2` (patch bump — internal optimization, no API changes). + +Update each file: +- `understand-anything-plugin/package.json`: `"version": "1.0.2"` +- `.claude-plugin/marketplace.json`: `"version": "1.0.2"` in `plugins[0]` +- `.claude-plugin/plugin.json`: `"version": "1.0.2"` +- `.cursor-plugin/plugin.json`: `"version": "1.0.2"` + +### Step 3: Verify all four files match + +```bash +grep -r '"version"' understand-anything-plugin/package.json .claude-plugin/marketplace.json .claude-plugin/plugin.json .cursor-plugin/plugin.json +``` + +All four should show `"version": "1.0.2"`. + +### Step 4: Commit + +```bash +git add understand-anything-plugin/package.json \ + .claude-plugin/marketplace.json \ + .claude-plugin/plugin.json \ + .cursor-plugin/plugin.json +git commit -m "chore: bump version to 1.0.2" +``` + +--- + +## Task 9: Build and smoke test + +Verifies all changes work end-to-end by running `/understand --full` against a real project. + +**Files:** None (testing only) + +### Step 1: Build the packages + +```bash +pnpm --filter @understand-anything/core build +pnpm --filter @understand-anything/skill build +``` + +Expected: both build without errors. + +### Step 2: Find installed plugin version and copy to cache + +```bash +ls ~/.claude/plugins/cache/understand-anything/understand-anything/ +``` + +Note the version (e.g., `1.0.1`). Copy local build into the cache: + +```bash +VERSION=$(node -e "const p = require('./understand-anything-plugin/package.json'); console.log(p.version)") +rm -rf ~/.claude/plugins/cache/understand-anything/understand-anything/$VERSION +cp -R ./understand-anything-plugin ~/.claude/plugins/cache/understand-anything/understand-anything/$VERSION +``` + +### Step 3: Smoke test on a small project (~20 files) + +Open a fresh Claude Code session in a small TypeScript project. Run: +``` +/understand --full +``` + +Verify: +- Phases 0–7 complete without errors +- `knowledge-graph.json` is created +- Node count and edge count are reasonable +- Layers and tour are present +- No "allProjectFiles" or addendum errors in the output + +### Step 4: Smoke test on a larger project (~100+ files) + +Run `/understand --full` on a medium/large TypeScript+React project. + +Verify: +- Batch count is ~4-6 (at 20-30 files per batch for 100 files), not 10-20 +- No errors about missing import resolution +- `importMap` is present in `scan-result.json` (check `.understand-anything/intermediate/` before cleanup, or add a temporary debug log) +- Graph quality is comparable to before (summaries are descriptive, layers are correct) + +### Step 5: Test `--review` flag + +Run `/understand --full --review` on the same project. + +Verify: +- Phase 6 now dispatches the LLM graph-reviewer subagent (not the inline script) +- `review.json` is produced with `approved` field +- Pipeline completes normally + +### Step 6: Final commit (if any fixes needed from smoke test) + +```bash +git add -A +git commit -m "fix(understand): smoke test fixes for token reduction changes" +``` + +--- + +## Summary + +| Task | Change | Risk | +|---|---|---| +| 1 | C5: Gate reviewer | Low | +| 2 | C4a: Slim Phase 4 payload | Low | +| 3 | C4b: Slim Phase 5 payload | Low | +| 4 | C3: Remove addendums from batches | Low | +| 5 | C1a: Scanner import resolution | Medium | +| 6 | C1b: File-analyzer uses batchImportData | Medium | +| 7 | C1c+C2: SKILL.md orchestration + batch size | Medium | +| 8 | Version bump | Low | +| 9 | Smoke test | — | + +Tasks 1–4 are independent of Tasks 5–7. They can be shipped separately if needed. Tasks 5, 6, and 7 are tightly coupled (scanner produces importMap → SKILL.md passes batchImportData → file-analyzer consumes it) and must be shipped together. From 2d86d339c048e2849606800fb1fe6e07172e0ae1 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 14:34:42 +0800 Subject: [PATCH 18/32] perf(understand): gate LLM graph-reviewer behind --review flag, add inline deterministic validation Co-Authored-By: Claude Opus 4.6 --- .../skills/understand/SKILL.md | 107 ++++++++++++++++-- 1 file changed, 98 insertions(+), 9 deletions(-) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 71f188f..13cabcd 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -12,6 +12,7 @@ Analyze the current codebase and produce a `knowledge-graph.json` file in `.unde - `$ARGUMENTS` may contain: - `--full` — Force a full rebuild, ignoring any existing graph + - `--review` — Run full LLM graph-reviewer instead of inline deterministic validation - A directory path — Scope analysis to a specific subdirectory --- @@ -327,7 +328,93 @@ Assemble the full KnowledgeGraph JSON object: 2. Write the assembled graph to `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`. -3. Dispatch a subagent using the prompt template at `./graph-reviewer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending the following additional context: +3. **Check `$ARGUMENTS` for `--review` flag.** Then run the appropriate validation path: + +--- + +#### Default path (no `--review`): inline deterministic validation + +Write the following Node.js script to `$PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.cjs`: + +```javascript +#!/usr/bin/env node +const fs = require('fs'); +const graphPath = process.argv[2]; +const outputPath = process.argv[3]; +try { + const graph = JSON.parse(fs.readFileSync(graphPath, 'utf8')); + const issues = [], warnings = []; + if (!Array.isArray(graph.nodes)) { issues.push('graph.nodes is missing or not an array'); graph.nodes = []; } + if (!Array.isArray(graph.edges)) { issues.push('graph.edges is missing or not an array'); graph.edges = []; } + const nodeIds = new Set(); + const seen = new Map(); + graph.nodes.forEach((n, i) => { + if (!n.id) { issues.push(`Node[${i}] missing id`); return; } + if (!n.type) issues.push(`Node[${i}] '${n.id}' missing type`); + if (!n.name) issues.push(`Node[${i}] '${n.id}' missing name`); + if (!n.summary) issues.push(`Node[${i}] '${n.id}' missing summary`); + if (!n.tags || !n.tags.length) issues.push(`Node[${i}] '${n.id}' missing tags`); + if (seen.has(n.id)) issues.push(`Duplicate node ID '${n.id}' at indices ${seen.get(n.id)} and ${i}`); + else seen.set(n.id, i); + nodeIds.add(n.id); + }); + graph.edges.forEach((e, i) => { + if (!nodeIds.has(e.source)) issues.push(`Edge[${i}] source '${e.source}' not found`); + if (!nodeIds.has(e.target)) issues.push(`Edge[${i}] target '${e.target}' not found`); + }); + const fileNodes = graph.nodes.filter(n => n.type === 'file').map(n => n.id); + const assigned = new Map(); + (graph.layers || []).forEach(layer => { + (layer.nodeIds || []).forEach(id => { + if (!nodeIds.has(id)) issues.push(`Layer '${layer.id}' refs missing node '${id}'`); + if (assigned.has(id)) issues.push(`Node '${id}' appears in multiple layers`); + assigned.set(id, layer.id); + }); + }); + fileNodes.forEach(id => { + if (!assigned.has(id)) issues.push(`File node '${id}' not in any layer`); + }); + (graph.tour || []).forEach((step, i) => { + (step.nodeIds || []).forEach(id => { + if (!nodeIds.has(id)) issues.push(`Tour step[${i}] refs missing node '${id}'`); + }); + }); + const withEdges = new Set([ + ...graph.edges.map(e => e.source), + ...graph.edges.map(e => e.target) + ]); + graph.nodes.forEach(n => { + if (!withEdges.has(n.id)) warnings.push(`Node '${n.id}' has no edges (orphan)`); + }); + const stats = { + totalNodes: graph.nodes.length, + totalEdges: graph.edges.length, + totalLayers: (graph.layers || []).length, + tourSteps: (graph.tour || []).length, + nodeTypes: graph.nodes.reduce((a, n) => { a[n.type] = (a[n.type]||0)+1; return a; }, {}), + edgeTypes: graph.edges.reduce((a, e) => { a[e.type] = (a[e.type]||0)+1; return a; }, {}) + }; + fs.writeFileSync(outputPath, JSON.stringify({ issues, warnings, stats }, null, 2)); + process.exit(0); +} catch (err) { process.stderr.write(err.message + '\n'); process.exit(1); } +``` + +Execute it: +```bash +node $PROJECT_ROOT/.understand-anything/tmp/ua-inline-validate.cjs \ + "$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json" \ + "$PROJECT_ROOT/.understand-anything/intermediate/review.json" +``` + +If the script exits non-zero, read stderr, fix the script, and retry once. + +--- + +#### `--review` path: full LLM reviewer + +If `--review` IS in `$ARGUMENTS`, dispatch the LLM graph-reviewer subagent as follows: + +Dispatch a subagent using the prompt template at `./graph-reviewer-prompt.md`. Read the template file and pass the full content as the subagent's prompt, appending the following additional context: > **Additional context from main session:** > @@ -343,14 +430,16 @@ Assemble the full KnowledgeGraph JSON object: Pass these parameters in the dispatch prompt: - > Validate the knowledge graph at `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`. - > Project root: `$PROJECT_ROOT` - > Read the file and validate it for completeness and correctness. - > Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/review.json` +> Validate the knowledge graph at `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`. +> Project root: `$PROJECT_ROOT` +> Read the file and validate it for completeness and correctness. +> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/review.json` -4. After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/review.json`. +--- -5. **If `approved: false`:** +4. Read `$PROJECT_ROOT/.understand-anything/intermediate/review.json`. + +5. **If `issues` array is non-empty:** - Review the `issues` list - Apply automated fixes where possible: - Remove edges with dangling references @@ -359,7 +448,7 @@ Pass these parameters in the dispatch prompt: - Re-run the final graph validation after automated fixes - If critical issues remain after one fix attempt, save the graph anyway but include the warnings in the final report and mark dashboard auto-launch as skipped -6. **If `approved: true`:** Proceed to Phase 7. +6. **If `issues` array is empty:** Proceed to Phase 7. --- @@ -401,7 +490,7 @@ Pass these parameters in the dispatch prompt: ## Error Handling - If any subagent dispatch fails, retry **once** with the same prompt plus additional context about the failure. -- Track all warnings and errors from each phase in a `$PHASE_WARNINGS` list. Pass this list to the graph-reviewer in Phase 6 for comprehensive validation. +- Track all warnings and errors from each phase in a `$PHASE_WARNINGS` list. When using `--review`, pass this list to the graph-reviewer in Phase 6. On the default path, include accumulated warnings in the Phase 7 final report. - If it fails a second time, skip that phase and continue with partial results. - ALWAYS save partial results — a partial graph is better than no graph. - Report any skipped phases or errors in the final summary so the user knows what happened. From e4f7902a226494c37736d5c4f49b47aca50e838c Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 14:42:58 +0800 Subject: [PATCH 19/32] =?UTF-8?q?perf(understand):=20slim=20Phase=204=20ar?= =?UTF-8?q?chitecture=20payload=20=E2=80=94=20drop=20redundant=20node=20fi?= =?UTF-8?q?elds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- understand-anything-plugin/skills/understand/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 13cabcd..a73fd65 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -188,7 +188,7 @@ Pass these parameters in the dispatch prompt: > > File nodes: > ```json -> [list of {id, name, filePath, summary, tags} for all file-type nodes] +> [list of {id, filePath, summary, tags} for all file-type nodes — omit name, complexity, languageNotes] > ``` > > Import edges: From 1fa84c595932dce8df66f3276ed617dc99063b9f Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 14:46:19 +0800 Subject: [PATCH 20/32] =?UTF-8?q?perf(understand):=20slim=20Phase=205=20to?= =?UTF-8?q?ur=20payload=20=E2=80=94=20file=20nodes=20only,=20imports+calls?= =?UTF-8?q?=20edges,=20slim=20layers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- .../skills/understand/SKILL.md | 10 ++--- .../skills/understand/tour-builder-prompt.md | 39 ++++++++++--------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index a73fd65..6fe1f98 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -255,19 +255,19 @@ Pass these parameters in the dispatch prompt: > Project: `` — `` > Languages: `` > -> Nodes (summarized): +> Nodes (file nodes only): > ```json -> [list of {id, name, filePath, summary, type} for key nodes] +> [list of {id, name, filePath, summary, type} for file-type nodes ONLY — do NOT include function or class nodes] > ``` > > Layers: > ```json -> [layers from Phase 4] +> [list of {id, name, description} for each layer — omit nodeIds] > ``` > -> Key edges: +> Edges (imports and calls only): > ```json -> [imports and calls edges] +> [list of edges where type is "imports" or "calls" only — exclude all other edge types] > ``` After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermediate/tour.json` and normalize it into a final `tour` array. Apply these steps **in order**: diff --git a/understand-anything-plugin/skills/understand/tour-builder-prompt.md b/understand-anything-plugin/skills/understand/tour-builder-prompt.md index 8b1d318..0c0dda8 100644 --- a/understand-anything-plugin/skills/understand/tour-builder-prompt.md +++ b/understand-anything-plugin/skills/understand/tour-builder-prompt.md @@ -20,13 +20,13 @@ Write a Node.js script that analyzes the graph's topology to surface structural ```json { "nodes": [ - {"id": "file:src/index.ts", "type": "file", "name": "index.ts", "filePath": "src/index.ts", "summary": "...", "tags": ["entry-point"]} + {"id": "file:src/index.ts", "type": "file", "name": "index.ts", "filePath": "src/index.ts", "summary": "..."} ], "edges": [ {"source": "file:src/index.ts", "target": "file:src/utils.ts", "type": "imports"} ], "layers": [ - {"id": "layer:core", "name": "Core", "nodeIds": ["file:src/index.ts"]} + {"id": "layer:core", "name": "Core", "description": "Core application logic"} ] } ``` @@ -47,7 +47,6 @@ For every node, count how many other nodes it has edges pointing TO (fan-out). H Identify likely entry points using these signals (score each file node, sum the scores): - Filename matches `index.ts`, `index.js`, `main.ts`, `main.js`, `app.ts`, `app.js`, `server.ts`, `server.js`, `mod.rs`, `main.go`, `main.py`, `main.rs`, `manage.py`, `app.py`, `wsgi.py`, `asgi.py`, `run.py`, `__main__.py`, `Application.java`, `Main.java`, `Program.cs`, `config.ru`, `index.php`, `App.swift`, `Application.kt`, `main.cpp`, `main.c` -> +3 points -- Node tags contain `entry-point` or `barrel` -> +2 points - File is at the project root or one level deep (e.g., `src/index.ts`) -> +1 point - High fan-out (top 10%) -> +1 point - Low fan-in (bottom 25%) -> +1 point (entry points are imported by few files) @@ -71,17 +70,15 @@ Algorithm: For each pair of nodes with a bidirectional relationship (A imports B Output the top 5-10 clusters, each as a list of node IDs. -**F. Layer Statistics** +**F. Layer List** -For each layer, compute: -- Number of file nodes -- Average fan-in of files in this layer -- Average fan-out of files in this layer -- The layer's "rank" in the dependency hierarchy (layers that are imported by many others but import few = foundational; layers that import many others but are imported by few = top-level) +Record the layers provided in the input. Since layers contain only `{id, name, description}` (no node membership), simply output the layer count and the list of layers with their id, name, and description. **G. Node Summary Index** -Create a lookup of each node ID to its `summary`, `type`, `tags` (default to empty array `[]` if not present in input), and `name` for easy reference. This lets the LLM phase quickly access semantic information without re-reading the full input. +Create a lookup of each node ID to its `summary`, `type`, and `name` for easy reference. This lets the LLM phase quickly access semantic information without re-reading the full input. + +Note: input nodes are file-type only. The nodeSummaryIndex will contain only file nodes. ### Script Output Format @@ -114,15 +111,19 @@ Create a lookup of each node ID to its `summary`, `type`, `tags` (default to emp "clusters": [ {"nodes": ["file:src/services/auth.ts", "file:src/models/user.ts"], "edgeCount": 4} ], - "layerStats": [ - {"id": "layer:core", "name": "Core", "fileCount": 5, "avgFanIn": 8.2, "avgFanOut": 3.1, "hierarchyRank": 1} - ], + "layers": { + "count": 3, + "list": [ + {"id": "layer:core", "name": "Core", "description": "Core application logic"}, + {"id": "layer:services", "name": "Services", "description": "Business logic services"}, + {"id": "layer:ui", "name": "UI", "description": "User interface components"} + ] + }, "nodeSummaryIndex": { - "file:src/index.ts": {"name": "index.ts", "type": "file", "summary": "Main entry point...", "tags": ["entry-point"]}, - "file:src/utils.ts": {"name": "utils.ts", "type": "file", "summary": "Shared helpers...", "tags": []} + "file:src/index.ts": {"name": "index.ts", "type": "file", "summary": "Main entry point..."}, + "file:src/utils.ts": {"name": "utils.ts", "type": "file", "summary": "Shared helpers..."} }, "totalNodes": 42, - "totalFileNodes": 20, "totalEdges": 87 } ``` @@ -179,13 +180,13 @@ You do not need to include every node from the BFS. Select the most important an When a `cluster` from the script output appears at the same BFS depth, group those nodes into a single tour step. Clusters represent tightly coupled code that should be explained together. -### Step 4 -- Use Layer Statistics for Narrative Arc +### Step 4 -- Use Layers for Narrative Arc -The `layerStats` with `hierarchyRank` tells you which layers are foundational vs. top-level. Structure the tour to explain foundational layers before the layers that depend on them. +The `layers` list gives you the project's architectural groupings. Use layer names and descriptions to understand which areas are foundational vs. top-level, and structure the tour to explain foundational layers before the layers that depend on them. ### Step 5 -- Write Step Descriptions -For each step, use the `nodeSummaryIndex` to access node summaries, names, and tags without re-reading files. Each description must: +For each step, use the `nodeSummaryIndex` to access node summaries and names without re-reading files. Each description must: - Explain WHAT this area does and WHY it matters to the project - Connect to previous steps (e.g., "Building on the User types from Step 2, this service implements...") From 67d146033de692cc6d4c584fff121d00ad340a8c Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 15:22:24 +0800 Subject: [PATCH 21/32] perf(understand): remove language/framework addendums from Phase 2 batches Co-Authored-By: Claude Opus 4.6 --- .../skills/understand/SKILL.md | 11 +------ .../skills/understand/file-analyzer-prompt.md | 29 +++++++++++++++++++ 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 6fe1f98..a248efe 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -100,22 +100,13 @@ After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermedi Batch the file list from Phase 1 into groups of **5-10 files each** (aim for balanced batch sizes). -For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **3 subagents concurrently** using parallel dispatch. - -**Build the combined prompt template:** -1. Read the base template at `./file-analyzer-prompt.md`. -2. **Language context injection:** For each language detected in Phase 1 (e.g., `python`), read the file at `./languages/.md` (e.g., `./languages/python.md`) and append its content after the base template under a `## Language Context` header. If the file does not exist for a detected language, skip it silently and continue. These files are in the `languages/` subdirectory next to this SKILL.md file. Use `ls ./languages/` to discover available language files if needed. -3. **Framework addendum injection:** For each framework detected in Phase 1 (e.g., `Django`), read the file at `./frameworks/.md` (e.g., `./frameworks/django.md`) and append its full content after the language context. If the file does not exist for a detected framework, skip it silently and continue. These files are in the `frameworks/` subdirectory next to this SKILL.md file. Use `ls ./frameworks/` to discover available framework files if needed. - -Then for each batch pass the combined template content as the subagent's prompt, appending the following additional context: +For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **3 subagents concurrently** using parallel dispatch. Pass the template as the subagent's prompt, appending the following additional context: > **Additional context from main session:** > > Project: `` — `` > Frameworks detected: `` > Languages: `` -> -> Use the language context and framework addendums (appended above) to produce more accurate summaries and better classify file roles. Fill in batch-specific parameters below and dispatch: diff --git a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md index 27534a5..6b90665 100644 --- a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md +++ b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md @@ -295,6 +295,35 @@ Produce a single, valid JSON block. Validate it mentally before writing -- malfo - `direction` (string) -- always `forward` - `weight` (number) -- must match the weight specified in the edge type table +## Language and Framework Quick Reference + +Use these hints to improve tag and edge accuracy for common patterns. Your training knowledge covers these — this is a fast lookup for the most impactful signals. + +**Tag signals:** + +| Signal | Tags to apply | +|---|---| +| File in `hooks/`, exports a function starting with `use` | `hook`, `service` | +| File in `contexts/` or `context/`, exports a Provider component | `service`, `state` | +| File in `pages/` or `views/` | `ui`, `routing` | +| File in `store/`, `slices/`, `reducers/`, `state/` | `state` | +| File in `services/`, `api/`, `client/` | `service` | +| `__init__.py` at a package root with re-exports | `entry-point`, `barrel` | +| `manage.py` at the project root | `entry-point` | +| `mod.rs` in a directory | `barrel` | +| `main.go` in a `cmd/` subdirectory | `entry-point` | + +**Edge signals:** + +| Pattern | Edge to create | +|---|---| +| React component renders another component in its JSX | `contains` from parent to child | +| Component/hook calls a custom hook (`useX`) | `depends_on` from consumer to hook file | +| Context provider wraps components | `publishes` from provider to context definition | +| Component calls `useContext` or custom context hook | `subscribes` from consumer to context definition | +| Python file uses `from x import y` where x is a project file | `imports` edge (same rule as JS/TS) | +| Go file `import`s an internal package path | `imports` edge to the resolved file | + ## Critical Constraints - NEVER invent file paths. Every `filePath` and every file reference in node IDs must correspond to a real file from the script's output or the project file list provided to you. From c8261ff28b697990f28e9f30aa0b4dcadfd40975 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 15:34:43 +0800 Subject: [PATCH 22/32] perf(understand): extend scanner to pre-resolve imports, output importMap in scan-result.json Co-Authored-By: Claude Opus 4.6 --- .../understand/project-scanner-prompt.md | 49 +++++++++++++++++-- 1 file changed, 46 insertions(+), 3 deletions(-) diff --git a/understand-anything-plugin/skills/understand/project-scanner-prompt.md b/understand-anything-plugin/skills/understand/project-scanner-prompt.md index b80de31..8084433 100644 --- a/understand-anything-plugin/skills/understand/project-scanner-prompt.md +++ b/understand-anything-plugin/skills/understand/project-scanner-prompt.md @@ -106,6 +106,40 @@ Extract from (in priority order): 4. `pyproject.toml` -- check `[project].name` first, then `[tool.poetry].name` 5. Directory name of project root +**Step 8 -- Import Resolution** + +For each file in the discovered source list, extract and resolve relative import statements. The goal is to produce a map from each file's path to the list of project-internal files it imports. External package imports are ignored. + +For each file, read its content and extract import paths using language-appropriate patterns: + +| Language | Import patterns to match | +|---|---| +| TypeScript/JavaScript | `import ... from './...'` or `'../'`, `require('./...')` or `require('../...')` | +| Python | `from .x import y`, `from ..x import y`, `import .x` (relative only) | +| Go | Paths in `import (...)` blocks that start with the module path from `go.mod` | +| Rust | `use crate::`, `use super::`, `mod x` (within the same crate) | +| Java/Kotlin | Not resolvable by path — skip import resolution for these languages | +| Ruby | `require_relative '...'` paths | + +For each extracted import path: +1. Compute the resolved file path relative to project root: + - For relative imports (`./x`, `../x`): resolve from the importing file's directory + - Try these extension variants in order if the import has no extension: `.ts`, `.tsx`, `.js`, `.jsx`, `/index.ts`, `/index.js`, `/index.tsx`, `/index.jsx`, `.py`, `.go`, `.rs`, `.rb` +2. Check if the resolved path exists in the discovered file list +3. If yes: add to this file's resolved imports list +4. If no: skip (external, unresolvable, or dynamic import) + +Output format in the script result: +```json +"importMap": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [], + "src/components/App.tsx": ["src/hooks/useAuth.ts", "src/store/index.ts"] +} +``` + +Keys are project-relative paths. Values are arrays of resolved project-relative paths. Every key in the file list must appear in `importMap` (use an empty array `[]` if no imports were resolved). External packages and unresolvable imports are omitted entirely. + ### Script Output Format The script must write this exact JSON structure to the output file: @@ -122,7 +156,11 @@ The script must write this exact JSON structure to the output file: {"path": "src/index.ts", "language": "typescript", "sizeLines": 150} ], "totalFiles": 42, - "estimatedComplexity": "moderate" + "estimatedComplexity": "moderate", + "importMap": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [] + } } ``` @@ -135,6 +173,7 @@ The script must write this exact JSON structure to the output file: - `files` (object[]) -- every source file, sorted by `path` alphabetically - `totalFiles` (integer) -- must equal `files.length` - `estimatedComplexity` (string) -- one of `small`, `moderate`, `large`, `very-large` +- `importMap` (object) — map from every source file path to its list of resolved project-internal import paths; empty array if no resolved imports; external packages excluded ### Executing the Script @@ -154,7 +193,7 @@ If the script exits with a non-zero code, read stderr, diagnose the issue, fix t After the script completes, read `$PROJECT_ROOT/.understand-anything/tmp/ua-scan-results.json`. Do NOT re-run file discovery commands or re-count lines -- trust the script's results entirely. -**IMPORTANT:** The final output must NOT contain the `scriptCompleted`, `rawDescription`, or `readmeHead` fields. These are intermediate script fields only. Strip them when assembling the final JSON. +**IMPORTANT:** The final output must NOT contain the `scriptCompleted`, `rawDescription`, or `readmeHead` fields. These are intermediate script fields only. Strip them when assembling the final JSON. All other fields — including `importMap` — MUST be preserved exactly as output by the script. Your only task in this phase is to produce the final `description` field: @@ -175,7 +214,10 @@ Then assemble the final output JSON: {"path": "src/index.ts", "language": "typescript", "sizeLines": 150} ], "totalFiles": 42, - "estimatedComplexity": "moderate" + "estimatedComplexity": "moderate", + "importMap": { + "src/index.ts": ["src/utils.ts"] + } } ``` @@ -187,6 +229,7 @@ Then assemble the final output JSON: - `files` (object[]): directly from script output - `totalFiles` (integer): directly from script output - `estimatedComplexity` (string): directly from script output +- `importMap` (object): directly from script output ## Critical Constraints From 7dbd89a44b6067ceba9fc557e79fe1bf500df035 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 17:08:48 +0800 Subject: [PATCH 23/32] =?UTF-8?q?perf(understand):=20replace=20allProjectF?= =?UTF-8?q?iles=20with=20batchImportData=20in=20file-analyzer=20=E2=80=94?= =?UTF-8?q?=20import=20resolution=20now=20done=20by=20scanner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../skills/understand/file-analyzer-prompt.md | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md index 6b90665..e7c2d2f 100644 --- a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md +++ b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md @@ -20,11 +20,14 @@ Write a script that reads each source file in your batch and extracts determinis ```json { "projectRoot": "/path/to/project", - "allProjectFiles": ["src/index.ts", "src/utils.ts", "..."], "batchFiles": [ {"path": "src/index.ts", "language": "typescript", "sizeLines": 150}, {"path": "src/utils.ts", "language": "typescript", "sizeLines": 80} - ] + ], + "batchImportData": { + "src/index.ts": ["src/utils.ts", "src/config.ts"], + "src/utils.ts": [] + } } ``` 2. **Write** results JSON to the path given as the second argument. @@ -45,10 +48,9 @@ For each file in `batchFiles`, read the file content and extract: - Detection approach: match `class `, `interface `, `type =`, `struct `, `trait `, `impl ` as appropriate **Imports:** -- Source module path (exactly as written in the import statement) -- Imported specifiers (named imports, default import, namespace import) -- Line number -- For relative imports (starting with `./` or `../`), compute the resolved path relative to project root. Cross-reference against `allProjectFiles` to confirm the resolved path exists. Mark unresolvable imports. +- Do NOT extract imports in the script. Import resolution has already been performed by the project scanner. +- The pre-resolved imports for each file are provided in `batchImportData` in the input JSON. +- Do not include an `imports` field in the script output — import edges will be created in Phase 2 using `batchImportData` directly. **Exports:** - Exported names and their line numbers @@ -82,10 +84,6 @@ The script must write this exact JSON structure to the output file: "classes": [ {"name": "App", "startLine": 50, "endLine": 140, "methods": ["init", "run"], "properties": ["config", "logger"]} ], - "imports": [ - {"source": "./utils", "resolvedPath": "src/utils.ts", "specifiers": ["formatDate", "sanitize"], "line": 1, "isExternal": false}, - {"source": "express", "resolvedPath": null, "specifiers": ["default"], "line": 2, "isExternal": true} - ], "exports": [ {"name": "App", "line": 50, "isDefault": true}, {"name": "createApp", "line": 145, "isDefault": false} @@ -114,8 +112,8 @@ Before writing the script, create its input JSON file. **IMPORTANT:** Use the ba cat > $PROJECT_ROOT/.understand-anything/tmp/ua-file-analyzer-input-.json << 'ENDJSON' { "projectRoot": "", - "allProjectFiles": [], - "batchFiles": [] + "batchFiles": [], + "batchImportData": } ENDJSON ``` @@ -202,7 +200,7 @@ Using the script's import, export, and structural data, create edges: | Edge Type | When to Create | Weight | Direction | |---|---|---|---| | `contains` | File contains a function or class node you created | `1.0` | `forward` | -| `imports` | File imports from another project file (use `resolvedPath` from script, skip external imports where `isExternal: true`) | `0.7` | `forward` | +| `imports` | File imports from another project file (use `batchImportData[filePath]` from input JSON — external imports already filtered out) | `0.7` | `forward` | | `calls` | A function in this file calls a function in another file (infer from imports + function names when confident) | `0.8` | `forward` | | `inherits` | A class extends another class in the project | `0.9` | `forward` | | `implements` | A class implements an interface in the project | `0.9` | `forward` | @@ -210,7 +208,7 @@ Using the script's import, export, and structural data, create edges: | `depends_on` | File has runtime dependency on another project file (broader than imports -- includes dynamic requires, lazy loads) | `0.6` | `forward` | | `tested_by` | Source file is tested by a test file (infer from test file imports and naming conventions) | `0.5` | `forward` | -**Import edge creation rule:** For each import in the script output where `isExternal` is `false` and `resolvedPath` is non-null, create an `imports` edge from the current file node to `file:`. Do NOT create edges for external package imports. +**Import edge creation rule:** For each resolved path in `batchImportData[filePath]` (provided in the input JSON), create an `imports` edge from the current file node to `file:`. The `batchImportData` values contain only resolved project-internal paths — external packages have already been filtered out. Do NOT attempt to re-resolve imports from source. Do NOT use edge types not listed in this table. @@ -327,10 +325,10 @@ Use these hints to improve tag and edge accuracy for common patterns. Your train ## Critical Constraints - NEVER invent file paths. Every `filePath` and every file reference in node IDs must correspond to a real file from the script's output or the project file list provided to you. -- NEVER create edges to nodes that do not exist. If an import target is external (`isExternal: true` in script output), do NOT create an edge for it. +- NEVER create edges to nodes that do not exist. Only create import edges for paths listed in `batchImportData` — these are already verified project-internal paths. - ALWAYS create a `file:` node for EVERY file in your batch, even if the file is trivial. - Only create `function:` and `class:` nodes for significant code elements (see significance filter above). -- For import edges, use the script's `resolvedPath` field directly. Do NOT attempt to resolve import paths yourself -- the script already did this deterministically. +- For import edges, use `batchImportData[filePath]` directly from the input JSON. Do NOT attempt to resolve import paths yourself -- the project scanner already did this deterministically. - NEVER produce duplicate node IDs within your batch. - NEVER create self-referencing edges (where source equals target). - Trust the script's structural extraction. Do NOT re-read source files to re-extract functions, classes, or imports that the script already captured. Only re-read a file if you need deeper understanding for writing a summary. From 33d4e73e325eb1fe072c4420e7665e62990c7c83 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 17:09:00 +0800 Subject: [PATCH 24/32] =?UTF-8?q?perf(understand):=20wire=20importMap=20in?= =?UTF-8?q?to=20batchImportData=20per=20batch,=20increase=20batch=20size?= =?UTF-8?q?=205-10=E2=86=9220-30,=20concurrency=203=E2=86=925?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .../skills/understand/SKILL.md | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index a248efe..0514988 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -89,6 +89,9 @@ After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermedi - Languages, frameworks - File list with line counts - Complexity estimate +- Import map (`importMap`): pre-resolved project-internal imports per file + +Store `importMap` in memory as `$IMPORT_MAP` for use in Phase 2 batch construction. **Gate check:** If >200 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. @@ -98,16 +101,22 @@ After the subagent completes, read `$PROJECT_ROOT/.understand-anything/intermedi ### Full analysis path -Batch the file list from Phase 1 into groups of **5-10 files each** (aim for balanced batch sizes). +Batch the file list from Phase 1 into groups of **20-30 files each** (aim for ~25 files per batch for balanced sizes). -For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **3 subagents concurrently** using parallel dispatch. Pass the template as the subagent's prompt, appending the following additional context: +For each batch, dispatch a subagent using the prompt template at `./file-analyzer-prompt.md`. Run up to **5 subagents concurrently** using parallel dispatch. Pass the template as the subagent's prompt, appending the following additional context: > **Additional context from main session:** > > Project: `` — `` -> Frameworks detected: `` > Languages: `` +Before dispatching each batch, construct `batchImportData` from `$IMPORT_MAP`: +```json +batchImportData = {} +for each file in this batch: + batchImportData[file.path] = $IMPORT_MAP[file.path] ?? [] +``` + Fill in batch-specific parameters below and dispatch: > Analyze these source files and produce GraphNode and GraphEdge objects. @@ -117,8 +126,10 @@ Fill in batch-specific parameters below and dispatch: > Batch index: `` > Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-.json` > -> All project files (for import resolution): -> `` +> Pre-resolved import data for this batch (use this for all import edge creation — do NOT re-resolve imports from source): +> ```json +> +> ``` > > Files to analyze in this batch: > 1. `` ( lines) @@ -131,7 +142,7 @@ After ALL batches complete, read each `batch-.json` file and merge: ### Incremental update path -Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above, but only for changed files. +Use the changed files list from Phase 0. Batch and dispatch file-analyzer subagents using the same process as above (20-30 files per batch, up to 5 concurrent, with batchImportData constructed from $IMPORT_MAP), but only for changed files. After batches complete, merge with the existing graph: 1. Remove old nodes whose `filePath` matches any changed file From a33240180d85064e6b21acda69f9d1aceb7a6b63 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 17:09:04 +0800 Subject: [PATCH 25/32] chore: bump version to 1.2.2 Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- understand-anything-plugin/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 140122a..6f2845b 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "understand-anything", "description": "Multi-agent codebase analysis with interactive dashboard, guided tours, and skill commands", - "version": "1.2.1", + "version": "1.2.2", "source": "./understand-anything-plugin" } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5cabce8..375688e 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": "1.2.1", + "version": "1.2.2", "author": { "name": "Lum1104" }, diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 41f5046..76707fd 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": "1.2.1", + "version": "1.2.2", "author": { "name": "Lum1104" }, diff --git a/understand-anything-plugin/package.json b/understand-anything-plugin/package.json index 56906b1..59ca16d 100644 --- a/understand-anything-plugin/package.json +++ b/understand-anything-plugin/package.json @@ -1,6 +1,6 @@ { "name": "@understand-anything/skill", - "version": "1.2.1", + "version": "1.2.2", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", From c0dc9b3e717135dec779effe5835ddf7fce441e9 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 17:10:10 +0800 Subject: [PATCH 26/32] =?UTF-8?q?docs:=20fix=20version=20references=20in?= =?UTF-8?q?=20token-reduction=20plan=20(1.0.2=20=E2=86=92=201.2.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/plans/2026-03-27-token-reduction-impl.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/plans/2026-03-27-token-reduction-impl.md b/docs/plans/2026-03-27-token-reduction-impl.md index 08d88ed..45c04eb 100644 --- a/docs/plans/2026-03-27-token-reduction-impl.md +++ b/docs/plans/2026-03-27-token-reduction-impl.md @@ -851,17 +851,17 @@ Per project convention, all four version files must stay in sync when changes ar node -e "const p = require('./understand-anything-plugin/package.json'); console.log(p.version)" ``` -Expected: `1.0.1` (or whatever the current version is). +Expected: `1.2.1` (or whatever the current version is). ### Step 2: Bump patch version in all four files -New version: `1.0.2` (patch bump — internal optimization, no API changes). +New version: `1.2.2` (patch bump — internal optimization, no API changes). Update each file: -- `understand-anything-plugin/package.json`: `"version": "1.0.2"` -- `.claude-plugin/marketplace.json`: `"version": "1.0.2"` in `plugins[0]` -- `.claude-plugin/plugin.json`: `"version": "1.0.2"` -- `.cursor-plugin/plugin.json`: `"version": "1.0.2"` +- `understand-anything-plugin/package.json`: `"version": "1.2.2"` +- `.claude-plugin/marketplace.json`: `"version": "1.2.2"` in `plugins[0]` +- `.claude-plugin/plugin.json`: `"version": "1.2.2"` +- `.cursor-plugin/plugin.json`: `"version": "1.2.2"` ### Step 3: Verify all four files match @@ -869,7 +869,7 @@ Update each file: grep -r '"version"' understand-anything-plugin/package.json .claude-plugin/marketplace.json .claude-plugin/plugin.json .cursor-plugin/plugin.json ``` -All four should show `"version": "1.0.2"`. +All four should show `"version": "1.2.2"`. ### Step 4: Commit @@ -878,7 +878,7 @@ git add understand-anything-plugin/package.json \ .claude-plugin/marketplace.json \ .claude-plugin/plugin.json \ .cursor-plugin/plugin.json -git commit -m "chore: bump version to 1.0.2" +git commit -m "chore: bump version to 1.2.2" ``` --- From b90fb16b012bfb7e88dc3b683596cd9ee04e57d0 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Fri, 27 Mar 2026 17:23:09 +0800 Subject: [PATCH 27/32] =?UTF-8?q?docs:=20update=20README=20pipeline=20stat?= =?UTF-8?q?s=20=E2=80=94=20concurrency=203=E2=86=925,=20batch=20size,=20--?= =?UTF-8?q?review=20flag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ea6d0ff..999fb49 100644 --- a/README.md +++ b/README.md @@ -237,9 +237,9 @@ The `/understand` command orchestrates 5 specialized agents: | `file-analyzer` | Extract functions, classes, imports; produce graph nodes and edges | | `architecture-analyzer` | Identify architectural layers | | `tour-builder` | Generate guided learning tours | -| `graph-reviewer` | Validate graph completeness and referential integrity | +| `graph-reviewer` | Validate graph completeness and referential integrity (runs inline by default; use `--review` for full LLM review) | -File analyzers run in parallel (up to 3 concurrent). Supports incremental updates — only re-analyzes files that changed since the last run. +File analyzers run in parallel (up to 5 concurrent, 20-30 files per batch). Supports incremental updates — only re-analyzes files that changed since the last run. ### Project Structure From 3fb12033d65ec4ce2aed3c5d9594ad9f9b7f48ae Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 28 Mar 2026 11:11:49 +0800 Subject: [PATCH 28/32] =?UTF-8?q?fix(understand):=20address=20review=20fin?= =?UTF-8?q?dings=20=E2=80=94=20importCount=20metric=20source=20and=20plan?= =?UTF-8?q?=20typo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update importCount metric description to reference batchImportData[file.path].length instead of "number of import statements" (contradicted "do NOT extract imports" rule) - Fix duplicate /index.js → /index.jsx in impl plan Task 5 extension variants Co-Authored-By: Claude Opus 4.6 --- docs/plans/2026-03-27-token-reduction-impl.md | 2 +- .../skills/understand/file-analyzer-prompt.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/plans/2026-03-27-token-reduction-impl.md b/docs/plans/2026-03-27-token-reduction-impl.md index 45c04eb..848276b 100644 --- a/docs/plans/2026-03-27-token-reduction-impl.md +++ b/docs/plans/2026-03-27-token-reduction-impl.md @@ -440,7 +440,7 @@ For each file, read its content and extract import paths using language-appropri For each extracted import path: 1. Compute the resolved file path relative to project root: - For relative imports (`./x`, `../x`): resolve from the importing file's directory - - Try these extension variants in order if the import has no extension: `.ts`, `.tsx`, `.js`, `.jsx`, `/index.ts`, `/index.js`, `/index.tsx`, `/index.js`, `.py`, `.go`, `.rs`, `.rb` + - Try these extension variants in order if the import has no extension: `.ts`, `.tsx`, `.js`, `.jsx`, `/index.ts`, `/index.js`, `/index.tsx`, `/index.jsx`, `.py`, `.go`, `.rs`, `.rb` 2. Check if the resolved path exists in the discovered file list 3. If yes: add to this file's resolved imports list 4. If no: skip (external, unresolvable, or dynamic import) diff --git a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md index e7c2d2f..7873d0a 100644 --- a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md +++ b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md @@ -59,7 +59,7 @@ For each file in `batchFiles`, read the file content and extract: **Basic Metrics:** - Total line count - Non-empty line count (lines that are not blank or comment-only) -- Import count (number of import statements) +- Import count — use `batchImportData[file.path].length` from the input JSON (do not count from source) - Export count (number of export statements) - Function count, class count From 36e7ad8c6e3af4593601e0998a11c0ccd1c15c80 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 28 Mar 2026 11:15:16 +0800 Subject: [PATCH 29/32] =?UTF-8?q?fix(understand):=20address=20PR=20#47=20r?= =?UTF-8?q?eview=20=E2=80=94=20edge=20types,=20validator=20guards,=20promp?= =?UTF-8?q?t=20accuracy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Map Quick Reference edge signals to allowed types (publishes→exports, subscribes→depends_on) to resolve contradiction with edge type table - Restore `name` in Phase 4 payload (architecture-analyzer expects it) - Add Array.isArray guards for layers/tour in inline validator - Fix Python import syntax: `import .x` → `from . import x` - Update Critical Constraints to reference batchFiles/batchImportData Co-Authored-By: Claude Opus 4.6 --- .../skills/understand/SKILL.md | 12 +++++++----- .../skills/understand/file-analyzer-prompt.md | 6 +++--- .../skills/understand/project-scanner-prompt.md | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 0514988..cee167a 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -190,7 +190,7 @@ Pass these parameters in the dispatch prompt: > > File nodes: > ```json -> [list of {id, filePath, summary, tags} for all file-type nodes — omit name, complexity, languageNotes] +> [list of {id, name, filePath, summary, tags} for all file-type nodes — omit complexity, languageNotes] > ``` > > Import edges: @@ -366,7 +366,9 @@ try { }); const fileNodes = graph.nodes.filter(n => n.type === 'file').map(n => n.id); const assigned = new Map(); - (graph.layers || []).forEach(layer => { + if (!Array.isArray(graph.layers)) { if (graph.layers) warnings.push('graph.layers is not an array'); graph.layers = []; } + if (!Array.isArray(graph.tour)) { if (graph.tour) warnings.push('graph.tour is not an array'); graph.tour = []; } + graph.layers.forEach(layer => { (layer.nodeIds || []).forEach(id => { if (!nodeIds.has(id)) issues.push(`Layer '${layer.id}' refs missing node '${id}'`); if (assigned.has(id)) issues.push(`Node '${id}' appears in multiple layers`); @@ -376,7 +378,7 @@ try { fileNodes.forEach(id => { if (!assigned.has(id)) issues.push(`File node '${id}' not in any layer`); }); - (graph.tour || []).forEach((step, i) => { + graph.tour.forEach((step, i) => { (step.nodeIds || []).forEach(id => { if (!nodeIds.has(id)) issues.push(`Tour step[${i}] refs missing node '${id}'`); }); @@ -391,8 +393,8 @@ try { const stats = { totalNodes: graph.nodes.length, totalEdges: graph.edges.length, - totalLayers: (graph.layers || []).length, - tourSteps: (graph.tour || []).length, + totalLayers: graph.layers.length, + tourSteps: graph.tour.length, nodeTypes: graph.nodes.reduce((a, n) => { a[n.type] = (a[n.type]||0)+1; return a; }, {}), edgeTypes: graph.edges.reduce((a, e) => { a[e.type] = (a[e.type]||0)+1; return a; }, {}) }; diff --git a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md index 7873d0a..26df55f 100644 --- a/understand-anything-plugin/skills/understand/file-analyzer-prompt.md +++ b/understand-anything-plugin/skills/understand/file-analyzer-prompt.md @@ -317,14 +317,14 @@ Use these hints to improve tag and edge accuracy for common patterns. Your train |---|---| | React component renders another component in its JSX | `contains` from parent to child | | Component/hook calls a custom hook (`useX`) | `depends_on` from consumer to hook file | -| Context provider wraps components | `publishes` from provider to context definition | -| Component calls `useContext` or custom context hook | `subscribes` from consumer to context definition | +| Context provider wraps components | `exports` from provider to context definition | +| Component calls `useContext` or custom context hook | `depends_on` from consumer to context definition | | Python file uses `from x import y` where x is a project file | `imports` edge (same rule as JS/TS) | | Go file `import`s an internal package path | `imports` edge to the resolved file | ## Critical Constraints -- NEVER invent file paths. Every `filePath` and every file reference in node IDs must correspond to a real file from the script's output or the project file list provided to you. +- NEVER invent file paths. Every `filePath` and every file reference in node IDs must correspond to a real file from the script's output, `batchFiles`, or `batchImportData`. - NEVER create edges to nodes that do not exist. Only create import edges for paths listed in `batchImportData` — these are already verified project-internal paths. - ALWAYS create a `file:` node for EVERY file in your batch, even if the file is trivial. - Only create `function:` and `class:` nodes for significant code elements (see significance filter above). diff --git a/understand-anything-plugin/skills/understand/project-scanner-prompt.md b/understand-anything-plugin/skills/understand/project-scanner-prompt.md index 8084433..8febcc8 100644 --- a/understand-anything-plugin/skills/understand/project-scanner-prompt.md +++ b/understand-anything-plugin/skills/understand/project-scanner-prompt.md @@ -115,7 +115,7 @@ For each file, read its content and extract import paths using language-appropri | Language | Import patterns to match | |---|---| | TypeScript/JavaScript | `import ... from './...'` or `'../'`, `require('./...')` or `require('../...')` | -| Python | `from .x import y`, `from ..x import y`, `import .x` (relative only) | +| Python | `from .x import y`, `from ..x import y`, `from . import x` (relative only) | | Go | Paths in `import (...)` blocks that start with the module path from `go.mod` | | Rust | `use crate::`, `use super::`, `mod x` (within the same crate) | | Java/Kotlin | Not resolvable by path — skip import resolution for these languages | From 867293ebabe7204fa95c3a4deaf539efbf2fd18f Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 28 Mar 2026 11:45:29 +0800 Subject: [PATCH 30/32] feat(understand): add review-only path and clarifying prompt for up-to-date graphs - --review at same commit hash skips to Phase 6, reuses existing graph - Same commit hash without flags now asks user what they'd like to do instead of silently stopping Co-Authored-By: Claude Opus 4.6 --- understand-anything-plugin/skills/understand/SKILL.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index cee167a..7c20679 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -39,9 +39,12 @@ Determine whether to run a full analysis or incremental update. |---|---| | `--full` flag in `$ARGUMENTS` | Full analysis (all phases) | | No existing graph or meta | Full analysis (all phases) | - | Existing graph + unchanged commit hash | Report "Graph is up to date" and STOP | + | `--review` flag + existing graph + unchanged commit hash | Skip to Phase 6 (review-only — reuse existing assembled graph) | + | Existing graph + unchanged commit hash | Ask the user: "The graph is up to date at this commit. Would you like to: **(a)** run a full rebuild (`--full`), **(b)** run the LLM graph reviewer (`--review`), or **(c)** do nothing?" Then follow their choice. If they pick (c), STOP. | | Existing graph + changed files | Incremental update (re-analyze changed files only) | + **Review-only path:** Copy the existing `knowledge-graph.json` to `$PROJECT_ROOT/.understand-anything/intermediate/assembled-graph.json`, then jump directly to Phase 6 step 3. + For incremental updates, get the changed file list: ```bash git diff ..HEAD --name-only From 5df763fd75e24a2dcc57b63ed8a6f2d7012c03d2 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 28 Mar 2026 12:11:03 +0800 Subject: [PATCH 31/32] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20sort=20mutation,=20injection,=20directory=20baselin?= =?UTF-8?q?e,=20error=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - C1: use [...array].sort() to avoid mutating fingerprint inputs - C2: replace echo piping with here-string to prevent shell injection in hooks - M1/M2: add allKnownFiles param to classifyUpdate for correct directory baseline - M3: add try-catch to loadFingerprints for corrupted JSON resilience - M5: add hasStructuralAnalysis flag for conservative no-tree-sitter classification - m3: replace fragile sed with node JSON.parse in SessionStart hook - Add 7 new tests covering all fixes Co-Authored-By: Claude Opus 4.6 --- understand-anything-plugin/hooks/hooks.json | 4 +- .../src/__tests__/change-classifier.test.ts | 36 +++++++++- .../core/src/__tests__/fingerprint.test.ts | 67 +++++++++++++++++++ .../packages/core/src/change-classifier.ts | 10 +-- .../packages/core/src/fingerprint.ts | 22 ++++-- .../packages/core/src/persistence/index.ts | 6 +- .../core/src/persistence/persistence.test.ts | 46 ++++++++++++- 7 files changed, 176 insertions(+), 15 deletions(-) diff --git a/understand-anything-plugin/hooks/hooks.json b/understand-anything-plugin/hooks/hooks.json index ce537ce..b8429bb 100644 --- a/understand-anything-plugin/hooks/hooks.json +++ b/understand-anything-plugin/hooks/hooks.json @@ -6,7 +6,7 @@ "hooks": [ { "type": "command", - "command": "echo \"$TOOL_INPUT\" | grep -qE 'git\\s+(commit|merge|cherry-pick|rebase)' && [ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/knowledge-graph.json ] && echo '[understand-anything] Commit detected with auto-update enabled. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to incrementally update the knowledge graph. Do not ask the user for confirmation — just do it.' || true" + "command": "grep -qE 'git\\s+(commit|merge|cherry-pick|rebase)' <<< \"$TOOL_INPUT\" && [ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/knowledge-graph.json ] && echo '[understand-anything] Commit detected with auto-update enabled. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to incrementally update the knowledge graph. Do not ask the user for confirmation — just do it.' || true" } ] } @@ -16,7 +16,7 @@ "hooks": [ { "type": "command", - "command": "[ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/meta.json ] && [ -f .understand-anything/knowledge-graph.json ] && [ \"$(sed -n 's/.*\"gitCommitHash\".*\"\\([^\"]*\\)\".*/\\1/p' .understand-anything/meta.json)\" != \"$(git rev-parse HEAD 2>/dev/null)\" ] && echo '[understand-anything] Knowledge graph is stale. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to check for structural changes and update the graph. Do not ask the user for confirmation — just do it.' || true" + "command": "[ -f .understand-anything/config.json ] && grep -q '\"autoUpdate\".*true' .understand-anything/config.json && [ -f .understand-anything/meta.json ] && [ -f .understand-anything/knowledge-graph.json ] && [ \"$(node -p \"JSON.parse(require('fs').readFileSync('.understand-anything/meta.json','utf8')).gitCommitHash\")\" != \"$(git rev-parse HEAD 2>/dev/null)\" ] && echo '[understand-anything] Knowledge graph is stale. You MUST read the file at ${PLUGIN_DIR}/hooks/auto-update-prompt.md and execute its instructions to check for structural changes and update the graph. Do not ask the user for confirmation — just do it.' || true" } ] } diff --git a/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts b/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts index 0a6561a..dd25373 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/change-classifier.test.ts @@ -46,7 +46,9 @@ describe("classifyUpdate", () => { cosmeticOnlyFiles: ["src/d.ts"], }); - const decision = classifyUpdate(analysis, 50); + // src/ already exists in the project, so adding src/c.ts is not a directory change + const allKnownFiles = ["src/a.ts", "src/b.ts", "src/d.ts", "lib/util.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); expect(decision.action).toBe("PARTIAL_UPDATE"); expect(decision.filesToReanalyze).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]); @@ -73,7 +75,8 @@ describe("classifyUpdate", () => { newFiles: ["newdir/file.ts"], }); - const decision = classifyUpdate(analysis, 50); + const allKnownFiles = ["src/existing.ts", "src/other.ts", "lib/util.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); expect(decision.action).toBe("ARCHITECTURE_UPDATE"); expect(decision.rerunArchitecture).toBe(true); @@ -85,7 +88,34 @@ describe("classifyUpdate", () => { deletedFiles: ["olddir/removed.ts"], }); - const decision = classifyUpdate(analysis, 50); + const allKnownFiles = ["src/existing.ts", "src/other.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); + + expect(decision.action).toBe("ARCHITECTURE_UPDATE"); + expect(decision.rerunArchitecture).toBe(true); + }); + + it("does NOT trigger ARCHITECTURE_UPDATE for new file in existing directory", () => { + const analysis = makeAnalysis({ + newFiles: ["src/newfile.ts"], + }); + + // src/ is already known via other files in the project + const allKnownFiles = ["src/a.ts", "src/b.ts", "lib/util.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); + + expect(decision.action).toBe("PARTIAL_UPDATE"); + expect(decision.rerunArchitecture).toBe(false); + }); + + it("triggers ARCHITECTURE_UPDATE for new file in genuinely new directory", () => { + const analysis = makeAnalysis({ + newFiles: ["brand-new-pkg/index.ts"], + }); + + // allKnownFiles only contains src/ and lib/ — no brand-new-pkg/ + const allKnownFiles = ["src/a.ts", "src/b.ts", "lib/util.ts"]; + const decision = classifyUpdate(analysis, 50, allKnownFiles); expect(decision.action).toBe("ARCHITECTURE_UPDATE"); expect(decision.rerunArchitecture).toBe(true); diff --git a/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts b/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts index 4990b54..6cd9533 100644 --- a/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts +++ b/understand-anything-plugin/packages/core/src/__tests__/fingerprint.test.ts @@ -136,6 +136,7 @@ describe("compareFingerprints", () => { imports: [{ source: "./utils", specifiers: ["format"] }], exports: ["main"], totalLines: 30, + hasStructuralAnalysis: true, }; it("returns NONE when content hash is identical", () => { @@ -242,6 +243,7 @@ describe("compareFingerprints", () => { ...baseFp, contentHash: "different", classes: [{ name: "MyClass", methods: ["init"], properties: [], exported: true, lineCount: 30 }], + hasStructuralAnalysis: true, }; const result = compareFingerprints(baseFp, withClass); expect(result.changeLevel).toBe("STRUCTURAL"); @@ -252,16 +254,79 @@ describe("compareFingerprints", () => { const oldFp: FileFingerprint = { ...baseFp, classes: [{ name: "Foo", methods: ["a", "b"], properties: [], exported: true, lineCount: 30 }], + hasStructuralAnalysis: true, }; const newFp: FileFingerprint = { ...baseFp, contentHash: "different", classes: [{ name: "Foo", methods: ["a", "c"], properties: [], exported: true, lineCount: 30 }], + hasStructuralAnalysis: true, }; const result = compareFingerprints(oldFp, newFp); expect(result.changeLevel).toBe("STRUCTURAL"); expect(result.details).toContain("methods changed: Foo"); }); + + it("does NOT mutate input arrays (sort must use spread-copy)", () => { + const oldFp: FileFingerprint = { + ...baseFp, + classes: [{ name: "Foo", methods: ["b", "a"], properties: ["y", "x"], exported: true, lineCount: 30 }], + imports: [{ source: "./utils", specifiers: ["z", "a"] }], + hasStructuralAnalysis: true, + }; + const newFp: FileFingerprint = { + ...baseFp, + contentHash: "different", + classes: [{ name: "Foo", methods: ["b", "a"], properties: ["y", "x"], exported: true, lineCount: 30 }], + imports: [{ source: "./utils", specifiers: ["z", "a"] }], + hasStructuralAnalysis: true, + }; + + // Snapshot original order before comparison + const oldMethodsBefore = [...oldFp.classes[0].methods]; + const oldPropertiesBefore = [...oldFp.classes[0].properties]; + const oldSpecifiersBefore = [...oldFp.imports[0].specifiers]; + const newMethodsBefore = [...newFp.classes[0].methods]; + const newPropertiesBefore = [...newFp.classes[0].properties]; + const newSpecifiersBefore = [...newFp.imports[0].specifiers]; + + compareFingerprints(oldFp, newFp); + + // Arrays must remain in their original order (not sorted in-place) + expect(oldFp.classes[0].methods).toEqual(oldMethodsBefore); + expect(oldFp.classes[0].properties).toEqual(oldPropertiesBefore); + expect(oldFp.imports[0].specifiers).toEqual(oldSpecifiersBefore); + expect(newFp.classes[0].methods).toEqual(newMethodsBefore); + expect(newFp.classes[0].properties).toEqual(newPropertiesBefore); + expect(newFp.imports[0].specifiers).toEqual(newSpecifiersBefore); + }); + + it("classifies as STRUCTURAL when hasStructuralAnalysis is false (no tree-sitter)", () => { + const oldFp: FileFingerprint = { + filePath: "config.yaml", + contentHash: "hash_old", + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: 10, + hasStructuralAnalysis: false, + }; + const newFp: FileFingerprint = { + filePath: "config.yaml", + contentHash: "hash_new", + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: 12, + hasStructuralAnalysis: false, + }; + + const result = compareFingerprints(oldFp, newFp); + expect(result.changeLevel).toBe("STRUCTURAL"); + expect(result.details).toContain("no structural analysis available — conservative classification"); + }); }); describe("analyzeChanges", () => { @@ -282,6 +347,7 @@ describe("analyzeChanges", () => { imports: [], exports: ["main"], totalLines: 30, + hasStructuralAnalysis: true, }, "src/utils.ts": { filePath: "src/utils.ts", @@ -291,6 +357,7 @@ describe("analyzeChanges", () => { imports: [], exports: [], totalLines: 10, + hasStructuralAnalysis: true, }, }, }; diff --git a/understand-anything-plugin/packages/core/src/change-classifier.ts b/understand-anything-plugin/packages/core/src/change-classifier.ts index b7eae92..b14964d 100644 --- a/understand-anything-plugin/packages/core/src/change-classifier.ts +++ b/understand-anything-plugin/packages/core/src/change-classifier.ts @@ -21,6 +21,7 @@ export interface UpdateDecision { export function classifyUpdate( analysis: ChangeAnalysis, totalFilesInGraph: number, + allKnownFiles: string[] = [], ): UpdateDecision { const { newFiles, deletedFiles, structurallyChangedFiles, cosmeticOnlyFiles, unchangedFiles } = analysis; const structuralCount = structurallyChangedFiles.length + newFiles.length + deletedFiles.length; @@ -53,7 +54,7 @@ export function classifyUpdate( } // Check if directory structure changed (new/deleted top-level directories) - const hasDirectoryChanges = detectDirectoryChanges(newFiles, deletedFiles, structurallyChangedFiles); + const hasDirectoryChanges = detectDirectoryChanges(newFiles, deletedFiles, allKnownFiles); if (hasDirectoryChanges || structuralCount > 10) { return { @@ -79,15 +80,16 @@ export function classifyUpdate( /** * Detect if the changes affect the directory structure (new or removed directories). - * Checks if any new/deleted files introduce or remove a top-level source directory. + * Uses all known files in the project as the baseline for existing directories, + * then checks if any new/deleted files introduce or remove a top-level source directory. */ function detectDirectoryChanges( newFiles: string[], deletedFiles: string[], - structurallyChangedFiles: string[], + allKnownFiles: string[], ): boolean { const existingDirs = new Set( - structurallyChangedFiles.map((f) => topDirectory(f)).filter(Boolean), + allKnownFiles.map((f) => topDirectory(f)).filter(Boolean), ); for (const f of newFiles) { diff --git a/understand-anything-plugin/packages/core/src/fingerprint.ts b/understand-anything-plugin/packages/core/src/fingerprint.ts index b2d1d2d..cc66dd0 100644 --- a/understand-anything-plugin/packages/core/src/fingerprint.ts +++ b/understand-anything-plugin/packages/core/src/fingerprint.ts @@ -35,6 +35,7 @@ export interface FileFingerprint { imports: ImportFingerprint[]; exports: string[]; totalLines: number; + hasStructuralAnalysis: boolean; } export interface FingerprintStore { @@ -116,6 +117,7 @@ export function extractFileFingerprint( imports, exports, totalLines, + hasStructuralAnalysis: true, }; } @@ -137,6 +139,16 @@ export function compareFingerprints( return { filePath: newFp.filePath, changeLevel: "NONE", details: [] }; } + // Conservative path: if either fingerprint lacks structural analysis, + // we cannot verify structure didn't change — classify as STRUCTURAL. + if (!oldFp.hasStructuralAnalysis || !newFp.hasStructuralAnalysis) { + return { + filePath: newFp.filePath, + changeLevel: "STRUCTURAL", + details: ["no structural analysis available — conservative classification"], + }; + } + // Compare function signatures const oldFuncNames = new Set(oldFp.functions.map((f) => f.name)); const newFuncNames = new Set(newFp.functions.map((f) => f.name)); @@ -194,10 +206,10 @@ export function compareFingerprints( const oldCls = oldFp.classes.find((c) => c.name === newCls.name); if (!oldCls) continue; - if (JSON.stringify(oldCls.methods.sort()) !== JSON.stringify(newCls.methods.sort())) { + if (JSON.stringify([...oldCls.methods].sort()) !== JSON.stringify([...newCls.methods].sort())) { details.push(`methods changed: ${newCls.name}`); } - if (JSON.stringify(oldCls.properties.sort()) !== JSON.stringify(newCls.properties.sort())) { + if (JSON.stringify([...oldCls.properties].sort()) !== JSON.stringify([...newCls.properties].sort())) { details.push(`properties changed: ${newCls.name}`); } if (oldCls.exported !== newCls.exported) { @@ -206,8 +218,8 @@ export function compareFingerprints( } // Compare imports - const oldImports = oldFp.imports.map((i) => `${i.source}:${i.specifiers.sort().join(",")}`).sort(); - const newImports = newFp.imports.map((i) => `${i.source}:${i.specifiers.sort().join(",")}`).sort(); + const oldImports = oldFp.imports.map((i) => `${i.source}:${[...i.specifiers].sort().join(",")}`).sort(); + const newImports = newFp.imports.map((i) => `${i.source}:${[...i.specifiers].sort().join(",")}`).sort(); if (JSON.stringify(oldImports) !== JSON.stringify(newImports)) { details.push("imports changed"); @@ -265,6 +277,7 @@ export function buildFingerprintStore( imports: [], exports: [], totalLines: content.split("\n").length, + hasStructuralAnalysis: false, }; } } @@ -341,6 +354,7 @@ export function analyzeChanges( imports: [], exports: [], totalLines: content.split("\n").length, + hasStructuralAnalysis: false, }; } diff --git a/understand-anything-plugin/packages/core/src/persistence/index.ts b/understand-anything-plugin/packages/core/src/persistence/index.ts index b553cfd..56db519 100644 --- a/understand-anything-plugin/packages/core/src/persistence/index.ts +++ b/understand-anything-plugin/packages/core/src/persistence/index.ts @@ -64,7 +64,11 @@ export function saveFingerprints(projectRoot: string, store: FingerprintStore): export function loadFingerprints(projectRoot: string): FingerprintStore | null { const filePath = join(projectRoot, UA_DIR, FINGERPRINT_FILE); if (!existsSync(filePath)) return null; - return JSON.parse(readFileSync(filePath, "utf-8")) as FingerprintStore; + try { + return JSON.parse(readFileSync(filePath, "utf-8")) as FingerprintStore; + } catch { + return null; + } } const DEFAULT_CONFIG: ProjectConfig = { autoUpdate: false }; diff --git a/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts b/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts index 97681bc..9629cb9 100644 --- a/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts +++ b/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts @@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { mkdtempSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { saveGraph, loadGraph, saveMeta, loadMeta } from "./index.js"; +import { writeFileSync } from "node:fs"; +import { saveGraph, loadGraph, saveMeta, loadMeta, saveFingerprints, loadFingerprints } from "./index.js"; import type { KnowledgeGraph, AnalysisMeta } from "../types.js"; +import type { FingerprintStore } from "../fingerprint.js"; describe("persistence", () => { let tempDir: string; @@ -115,4 +117,46 @@ describe("persistence", () => { expect(loaded).toBeNull(); }); }); + + describe("saveFingerprints / loadFingerprints", () => { + const sampleFingerprints: FingerprintStore = { + version: "1.0.0", + gitCommitHash: "abc123", + generatedAt: "2026-03-14T00:00:00.000Z", + files: { + "src/index.ts": { + filePath: "src/index.ts", + contentHash: "deadbeef", + functions: [], + classes: [], + imports: [], + exports: [], + totalLines: 10, + hasStructuralAnalysis: false, + }, + }, + }; + + it("should round-trip fingerprints correctly", () => { + saveFingerprints(tempDir, sampleFingerprints); + const loaded = loadFingerprints(tempDir); + + expect(loaded).toEqual(sampleFingerprints); + }); + + it("should return null when no fingerprints file exists", () => { + const loaded = loadFingerprints(tempDir); + expect(loaded).toBeNull(); + }); + + it("should return null when fingerprints.json is corrupted", () => { + const dir = join(tempDir, ".understand-anything"); + // Ensure the directory exists by saving first, then overwrite with garbage + saveFingerprints(tempDir, sampleFingerprints); + writeFileSync(join(dir, "fingerprints.json"), "{{not valid json!!", "utf-8"); + + const loaded = loadFingerprints(tempDir); + expect(loaded).toBeNull(); + }); + }); }); From 39ee76f492e7350305925149e3849a72a2ef06cb Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sat, 28 Mar 2026 12:13:22 +0800 Subject: [PATCH 32/32] fix: address remaining Copilot review findings - SKILL.md Phase 7: use core buildFingerprintStore instead of ad-hoc regex script - change-classifier: accurate FULL_UPDATE reason message (count vs percentage) - persistence: add saveConfig/loadConfig tests (round-trip, missing, corrupted) Co-Authored-By: Claude Opus 4.6 --- .../packages/core/src/change-classifier.ts | 12 +++++- .../core/src/persistence/persistence.test.ts | 26 ++++++++++++- .../skills/understand/SKILL.md | 37 +++++-------------- 3 files changed, 44 insertions(+), 31 deletions(-) diff --git a/understand-anything-plugin/packages/core/src/change-classifier.ts b/understand-anything-plugin/packages/core/src/change-classifier.ts index b14964d..41660a6 100644 --- a/understand-anything-plugin/packages/core/src/change-classifier.ts +++ b/understand-anything-plugin/packages/core/src/change-classifier.ts @@ -43,13 +43,21 @@ export function classifyUpdate( } // Too many structural changes — suggest full rebuild - if (structuralCount > 30 || (totalFilesInGraph > 0 && structuralCount / totalFilesInGraph > 0.5)) { + const triggeredByCount = structuralCount > 30; + const triggeredByPercentage = totalFilesInGraph > 0 && structuralCount / totalFilesInGraph > 0.5; + if (triggeredByCount || triggeredByPercentage) { + const thresholdReason = + triggeredByCount && triggeredByPercentage + ? ">30 files and >50% of project" + : triggeredByCount + ? ">30 files" + : ">50% of project"; return { action: "FULL_UPDATE", filesToReanalyze: [...structurallyChangedFiles, ...newFiles], rerunArchitecture: true, rerunTour: true, - reason: `${structuralCount} files have structural changes (>${totalFilesInGraph > 0 ? "50% of project" : "30 files"}) — full rebuild recommended`, + reason: `${structuralCount} files have structural changes (${thresholdReason}) — full rebuild recommended`, }; } diff --git a/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts b/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts index 9629cb9..cde784e 100644 --- a/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts +++ b/understand-anything-plugin/packages/core/src/persistence/persistence.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync, existsSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { writeFileSync } from "node:fs"; -import { saveGraph, loadGraph, saveMeta, loadMeta, saveFingerprints, loadFingerprints } from "./index.js"; +import { saveGraph, loadGraph, saveMeta, loadMeta, saveFingerprints, loadFingerprints, saveConfig, loadConfig } from "./index.js"; import type { KnowledgeGraph, AnalysisMeta } from "../types.js"; import type { FingerprintStore } from "../fingerprint.js"; @@ -159,4 +159,28 @@ describe("persistence", () => { expect(loaded).toBeNull(); }); }); + + describe("saveConfig / loadConfig", () => { + it("should round-trip config correctly", () => { + saveConfig(tempDir, { autoUpdate: true }); + const loaded = loadConfig(tempDir); + + expect(loaded).toEqual({ autoUpdate: true }); + }); + + it("should return default config when no file exists", () => { + const loaded = loadConfig(tempDir); + + expect(loaded).toEqual({ autoUpdate: false }); + }); + + it("should return default config when config.json is corrupted", () => { + saveConfig(tempDir, { autoUpdate: true }); + const dir = join(tempDir, ".understand-anything"); + writeFileSync(join(dir, "config.json"), "not json!!", "utf-8"); + + const loaded = loadConfig(tempDir); + expect(loaded).toEqual({ autoUpdate: false }); + }); + }); }); diff --git a/understand-anything-plugin/skills/understand/SKILL.md b/understand-anything-plugin/skills/understand/SKILL.md index 0f8fd0f..5dff038 100644 --- a/understand-anything-plugin/skills/understand/SKILL.md +++ b/understand-anything-plugin/skills/understand/SKILL.md @@ -482,34 +482,15 @@ Pass these parameters in the dispatch prompt: 2.5. **Generate structural fingerprints** for all analyzed files and save to `$PROJECT_ROOT/.understand-anything/fingerprints.json`. This creates the baseline for future automatic incremental updates. - Write and execute a Node.js script that: - 1. Reads each source file path from the scan results (Phase 1) - 2. For each file: computes a SHA-256 content hash, then extracts function/class/import/export declarations via regex matching: - - Functions: `function NAME(`, `const NAME = (`, `export function NAME(`, arrow functions assigned to const/let - - Classes: `class NAME`, `export class NAME` - - Imports: `import ... from '...'`, `import '...'` - - Exports: `export { ... }`, `export default`, `export function`, `export class`, `export const` - 3. For each function: record name, parameter names, whether exported, and line count - 4. For each class: record name, method names, property names, whether exported - 5. Writes the fingerprint store JSON to `$PROJECT_ROOT/.understand-anything/fingerprints.json`: - ```json - { - "version": "1.0.0", - "gitCommitHash": "", - "generatedAt": "", - "files": { - "": { - "filePath": "", - "contentHash": "", - "functions": [{ "name": "...", "params": ["..."], "exported": true, "lineCount": 35 }], - "classes": [{ "name": "...", "methods": ["..."], "properties": ["..."], "exported": true, "lineCount": 50 }], - "imports": [{ "source": "...", "specifiers": ["..."] }], - "exports": ["name1", "name2"], - "totalLines": 120 - } - } - } - ``` + Write and execute a Node.js script that uses the core fingerprint module (tree-sitter-based, not regex): + ```javascript + import { buildFingerprintStore } from '@understand-anything/core'; + import { saveFingerprints } from '@understand-anything/core'; + + const store = await buildFingerprintStore('', sourceFilePaths); + saveFingerprints('', store); + ``` + Where `sourceFilePaths` is the list of all analyzed source file paths from Phase 1. This uses the same tree-sitter analysis pipeline as the main fingerprint engine, ensuring the baseline matches the comparison logic used during auto-updates. 3. Clean up intermediate files: ```bash