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.
This commit is contained in:
Aditya
2026-03-23 15:20:10 +05:30
Unverified
parent d539fd45ba
commit fbe3cf4ee0
10 changed files with 1362 additions and 1 deletions
@@ -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 <lastCommitHash>..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: `<projectName from existing graph>` — `<projectDescription>`
> Frameworks detected: `<frameworks from existing graph>`
> Languages: `<languages from existing graph>`
>
> **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: `<projectName>`
> Languages: `<languages>`
> Batch index: `1`
> Write output to: `$PROJECT_ROOT/.understand-anything/intermediate/batch-1.json`
>
> All project files (for import resolution):
> `<file list from existing graph nodes>`
>
> Files to analyze in this batch:
> 1. `<path>` (`<sizeLines>` lines)
> ...
4. After batch(es) complete, read each `batch-<N>.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": "<ISO 8601 timestamp>",
"gitCommitHash": "<current commit hash>",
"version": "1.0.0",
"analyzedFiles": <total file count in graph>
}
```
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).
@@ -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"
}
]
}
]
}
}
@@ -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> = {}): 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");
});
});
@@ -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);
});
});
@@ -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(", ");
}
@@ -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<string, FileFingerprint>;
}
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<string, FileFingerprint> = {};
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,
};
}
@@ -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";
@@ -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 };
}
}
@@ -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 }>;
@@ -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": "<commit hash>",
"generatedAt": "<ISO timestamp>",
"files": {
"<filePath>": {
"filePath": "<filePath>",
"contentHash": "<sha256>",
"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