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