diff --git a/.gitignore b/.gitignore index a1475199..d1b72148 100644 --- a/.gitignore +++ b/.gitignore @@ -216,7 +216,7 @@ _bmad-output # VS Code settings (may contain user-specific or machine-specific settings) .vscode/settings.json - +.tokensave/ .serena /graphify-out/ @@ -228,3 +228,6 @@ graphify-out/cost.json # local only # graphify-out/cache/ # optional: commit for speed, skip to keep repo small # Generated wiki (graph-it wiki output) /wiki/ + +# Temp fixtures generated by PythonSymbolAnalyzer property tests (crash orphans) +/tests/fixtures/python-project/temp_*.py diff --git a/.vscodeignore b/.vscodeignore index 0e78e72d..06869624 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -19,6 +19,8 @@ tsconfig.tsbuildinfo meta.json metadata.json DEVELOPMENT.md +CLAUDE.md +.claude/** # Source code (bundled in dist/) src/** diff --git a/README.md b/README.md index d87dca8b..8f068ef2 100644 --- a/README.md +++ b/README.md @@ -413,6 +413,7 @@ graph-it wiki # Generate a navigable markdown wiki from the ca graph-it serve # Launch MCP stdio server (for AI clients) graph-it tool --list # List all 22 CLI analysis tools graph-it tool [args] # Run any MCP tool directly from the terminal +graph-it export [path] [--format html] # Export graph as standalone HTML (vis.js) graph-it update # Update graph-it to the latest version graph-it install # Symlink the binary into your system PATH (opt-in) ``` @@ -448,7 +449,7 @@ This output can be pasted directly into any Markdown renderer (GitHub, Notion, V **Use as MCP server (no VS Code):** Run `graph-it serve` and point your AI client at it — see [Manual MCP Server Configuration](#manual-mcp-server-configuration). -**Interactive REPL:** Run `graph-it` with no arguments to enter interactive mode. Type `/query how does X work` to ask natural language questions about your codebase (no quotes needed). Use `/wiki` to generate a navigable markdown wiki from the call graph. Other slash commands: `/trace`, `/summary`, `/architecture`, `/check`, `/cycles`, `/format`, `/help`. +**Interactive REPL:** Run `graph-it` with no arguments to enter interactive mode. Type `/query how does X work` to ask natural language questions about your codebase (no quotes needed). Use `/wiki` to generate a navigable markdown wiki from the call graph. Other slash commands: `/trace`, `/summary`, `/architecture`, `/check`, `/cycles`, `/export` (export dependency graph as standalone HTML), `/format`, `/help`. **Full CLI reference:** See **[docs/CLI.md](docs/CLI.md)** for complete documentation on every command, all options, output format examples, advanced workflows, and the full MCP tools reference. diff --git a/changelog.md b/changelog.md index aef657a7..5ff3e4d3 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,28 @@ # Changelog +## v1.11.0 + +### Added — Path-based community detection (F4) + +- **`PathCommunityDetector`**: Replaces Louvain algorithm. Groups files by first non-umbrella subdirectory (e.g. `src/analyzer/Spider.ts` → community `analyzer`). Umbrella dirs skipped: `src`, `tests`, `test`, `lib`, `app`, `packages`, `dist`, `out`. +- **`Spider.workspaceRoot` getter**: Exposes `config.rootDir` for downstream consumers. Threaded from `Spider` → `GraphViewService` → `computeNodeMetadata` → `detectPathCommunities`. +- **Community legend in webview**: Header "Import clusters", subtitle "Groups of closely connected files". `buildCommunityLegend` uses `path.relative(workspaceRoot, filePath)` for correct labels on scoped exports. + +### Added — HTML vis.js export + +- **`graph-it export --format html` / REPL `/export`**: Exports an interactive vis.js graph. Toolbar with Fit / Physics / Reset buttons. Physics disabled after stabilization so nodes stay when dragged. Community legend overlay (bottom-left) with domain names and palette colors. +- **REPL `/export` command**: Standalone (no `--format html` needed). Optional `--output ` / `-o ` flag. Respects `/path` workspace narrowing as default scope; falls back to `state.lastFile` → `state.workspaceRoot` → full workspace. + +### Added — MCP `crawl_dependency_graph` + +- **`hubScore` + `communityId` on each node**: `crawl_dependency_graph` now populates both fields using the same `PathCommunityDetector` logic as the VS Code extension and CLI. All three surfaces are aligned. + +### Fixed + +- **Community label showed workspace dir instead of functional domain**: Shallow files reduced `workspacePrefixLen` cap incorrectly — fixed with `path.relative(workspaceRoot)` in `buildCommunityLegend`. +- **`normalizePath` applied to `outputPath`**: Cross-platform path compliance (Règle 03). +- **Windows CI path assertions in `ExportHtmlCommand` tests**: Test comparisons now use `normalizePath` to handle Windows separators. + ## v1.10.0 ### Added — graph-it wiki diff --git a/docs/CLI.md b/docs/CLI.md index 8f28254c..532a6b40 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -180,6 +180,7 @@ graph-it [options] | `/check` | Find unused exports | | `/format` | Set preferred output format for the session | | `/command` | Run a raw CLI command line inside REPL | +| `/export` | Export dependency graph as standalone HTML (vis.js). Optional `--output ` / `-o `. Scope: active file context → workspace scope (set via `/path`) → full workspace | | `/help` | Show REPL command help | | `/quit` | Exit interactive mode | @@ -968,7 +969,11 @@ graph-it tool crawl_dependency_graph --entryFile=/abs/path/to/index.ts graph-it tool crawl_dependency_graph --entryFile=/abs/path/to/index.ts --maxDepth=5 ``` -**Output fields:** `files[]`, `edges[]`, `cycles[]`, `totalFiles`, `totalEdges` +**Output fields:** `nodes[]`, `edges[]`, `circularDependencies[]`, `nodeCount`, `edgeCount` + +Each node in `nodes[]` includes: +- `hubScore` (0–1): proportion of workspace files that import this node +- `communityId` (0+): path-based functional cluster — 0 = isolated, 1+ = first non-umbrella subdirectory group (e.g. files under `src/analyzer/` → same `communityId`) --- diff --git a/docs/architecture/ADR-F2-01-hubscore-source-of-truth.md b/docs/architecture/ADR-F2-01-hubscore-source-of-truth.md new file mode 100644 index 00000000..0f5a7834 --- /dev/null +++ b/docs/architecture/ADR-F2-01-hubscore-source-of-truth.md @@ -0,0 +1,57 @@ +# ADR-F2-01 : Source de vérité hubScore + +## Status + +Proposed + +## Context + +Story F2 ajoute `nodeMetadata?: Record` à `GraphData` (src/shared/graph-types.ts). +`GraphNodeMetadata` inclut `hubScore: number`. + +`parentCounts?: Record` existe déjà dans `GraphData` et est transmis au webview. +F1 (mergée) a implémenté `computeHubScores(visibleNodes, parentCounts)` dans `src/webview/components/reactflow/buildGraph.ts` — normalise sur les noeuds visibles uniquement. +`src/analyzer/wiki/WikiGenerator.ts` calcule déjà un `hubScore` workspace-wide pour les articles wiki. + +Consommateurs qui ont besoin d'un hubScore stable : MCP (`graphitlive_analyze_dependencies` retourne `GraphData` sérialisé), CLI, export HTML F3, community detection F4 (Louvain). + +## Decision + +**Option A retenue : Spider (analyzer layer) est la seule source de vérité pour `hubScore`.** + +Spider calcule `hubScore = parentCounts[file] / max(parentCounts)` normalisé [0-1] sur le workspace entier, lors du full-crawl uniquement (pas de mise à jour incrémentale partielle pour éviter un `maxParentCount` sous-estimé). Le résultat est stocké dans `nodeMetadata` de `GraphData`. + +`computeHubScores` de F1 est remplacé par une lecture directe de `nodeMetadata[path].hubScore` dans `buildGraph.ts`. + +Le calcul `hubScore` de `WikiGenerator.ts` est unifié : WikiGenerator lit `nodeMetadata.hubScore` depuis `GraphData` au lieu de recalculer. + +## Consequences + +**Positives** +- Source unique : un seul endroit à maintenir, cohérence garantie entre MCP, CLI, export HTML (F3) et webview. +- F3 (export HTML) et F4 (Louvain) lisent `nodeMetadata` sans recalcul. +- Conforme Règle 01 : pur Node.js dans l'analyzer, zéro import vscode. +- Suppression de duplication : calcul Wiki absorbé, `computeHubScores` webview supprimé. + +**Negatives** +- Hub local dans un sous-graphe filtré peut afficher un score faible (exemple : noeud central d'un sous-graphe de 10 fichiers sur un workspace de 1000). Accepté : la vue webview gagne en cohérence avec MCP et CLI au détriment de la relativité locale. +- `nodeMetadata` doit etre versionné avant F4 : si Louvain modifie la sémantique de `hubScore`, un champ `hubScoreVersion?: string` ou une migration breaking sera nécessaire. + +**Contrats modifiés** +- `src/shared/graph-types.ts` : ajout `GraphNodeMetadata` + `nodeMetadata?` sur `GraphData`. +- `src/analyzer/Spider.ts` (ou `GraphCrawler`) : calcul `hubScore` sur full-crawl, guard `max === 0 → score = 0`. +- `src/analyzer/wiki/WikiGenerator.ts` : lecture `nodeMetadata.hubScore` au lieu du calcul interne. +- `src/webview/components/reactflow/buildGraph.ts` : suppression `computeHubScores`, lecture `nodeMetadata[path]?.hubScore ?? 0`. +- `src/mcp/` : Zod schema `graphitlive_analyze_dependencies` étendu avec `nodeMetadata` optionnel. + +## Alternatives rejetées + +**Option B — buildGraph.ts (webview) uniquement** : score instable (change à chaque filtre de vue), absent de MCP et CLI. `parentCounts` brut transmis au webview ne suffit pas : MCP consomme `GraphData` sérialisé sans passer par le webview, et F3/F4 opèrent hors bundle webview. + +**Option C — Double calcul (global + local)** : rejetée sur recommandation de Marine. Deux sources sans règle de priorité formelle = comportement indéfini selon le chemin d'exécution (webview vs MCP vs export). Duplication de logique de normalisation dans deux bundles séparés (`dist/extension.js` et `dist/webview.js`) : toute évolution de formule (F4 Louvain) doit être propagée manuellement — risque de régression silencieux. + +--- + +Date : 2026-06-27 +Auteur : Antoine (architecte système) +Review : Marine (devil's advocate) diff --git a/docs/architecture/ADR-F3-01-visjs-inlining-strategy.md b/docs/architecture/ADR-F3-01-visjs-inlining-strategy.md new file mode 100644 index 00000000..eb3d7508 --- /dev/null +++ b/docs/architecture/ADR-F3-01-visjs-inlining-strategy.md @@ -0,0 +1,22 @@ +# ADR-F3-01 — Stratégie d'inlining vis.js dans le HTML autoportant + +**Date** : 2026-06-29 +**Statut** : Accepted + +## Contexte +Story F3 produit un HTML autoportant (zéro dépendance réseau) avec vis-network (~1.5MB minifié). CLI distribué via `npm install -g graph-it-live` ; `vis-network` est dans `dependencies`, présent dans le `node_modules` global du package. + +## Décision +Lecture au runtime via `createRequire(import.meta.url)` (compatible ESM) + `fs.readFileSync` sur le bundle `vis-network/standalone/umd/vis-network.min.js`. Inlining dans `', '<\\/script>'); + + const unusedSet = new Set(config.unusedEdges.map(id => normalizePath(id))); + + const nodesJson = JSON.stringify(config.nodes.map(n => ({ + id: normalizePath(n.id), + label: htmlEscape(n.label), + title: htmlEscape(normalizePath(n.id)), + color: nodeColor(n.communityId, n.hubScore), + borderWidth: hubScoreBorderWidth(n.hubScore), + }))); + + const edgesJson = JSON.stringify(config.edges.map(e => ({ + from: normalizePath(e.from), + to: normalizePath(e.to), + id: normalizePath(e.id), + dashes: unusedSet.has(normalizePath(e.id)), + arrows: 'to', + }))); + + const title = htmlEscape(config.workspaceName); + const legend = buildCommunityLegend(config.nodes, config.workspaceRoot); + + const html = ` + + + + ${title} — Graph-It + + + +
${title}
+
+ + + +
+
Laying out graph…
+
+ + + ${legend} + +`; + + fs.mkdirSync(path.dirname(config.outputPath), { recursive: true }); + fs.writeFileSync(config.outputPath, html, 'utf-8'); +} diff --git a/src/analyzer/wiki/WikiGenerator.ts b/src/analyzer/wiki/WikiGenerator.ts index 89aea858..8823bf2b 100644 --- a/src/analyzer/wiki/WikiGenerator.ts +++ b/src/analyzer/wiki/WikiGenerator.ts @@ -9,6 +9,7 @@ import type { WikiLink, WikiSymbol, } from "../../shared/wiki-types.js"; +import type { GraphNodeMetadata } from "../../shared/graph-types.js"; import { analyzeControlFlow } from "./ControlFlowAnalyzer.js"; import { buildArchitectureDiagram, @@ -136,15 +137,17 @@ export class WikiGenerator { private readonly topHubsLimit: number; private readonly scope: string | undefined; private readonly exclude: string[] | undefined; + private readonly externalNodeMetadata: Record | undefined; constructor(opts: WikiGeneratorOptions) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.db = opts.db; this.outputDir = opts.outputDir; this.workspaceRoot = opts.workspaceRoot; this.topHubsLimit = opts.topHubsLimit ?? 10; this.scope = opts.scope; this.exclude = opts.exclude; + this.externalNodeMetadata = opts.nodeMetadata; } async generate(): Promise { @@ -154,7 +157,7 @@ export class WikiGenerator { this.exclude, ); - // Build hub score map + // Build hub score map (DB-derived, 0-100 scale) const hubMap = this.buildHubMap(); // Enumerate files — with scope/exclude applied @@ -164,7 +167,12 @@ export class WikiGenerator { // Build all articles (pure, no I/O) const articles: WikiArticle[] = files.map((filePath) => { const normalized = normalizePath(filePath); - const score = hubMap.get(normalized) ?? 0; + // ADR-F2-01: prefer hubScore from GraphData.nodeMetadata when available. + // externalNodeMetadata.hubScore is in [0-1]; scale to 0-100 to match WikiArticle range. + const externalScore = this.externalNodeMetadata?.[normalized]?.hubScore; + const score = externalScore !== undefined + ? Math.min(Math.round(externalScore * 100), 100) + : (hubMap.get(normalized) ?? 0); return this.buildArticle(normalized, score); }); diff --git a/src/cli/commands/ExportHtmlCommand.ts b/src/cli/commands/ExportHtmlCommand.ts new file mode 100644 index 00000000..84308741 --- /dev/null +++ b/src/cli/commands/ExportHtmlCommand.ts @@ -0,0 +1,152 @@ +import { parseArgs } from 'node:util'; +import * as path from 'node:path'; +import { normalizePath } from '../../shared/path'; +import { exportHtml } from '../../analyzer/export/HtmlExporter'; +import { computeNodeMetadata } from '../../analyzer/NodeMetadataBuilder'; +import type { CliRuntime } from '../runtime'; +import type { GraphData } from '../../shared/graph-types'; + +type RawRecord = Record; + +function getStringValue(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function getNormalizedNodeId(node: RawRecord): string { + const id = getStringValue(node['id']); + if (id) { + return normalizePath(id); + } + + const filePath = getStringValue(node['path']); + return filePath ? normalizePath(filePath) : ''; +} + +function getNormalizedEdgeEndpoint(edge: RawRecord, key: 'source' | 'target'): string { + const value = getStringValue(edge[key]); + return value ? normalizePath(value) : ''; +} + +function parseArchitectureOutput(raw: RawRecord): { + rawNodes: RawRecord[]; + rawEdges: RawRecord[]; +} { + const rawNodes = Array.isArray(raw['nodes']) ? (raw['nodes'] as RawRecord[]) : []; + const rawEdges = Array.isArray(raw['edges']) ? (raw['edges'] as RawRecord[]) : []; + return { rawNodes, rawEdges }; +} + +function buildGraphData(rawNodes: RawRecord[], rawEdges: RawRecord[]): GraphData { + const graphData: GraphData = { + nodes: rawNodes.map(getNormalizedNodeId), + edges: rawEdges.map(edge => ({ + source: getNormalizedEdgeEndpoint(edge, 'source'), + target: getNormalizedEdgeEndpoint(edge, 'target'), + })), + unusedEdges: [], + }; + + const parentCounts: Record = {}; + for (const node of rawNodes) { + const depCount = Number(node['dependentCount'] ?? 0); + if (depCount <= 0) { + continue; + } + + const id = getNormalizedNodeId(node); + if (id) { + parentCounts[id] = depCount; + } + } + + if (Object.keys(parentCounts).length > 0) { + graphData.parentCounts = parentCounts; + } + + return graphData; +} + +function filterGraphByScope(graphData: GraphData, resolvedScope?: string): void { + if (!resolvedScope) { + return; + } + + const isFile = graphData.nodes.includes(resolvedScope); + let kept: Set; + if (isFile) { + kept = new Set([resolvedScope]); + for (const edge of graphData.edges) { + if (edge.source === resolvedScope) { + kept.add(edge.target); + } + if (edge.target === resolvedScope) { + kept.add(edge.source); + } + } + } else { + const prefix = resolvedScope.endsWith('/') ? resolvedScope : resolvedScope + '/'; + kept = new Set(graphData.nodes.filter(node => node === resolvedScope || node.startsWith(prefix))); + } + + graphData.nodes = graphData.nodes.filter(node => kept.has(node)); + graphData.edges = graphData.edges.filter(edge => kept.has(edge.source) && kept.has(edge.target)); + if (graphData.unusedEdges) { + graphData.unusedEdges = graphData.unusedEdges.filter(edge => kept.has(edge)); + } +} + +export async function runExportHtml( + runtime: CliRuntime, + workspaceName: string, + args: string[], + scopePath?: string, +): Promise { + const { values: cmdValues, positionals } = parseArgs({ + args, + options: { output: { type: 'string', short: 'o' } }, + strict: false, + allowPositionals: true, + }); + const outputPath = normalizePath(typeof cmdValues.output === 'string' + ? path.resolve(cmdValues.output) + : path.join(process.cwd(), 'graph.html')); + + // Resolve scope: positional arg > scopePath from session state + const rawScope = positionals[0] ?? scopePath; + const resolvedScope = rawScope ? normalizePath(path.resolve(runtime.workspaceRoot, rawScope)) : undefined; + + // Build graph data by delegating to the architecture command (json format) + const { run: architectureRun } = await import('./architecture.js'); + const jsonOutput = await architectureRun([], runtime, 'json'); + const raw = JSON.parse(jsonOutput) as RawRecord; + const { rawNodes, rawEdges } = parseArchitectureOutput(raw); + const graphData = buildGraphData(rawNodes, rawEdges); + computeNodeMetadata(graphData, runtime.workspaceRoot); // populates hubScore + communityId + + // Scope filter: positional arg or state.lastFile narrows the graph + filterGraphByScope(graphData, resolvedScope); + + const nodes = graphData.nodes.map(filePath => ({ + id: filePath, + label: path.basename(filePath), + hubScore: graphData.nodeMetadata?.[normalizePath(filePath)]?.hubScore, + communityId: graphData.nodeMetadata?.[normalizePath(filePath)]?.communityId, + })); + + const edges = graphData.edges.map(e => ({ + from: e.source, + to: e.target, + id: `${e.source}::${e.target}`, + })); + + exportHtml({ + nodes, + edges, + unusedEdges: graphData.unusedEdges ?? [], + workspaceName, + outputPath, + workspaceRoot: runtime.workspaceRoot, + }); + + process.stdout.write(`Graph exported to ${outputPath}\n`); +} diff --git a/src/cli/commands/repl.ts b/src/cli/commands/repl.ts index 1e39bf08..f5934cf4 100644 --- a/src/cli/commands/repl.ts +++ b/src/cli/commands/repl.ts @@ -142,6 +142,7 @@ function buildReplHelpText(state: ReturnType): string ' /check Find unused exports', ' /query Query the codebase with natural language', ' /wiki Generate a markdown wiki from the call graph', + ' /export Export graph as standalone HTML (vis.js)', ' /format Change the default display format', ' /command Run a raw CLI command line', ' /help Show this help', @@ -154,6 +155,7 @@ function buildReplHelpText(state: ReturnType): string ' /cycles src/cli/index.ts', ' /trace src/index.ts#main', ' /architecture --format mermaid', + ' /export --output my-graph.html', '', `${DIM}session:${RESET} format=${state.preferredFormat} last-file=${lastFile}`, ].join('\n'); @@ -569,6 +571,18 @@ async function handleTypedSessionCommand( return handlePathSessionCommand(args, state, runtime, allFiles, options); } + if (command === 'export') { + const workspaceName = path.basename(runtime.workspaceRoot); + const { runExportHtml } = await import('./ExportHtmlCommand.js'); + // Respect /path narrowing: if state.workspaceRoot was narrowed to a subdir, + // use it as default scope so /export stays within the active workspace scope. + const defaultScope = state.workspaceRoot !== runtime.workspaceRoot + ? state.workspaceRoot + : undefined; + await runExportHtml(runtime, workspaceName, args, state.lastFile ?? defaultScope); + return { command: 'export', skipPostAction: true }; + } + if (command !== 'format') { if (command !== 'file') { return undefined; diff --git a/src/cli/formatter.ts b/src/cli/formatter.ts index 2c9106da..af6dde43 100644 --- a/src/cli/formatter.ts +++ b/src/cli/formatter.ts @@ -10,9 +10,9 @@ import { estimateTokenSavings, jsonToToon } from "../shared/toon"; -export type CliOutputFormat = "text" | "json" | "toon" | "markdown" | "mermaid"; +export type CliOutputFormat = "text" | "json" | "toon" | "markdown" | "mermaid" | "html"; -export const CLI_OUTPUT_FORMATS: readonly CliOutputFormat[] = ["text", "json", "toon", "markdown", "mermaid"]; +export const CLI_OUTPUT_FORMATS: readonly CliOutputFormat[] = ["text", "json", "toon", "markdown", "mermaid", "html"]; /** Commands that can produce graph / mermaid output */ const GRAPH_COMMANDS = new Set(["trace", "path", "scan", "architecture"]); @@ -55,6 +55,8 @@ export function formatOutput(data: unknown, format: CliOutputFormat, command: st return formatMarkdown(data, command); case "mermaid": return formatMermaid(data, command); + case "html": + throw new Error('Format "html" must be handled before formatOutput — use runExportHtml directly.'); } } @@ -418,6 +420,8 @@ function finalizeMermaid(lines: string[]): string { } function buildMermaidFromGraph(graph: GraphLike): string | null { + // TODO F4b: community subgraphs in Mermaid — GraphLike nodes carry no communityId today; + // requires either extending GraphLike or a dedicated community-aware formatter. const nodes = graph.nodes ?? []; const edges = graph.edges ?? []; if (nodes.length === 0 && edges.length === 0) return null; diff --git a/src/cli/index.ts b/src/cli/index.ts index dfac70c1..bdee049d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -353,6 +353,18 @@ async function dispatch( const { run } = await import("./commands/wiki.js"); return run(args, runtime, format); } + case "export": { + if (format === "html") { + const { runExportHtml } = await import("./commands/ExportHtmlCommand.js"); + const workspaceName = runtime.workspaceRoot.split("/").pop() ?? "workspace"; + await runExportHtml(runtime, workspaceName, args); + return ""; + } + throw new CliError( + `Command "export" only supports --format html. Got: "${format}".`, + ExitCode.GENERAL_ERROR, + ); + } default: throw new CliError( `Unknown command "${command}". Run graph-it --help for usage.`, diff --git a/src/cli/repl/ink/ReplInkApp.ts b/src/cli/repl/ink/ReplInkApp.ts index 24ead62e..4268f5e4 100644 --- a/src/cli/repl/ink/ReplInkApp.ts +++ b/src/cli/repl/ink/ReplInkApp.ts @@ -86,6 +86,7 @@ const SLASH_COMMANDS: SlashCommandEntry[] = [ { command: '/scan', description: 'Force indexing scan' }, { command: '/query', description: 'Query the codebase with natural language', argsHint: '/query "" [--depth N] [--token-budget N]' }, { command: '/wiki', description: 'Generate a markdown wiki from the call graph', argsHint: '/wiki [--output ] [--top N] [--format markdown|json|toon]' }, + { command: '/export', description: 'Export graph as standalone HTML (vis.js)', argsHint: '/export [path] [--output |-o ]' }, { command: '/command', description: 'Run raw command line', argsHint: '/command ' }, { command: '/format', description: 'Set default output format', argsHint: '/format ' }, { command: '/help', description: 'Show REPL help' }, diff --git a/src/extension/GraphProvider.ts b/src/extension/GraphProvider.ts index 77cc745f..d45a36a0 100644 --- a/src/extension/GraphProvider.ts +++ b/src/extension/GraphProvider.ts @@ -1335,6 +1335,7 @@ export class GraphProvider implements vscode.WebviewViewProvider { refreshReason, unusedDependencyMode: effectiveMode, filterUnused: filterActive, + showCommunities: this._configSnapshot.showCommunities, }; this._view.webview.postMessage(initialMessage); @@ -1367,6 +1368,7 @@ export class GraphProvider implements vscode.WebviewViewProvider { refreshReason: "usage-analysis", unusedDependencyMode: effectiveMode, filterUnused: filterActive, + showCommunities: this._configSnapshot.showCommunities, }; this._view.webview.postMessage(enrichedMessage); diff --git a/src/extension/services/GraphViewService.ts b/src/extension/services/GraphViewService.ts index 871644da..62967fa2 100644 --- a/src/extension/services/GraphViewService.ts +++ b/src/extension/services/GraphViewService.ts @@ -1,4 +1,6 @@ import { Spider } from '../../analyzer/Spider'; +import { computeNodeMetadata } from '../../analyzer/NodeMetadataBuilder'; +import { normalizePath } from '../../shared/path'; import type { UnusedAnalysisCache } from './UnusedAnalysisCache'; type Logger = { @@ -82,6 +84,10 @@ export class GraphViewService { await this.populateParentCounts(enrichedData); } + // Compute per-node metadata (hubScore, fileExtension) from parentCounts. + // computeNodeMetadata is pure — no FS reads, no vscode imports. + computeNodeMetadata(enrichedData, this.spider.workspaceRoot); + return enrichedData; } @@ -94,7 +100,7 @@ export class GraphViewService { for (const node of graphData.nodes) { const count = this.spider.getCallerCount(node); if (count > 0) { - parentCounts[node] = count; + parentCounts[normalizePath(node)] = count; } } diff --git a/src/extension/services/ProviderStateManager.ts b/src/extension/services/ProviderStateManager.ts index d841924c..0b23a939 100644 --- a/src/extension/services/ProviderStateManager.ts +++ b/src/extension/services/ProviderStateManager.ts @@ -17,6 +17,7 @@ export interface ProviderConfigSnapshot extends BackgroundIndexingConfig { maxCacheSize: number; maxSymbolCacheSize: number; performanceProfile: PerformanceProfile; + showCommunities: boolean; } export class ProviderStateManager { @@ -71,6 +72,7 @@ export class ProviderStateManager { ? config.get('maxSymbolCacheSize', profileDefaults.maxSymbolCacheSize) : profileDefaults.maxSymbolCacheSize, performanceProfile: profile, + showCommunities: config.get('showCommunities', true), }; } diff --git a/src/mcp/shared/helpers.ts b/src/mcp/shared/helpers.ts index 6647dec4..d2b6e5ff 100644 --- a/src/mcp/shared/helpers.ts +++ b/src/mcp/shared/helpers.ts @@ -11,6 +11,7 @@ import { SUPPORTED_SYMBOL_ANALYSIS_EXTENSIONS } from "../../shared/constants"; import { normalizePath } from "../../shared/path"; import { detectLanguageFromExtension } from "../../shared/utils/languageDetection"; import type { EdgeInfo, NodeInfo } from "../types"; +import type { GraphNodeMetadata } from "../../shared/graph-types"; // ============================================================================ @@ -67,14 +68,21 @@ export function buildNodeInfo( dependencyCount: Map, dependentCount: Map, rootDir: string, + metadata?: Record, ): NodeInfo[] { - return nodePaths.map((nodePath) => ({ - path: nodePath, - relativePath: getRelativePath(nodePath, rootDir), - extension: nodePath.split(".").pop() ?? "", - dependencyCount: dependencyCount.get(nodePath) ?? 0, - dependentCount: dependentCount.get(nodePath) ?? 0, - })); + return nodePaths.map((nodePath) => { + const meta = metadata?.[nodePath]; + const node: NodeInfo = { + path: nodePath, + relativePath: getRelativePath(nodePath, rootDir), + extension: nodePath.split(".").pop() ?? "", + dependencyCount: dependencyCount.get(nodePath) ?? 0, + dependentCount: dependentCount.get(nodePath) ?? 0, + }; + if (meta?.hubScore !== undefined) node.hubScore = meta.hubScore; + if (meta?.communityId !== undefined) node.communityId = meta.communityId; + return node; + }); } /** diff --git a/src/mcp/tools/graph.ts b/src/mcp/tools/graph.ts index 9a5468ab..c27d27e1 100644 --- a/src/mcp/tools/graph.ts +++ b/src/mcp/tools/graph.ts @@ -1,4 +1,7 @@ import { getLogger } from "../../shared/logger"; +import { normalizePath } from "../../shared/path"; +import { computeNodeMetadata } from "../../analyzer/NodeMetadataBuilder"; +import type { GraphData } from "../../shared/graph-types"; import { applyPagination, buildEdgeCounts, @@ -82,12 +85,20 @@ export async function executeCrawlDependencyGraph( const { dependencyCount, dependentCount } = buildEdgeCounts(graph.edges); const circularDependencies = detectCircularDependencies(graph.edges); - // Build node and edge info + // Compute hubScore + communityId — same logic as CLI and extension + const parentCounts: Record = {}; + for (const [fp, cnt] of dependentCount) parentCounts[normalizePath(fp)] = cnt; + const graphData: GraphData = { nodes: graph.nodes.map(normalizePath), edges: graph.edges, parentCounts }; + computeNodeMetadata(graphData, config.rootDir); + + // Build node and edge info — use graphData.nodes (already normalized) so + // metadata lookup keys match nodeMetadata index (Règle 03). let nodes = buildNodeInfo( - graph.nodes, + graphData.nodes, dependencyCount, dependentCount, config.rootDir, + graphData.nodeMetadata, ); let edges = buildEdgeInfo(graph.edges, config.rootDir); diff --git a/src/mcp/types.ts b/src/mcp/types.ts index aa9b18f4..cb4ac44c 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -204,6 +204,48 @@ export const PAGINATION_LIMITS = { DEFAULT_LIMIT: 1000, } as const; +// ============================================================================ +// GraphData Schemas (F2: hub-score-visual) +// ============================================================================ + +/** + * Zod schema for per-node visual/structural metadata. + * Validates hubScore in [0, 1], optional loc (positive int), optional fileExtension string. + */ +export const GraphNodeMetadataSchema = z.object({ + hubScore: z + .number() + .min(0, "hubScore must be >= 0") + .max(1, "hubScore must be <= 1"), + loc: z.number().int().positive().optional(), + fileExtension: z.string().optional(), + communityId: z.number().int().min(0).optional(), // 0 = isolé, 1+ = cluster +}); +export type GraphNodeMetadataSchemaType = z.infer; + +/** + * Zod schema for a graph edge (source → target with optional relationType). + */ +export const GraphEdgeSchema = z.object({ + source: z.string(), + target: z.string(), + relationType: z.enum(["dependency", "call", "reference"]).optional(), +}); + +/** + * Zod schema for GraphData — validates the full graph payload including + * the optional F2 nodeMetadata field. + */ +export const GraphDataSchema = z.object({ + nodes: z.array(z.string()), + edges: z.array(GraphEdgeSchema), + nodeLabels: z.record(z.string(), z.string()).optional(), + parentCounts: z.record(z.string(), z.number()).optional(), + unusedEdges: z.array(z.string()).optional(), + nodeMetadata: z.record(z.string(), GraphNodeMetadataSchema).optional(), +}); +export type GraphDataSchemaType = z.infer; + export const SetWorkspaceParamsSchema = z.object({ workspacePath: FilePathSchema.describe( "Absolute path to the project/workspace directory to analyze", @@ -878,6 +920,10 @@ export interface NodeInfo { dependencyCount: number; /** Number of incoming edges (dependents) */ dependentCount: number; + /** Hub score 0–1: proportion of workspace files that import this node */ + hubScore?: number; + /** Community id: 0 = isolated, 1+ = functional subdirectory cluster */ + communityId?: number; } /** diff --git a/src/shared/communityPalette.ts b/src/shared/communityPalette.ts new file mode 100644 index 00000000..e0fe2389 --- /dev/null +++ b/src/shared/communityPalette.ts @@ -0,0 +1,9 @@ +/** + * 12-color WCAG AA palette for community visualization. + * Contrast ratio ≥ 4.5:1 vs #1E1E1E (dark background). + */ +export const COMMUNITY_PALETTE: readonly string[] = [ + '#4E79A7', '#F28E2B', '#E15759', '#76B7B2', + '#59A14F', '#EDC948', '#B07AA1', '#FF9DA7', + '#9C755F', '#BAB0AC', '#D37295', '#A0CBE8', +]; diff --git a/src/shared/graph-types.ts b/src/shared/graph-types.ts index a7324e9e..e55e3b36 100644 --- a/src/shared/graph-types.ts +++ b/src/shared/graph-types.ts @@ -10,6 +10,24 @@ export interface GraphEdge { relationType?: "dependency" | "call" | "reference"; } +/** + * Per-node metadata for the file-level dependency graph. + * All fields except hubScore are optional — absent means "not available", never default to 0. + */ +export interface GraphNodeMetadata { + /** Normalized hub score in [0-1]: incoming degree / max incoming degree across workspace. Guard: max === 0 → 0. */ + hubScore: number; + /** Raw line count from file content. Absent means not computed — consumers MUST NOT use ?? 0. */ + loc?: number; + /** File extension without leading dot, lowercase (e.g. "ts", "tsx", "py"). Absent if unknown or empty. */ + fileExtension?: string; + /** + * Community assignment from Louvain detection. + * 0 = isolated node (no edges). 1+ = cluster id. absent = not yet computed. + */ + communityId?: number; +} + export interface GraphData { nodes: string[]; edges: GraphEdge[]; @@ -19,4 +37,6 @@ export interface GraphData { parentCounts?: Record; /** Optional list of edge IDs (source-target) that represent unused dependencies (imports that are not used in code). */ unusedEdges?: string[]; + /** Optional per-node metadata. Key = normalizePath(filePath). */ + nodeMetadata?: Record; } diff --git a/src/shared/messages.ts b/src/shared/messages.ts index a33ad157..dfe3af88 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -38,6 +38,7 @@ export interface ShowGraphMessage { | "unknown"; unusedDependencyMode?: "none" | "hide" | "dim"; filterUnused?: boolean; + showCommunities?: boolean; } export interface OpenFileMessage { diff --git a/src/shared/wiki-types.ts b/src/shared/wiki-types.ts index 363ef843..f9079488 100644 --- a/src/shared/wiki-types.ts +++ b/src/shared/wiki-types.ts @@ -51,6 +51,13 @@ export interface WikiGeneratorOptions extends WikiScopeOptions { outputDir: string; // absolute workspaceRoot: string; // absolute topHubsLimit?: number; // default 10 + /** + * Optional per-node metadata from GraphData.nodeMetadata. + * When present, WikiGenerator uses nodeMetadata[normalizePath(filePath)].hubScore + * (scaled ×100 to match WikiArticle 0-100 range) instead of computing from DB edges. + * Key = normalizePath(filePath). + */ + nodeMetadata?: Record; } export interface WikiGenerateResult { diff --git a/src/webview/App.tsx b/src/webview/App.tsx index 8eb641e0..269e6476 100644 --- a/src/webview/App.tsx +++ b/src/webview/App.tsx @@ -212,6 +212,7 @@ const App: React.FC = () => { "none" | "hide" | "dim" >("none"); const [filterUnused, setFilterUnused] = React.useState(false); + const [showCommunities, setShowCommunities] = React.useState(true); const [layout, setLayout] = React.useState< "hierarchical" | "force" | "radial" >("hierarchical"); @@ -356,6 +357,9 @@ const App: React.FC = () => { ); setFilterUnused(message.filterUnused); } + if (message.showCommunities !== undefined) { + setShowCommunities(message.showCommunities); + } }, [], ); @@ -936,6 +940,7 @@ const App: React.FC = () => { resetToken={resetToken} unusedDependencyMode={unusedDependencyMode} filterUnused={filterUnused} + showCommunities={showCommunities} mode={viewMode as "file" | "symbol"} symbolData={symbolData} layout={layout} diff --git a/src/webview/components/ReactFlowGraph.tsx b/src/webview/components/ReactFlowGraph.tsx index 461c275f..424e2f85 100644 --- a/src/webview/components/ReactFlowGraph.tsx +++ b/src/webview/components/ReactFlowGraph.tsx @@ -21,11 +21,13 @@ import { import { computeRelatedNodes } from "../utils/graphTraversal"; import { normalizePath } from "../utils/path"; import { buildReactFlowGraph, GRAPH_LIMITS } from "./reactflow/buildGraph"; +import { CommunityLegend } from "./reactflow/CommunityLegend"; +import { communityColor } from "../utils/communityColor"; import { ExpansionOverlay, type ExpansionState, } from "./reactflow/ExpansionOverlay"; -import { FileNode } from "./reactflow/FileNode"; +import { FileNode, type FileNodeData } from "./reactflow/FileNode"; import { SymbolNode } from "./reactflow/SymbolNode"; /** Logger instance for ReactFlowGraph */ @@ -99,6 +101,8 @@ interface ReactFlowGraphProps { selectedNodeId?: string | null; /** Callback for symbol highlight on double-click */ onHighlight?: (symbolId: string) => void; + /** Whether to show community tint and legend (default true) */ + showCommunities?: boolean; } function stableGlobal(key: string, factory: () => T): T { @@ -363,6 +367,7 @@ const ReactFlowGraphContent: React.FC = ({ resetToken, unusedDependencyMode = "none", filterUnused: backendFilterUnused, + showCommunities = true, mode = "file", symbolData, onLayoutChange, @@ -508,6 +513,7 @@ const ReactFlowGraphContent: React.FC = ({ unusedEdges: data?.unusedEdges, unusedDependencyMode, filterUnused, + showCommunities, mode, symbolData, layout, @@ -535,6 +541,7 @@ const ReactFlowGraphContent: React.FC = ({ showParents, unusedDependencyMode, filterUnused, + showCommunities, // DO NOT include callbacks in deps! They don't change graph structure, // only node data handlers. Including them causes constant re-renders. mode, @@ -712,6 +719,63 @@ const ReactFlowGraphContent: React.FC = ({ return graph.nodes; }, [graph.nodes, highlightState]); + // Derive communities with directory-path label for the legend + const communities = useMemo(() => { + const UMBRELLA = new Set(['src', 'tests', 'test', 'lib', 'app', 'packages', 'dist', 'out']); + const allIds = graph.nodes.map(n => n.id ?? ''); + + // Strip common absolute prefix (/Users/x/github/project/…) + function absolutePrefixLen(paths: string[]): number { + if (!paths.length) return 0; + const split = paths.map(p => p.split('/')); + const minLen = Math.min(...split.map(p => p.length)); + const cap = Math.max(0, minLen - 3); + let i = 0; + while (i < cap && split.every(p => p[i] === split[0][i])) i++; + return i; + } + + // Strip common dir prefix inside relative paths (e.g. 'vue/src' in monorepos) + function commonRelDirPrefixLen(relPaths: string[]): number { + if (!relPaths.length) return 0; + const dirParts = relPaths.map(p => p.split('/').slice(0, -1)); + const minLen = Math.min(...dirParts.map(p => p.length)); + let i = 0; + while (i < minLen && dirParts.every(p => p[i] === dirParts[0][i])) i++; + return i; + } + + const absPrefixLen = absolutePrefixLen(allIds); + const relIds = allIds.map(id => id.split('/').slice(absPrefixLen).join('/')); + const relDirPrefixLen = commonRelDirPrefixLen(relIds); + + function communityLabel(anyNodeId: string): string { + const parts = anyNodeId.split('/'); + const relParts = parts.slice(absPrefixLen); + const dirParts = relParts.slice(0, -1); // strip filename + // Skip up to and INCLUDING the last umbrella dir → handles nested roots (vue/src/) + let startIdx = relDirPrefixLen; + for (let i = relDirPrefixLen; i < dirParts.length; i++) { + if (UMBRELLA.has(dirParts[i])) startIdx = i + 1; + } + const domain = dirParts[startIdx]; + return domain || (parts[parts.length - 1] ?? anyNodeId); // fallback to filename + } + + // Collect one representative node ID per community + const repByComm = new Map(); + for (const n of graph.nodes) { + const d = n.data as FileNodeData; + if (!d.communityId || d.communityId === 0) continue; + if (!repByComm.has(d.communityId)) { + repByComm.set(d.communityId, n.id ?? ''); + } + } + return [...repByComm.entries()] + .sort(([a], [b]) => a - b) + .map(([id, nodeId]) => ({ id, label: communityLabel(nodeId), color: communityColor(id) })); + }, [graph.nodes]); + // Use useLayoutEffect for highlight - runs synchronously AFTER useMemo recalculates // This ensures we see the updated visibleNodes/styledEdges values React.useLayoutEffect(() => { @@ -925,6 +989,7 @@ const ReactFlowGraphContent: React.FC = ({ + {showCommunities && } {/* T081: Loading indicator during re-analysis */} {isReanalyzing && (
+
+
Import clusters
+
Groups of closely connected files
+
+ {communities.map(({ id, label, color }) => ( +
+
+ {label} +
+ ))} +
+ ); +} diff --git a/src/webview/components/reactflow/FileNode.tsx b/src/webview/components/reactflow/FileNode.tsx index 8c8b2af1..5ddd54cd 100644 --- a/src/webview/components/reactflow/FileNode.tsx +++ b/src/webview/components/reactflow/FileNode.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Handle, Position, type NodeProps } from 'reactflow'; import { EXTENSION_COLORS, LANGUAGE_COLORS } from '../../../shared/constants'; import { actionButtonSize, cycleIndicatorSize } from '../../utils/nodeUtils'; +import { communityColor } from '../../utils/communityColor'; import { LanguageIcon } from './LanguageIcon'; export interface FileNodeData { @@ -15,6 +16,9 @@ export interface FileNodeData { hasReferencingFiles: boolean; parentCount?: number; isParentsVisible: boolean; + hubScore?: number; // 0-100, undefined en mode symbol + communityId?: number; + showCommunities?: boolean; onNodeClick: () => void; onDrillDown: () => void; onFindReferences: () => void; @@ -46,6 +50,14 @@ function isExternalPackage(path: string): boolean { return true; } +export function getHubBorderWidth(score: number | undefined): number { + if (score === undefined) return 2; + if (score >= 80) return 5; + if (score >= 50) return 4; + if (score >= 20) return 3; + return 2; +} + function getFileBorderColor(label: string, fullPath: string): string { if (isExternalPackage(fullPath || label)) { return EXTERNAL_PACKAGE_COLOR; @@ -109,17 +121,24 @@ export const FileNode = React.memo>(function FileNode({ alignItems: 'center', justifyContent: 'center', height: '100%', - background: data.isRoot ? borderColor : 'var(--vscode-editor-background)', + background: data.isRoot + ? borderColor + : (() => { + if (data.showCommunities === false) return 'var(--vscode-editor-background)'; + const commBg = communityColor(data.communityId); + return commBg !== 'transparent' ? commBg + '22' : 'var(--vscode-editor-background)'; + })(), color: data.isRoot ? '#000' : 'var(--vscode-editor-foreground)', border: (() => { if (isSelected) return '4px solid #0078d4'; - if (isExternal) return `2px dashed ${borderColor}`; - return `2px solid ${borderColor}`; + const width = getHubBorderWidth(data.hubScore); + const style = isExternal ? 'dashed' : 'solid'; + return `${width}px ${style} ${borderColor}`; })(), borderRadius: 4, padding: '0 12px', - fontSize: 12, - fontWeight: data.isRoot ? 'bold' : 'normal', + fontSize: (!data.isRoot && (data.hubScore ?? 0) >= 80) ? 13 : 12, + fontWeight: data.isRoot ? 'bold' : (data.hubScore ?? 0) >= 50 ? 'bold' : 'normal', fontStyle: isExternal ? 'italic' : 'normal', fontFamily: 'var(--vscode-font-family)', pointerEvents: 'none', @@ -295,6 +314,9 @@ export const FileNode = React.memo>(function FileNode({ pd.hasReferencingFiles === nd.hasReferencingFiles && pd.parentCount === nd.parentCount && pd.isParentsVisible === nd.isParentsVisible && + pd.hubScore === nd.hubScore && + pd.communityId === nd.communityId && + pd.showCommunities === nd.showCommunities && pd.selectedNodeId === nd.selectedNodeId && pd.nodeId === nd.nodeId ); diff --git a/src/webview/components/reactflow/buildGraph.ts b/src/webview/components/reactflow/buildGraph.ts index 444101fa..396bcaa5 100644 --- a/src/webview/components/reactflow/buildGraph.ts +++ b/src/webview/components/reactflow/buildGraph.ts @@ -337,6 +337,22 @@ function createVisibleEdges( }); } +/** + * Compute hub scores (0-100) for visible nodes based on parent import counts. + * The node with the most imports gets score 100; others are proportional. + * Private: used only as fallback when nodeMetadata is absent (retro-compat). + */ +function computeHubScores( + visibleNodes: string[], + parentCounts: Record, +): Record { + const counts = visibleNodes.map((p) => Math.max(0, parentCounts[p] ?? 0)); + const max = Math.max(...counts, 1); + return Object.fromEntries( + visibleNodes.map((p, i) => [p, Math.round((counts[i] / max) * 100)]), + ); +} + export function buildReactFlowGraph(params: { data: GraphData | undefined; currentFilePath: string; @@ -352,6 +368,7 @@ export function buildReactFlowGraph(params: { layout?: "hierarchical" | "force" | "radial"; selectedNodeId?: string | null; highlightState?: { highlightedNodes: Set; highlightedEdges: Set } | null; // Étape 4 + showCommunities?: boolean; }): BuildGraphResult { const { data, @@ -363,6 +380,7 @@ export function buildReactFlowGraph(params: { unusedEdges = [], unusedDependencyMode = "none", filterUnused = true, + showCommunities = true, mode = "file", symbolData, layout = "hierarchical", @@ -427,6 +445,12 @@ export function buildReactFlowGraph(params: { nodesTruncated = nodesTruncated || bfsTruncated; + // F2: if nodeMetadata is present (server-side scores), skip local computation. + // Otherwise compute locally for retro-compat (e.g. old extension versions). + const computedFallback = data.nodeMetadata + ? {} + : computeHubScores(Array.from(visibleNodes), data.parentCounts ?? {}); + const createNodeData = ( path: string, label: string, @@ -531,6 +555,17 @@ export function buildReactFlowGraph(params: { onExpandRequest: () => callbacks.onExpandRequest(path), selectedNodeId, nodeId: path, + hubScore: (() => { + const meta = data.nodeMetadata?.[normalizePath(path)]; + if (meta?.hubScore !== undefined) { + // nodeMetadata.hubScore is [0-1] → convert to [0-100] for FileNodeData + return Math.round(meta.hubScore * 100); + } + // Fallback: nodeMetadata absent → use locally-computed scores (retro-compat) + return computedFallback[path] ?? 0; + })(), + communityId: data.nodeMetadata?.[normalizePath(path)]?.communityId, + showCommunities, } as FileNodeData; }; diff --git a/src/webview/utils/communityColor.ts b/src/webview/utils/communityColor.ts new file mode 100644 index 00000000..e93551fa --- /dev/null +++ b/src/webview/utils/communityColor.ts @@ -0,0 +1,12 @@ +import { COMMUNITY_PALETTE } from '../../shared/communityPalette'; + +/** + * Returns hex color for a community id. + * communityId 0 (isolated) → transparent. + * communityId 1+ → COMMUNITY_PALETTE cyclic. + * absent → transparent. + */ +export function communityColor(communityId: number | undefined): string { + if (communityId === undefined || communityId === 0) return 'transparent'; + return COMMUNITY_PALETTE[(communityId - 1) % COMMUNITY_PALETTE.length]; +} diff --git a/tests/analyzer/NodeMetadataBuilder.test.ts b/tests/analyzer/NodeMetadataBuilder.test.ts new file mode 100644 index 00000000..e10fbba6 --- /dev/null +++ b/tests/analyzer/NodeMetadataBuilder.test.ts @@ -0,0 +1,247 @@ +/** + * Unit tests for NodeMetadataBuilder.computeNodeMetadata (F2 + F4 feature). + * AC3: hubScore is correctly computed from parentCounts. + * AC4: loc is absent (NodeMetadataBuilder never reads the FS). + * AC6: fileExtension absent when file has no extension. + * F4: communityId assigned via detectCommunities, graceful degrade on failure. + */ +import { describe, it, expect, vi } from 'vitest'; +import { computeNodeMetadata } from '../../src/analyzer/NodeMetadataBuilder.js'; +import { normalizePath } from '../../src/shared/path.js'; +import type { GraphData } from '../../src/shared/graph-types.js'; + +describe('computeNodeMetadata', () => { + it('AC3 — computes hubScore from parentCounts', () => { + const nodeA = normalizePath('/project/src/a.ts'); + const nodeB = normalizePath('/project/src/b.ts'); + const nodeC = normalizePath('/project/src/c.ts'); + + const data: GraphData = { + nodes: [nodeA, nodeB, nodeC], + edges: [], + parentCounts: { + [nodeA]: 10, + [nodeB]: 5, + // nodeC has 0 callers (absent from parentCounts) + }, + }; + + computeNodeMetadata(data); + + expect(data.nodeMetadata).toBeDefined(); + expect(data.nodeMetadata![nodeA].hubScore).toBe(1); // 10/10 + expect(data.nodeMetadata![nodeB].hubScore).toBe(0.5); // 5/10 + expect(data.nodeMetadata![nodeC].hubScore).toBe(0); // 0/10 + }); + + it('AC3 — hubScore is rounded to 3 decimal places', () => { + const nodeA = normalizePath('/project/src/a.ts'); + const nodeB = normalizePath('/project/src/b.ts'); + + const data: GraphData = { + nodes: [nodeA, nodeB], + edges: [], + parentCounts: { [nodeA]: 1, [nodeB]: 3 }, + }; + + computeNodeMetadata(data); + + // 1/3 = 0.333... + expect(data.nodeMetadata![nodeA].hubScore).toBe(0.333); + expect(data.nodeMetadata![nodeB].hubScore).toBe(1); + }); + + it('AC3 — guard: when max === 0, all hubScores are 0', () => { + const nodeA = normalizePath('/project/src/a.ts'); + const nodeB = normalizePath('/project/src/b.ts'); + + const data: GraphData = { + nodes: [nodeA, nodeB], + edges: [], + // no parentCounts → all counts are 0 → max guard kicks in + }; + + computeNodeMetadata(data); + + expect(data.nodeMetadata![nodeA].hubScore).toBe(0); + expect(data.nodeMetadata![nodeB].hubScore).toBe(0); + }); + + it('AC3 — handles empty parentCounts object', () => { + const nodeA = normalizePath('/project/src/a.ts'); + const data: GraphData = { + nodes: [nodeA], + edges: [], + parentCounts: {}, + }; + + computeNodeMetadata(data); + + expect(data.nodeMetadata![nodeA].hubScore).toBe(0); + }); + + it('AC4 — loc is never set (no FS reads in NodeMetadataBuilder)', () => { + const nodeA = normalizePath('/project/src/a.ts'); + const data: GraphData = { + nodes: [nodeA], + edges: [], + }; + + computeNodeMetadata(data); + + expect(data.nodeMetadata![nodeA].loc).toBeUndefined(); + }); + + it('AC6 — fileExtension is absent when file has no extension', () => { + const nodeNoExt = normalizePath('/project/Makefile'); + const data: GraphData = { + nodes: [nodeNoExt], + edges: [], + }; + + computeNodeMetadata(data); + + expect(data.nodeMetadata![nodeNoExt].fileExtension).toBeUndefined(); + }); + + it('fileExtension is lowercase without leading dot', () => { + const nodeTs = normalizePath('/project/src/App.TSX'); + const data: GraphData = { + nodes: [nodeTs], + edges: [], + }; + + computeNodeMetadata(data); + + expect(data.nodeMetadata![nodeTs].fileExtension).toBe('tsx'); + }); + + it('fileExtension is set for .ts files', () => { + const node = normalizePath('/project/src/utils.ts'); + const data: GraphData = { + nodes: [node], + edges: [], + }; + + computeNodeMetadata(data); + + expect(data.nodeMetadata![node].fileExtension).toBe('ts'); + }); + + it('does nothing when nodes array is empty', () => { + const data: GraphData = { + nodes: [], + edges: [], + }; + + computeNodeMetadata(data); + + // nodeMetadata should not be set when there are no nodes + expect(data.nodeMetadata).toBeUndefined(); + }); + + it('keys in nodeMetadata are normalizePath results', () => { + const rawPath = '/project/src/index.ts'; + const data: GraphData = { + nodes: [rawPath], + edges: [], + }; + + computeNodeMetadata(data); + + const expectedKey = normalizePath(rawPath); + expect(data.nodeMetadata![expectedKey]).toBeDefined(); + }); + + // F4 — communityId tests + + it('F4 — files in same directory get same communityId (path-based)', () => { + const nodeA = normalizePath('/project/src/analyzer/a.ts'); + const nodeB = normalizePath('/project/src/analyzer/b.ts'); + + const data: GraphData = { + nodes: [nodeA, nodeB], + edges: [], + parentCounts: { [nodeA]: 1, [nodeB]: 1 }, + }; + + computeNodeMetadata(data, '/project'); + + expect(data.nodeMetadata![nodeA].communityId).toBeDefined(); + expect(data.nodeMetadata![nodeB].communityId).toBeDefined(); + // Same directory → same community + expect(data.nodeMetadata![nodeA].communityId).toBe( + data.nodeMetadata![nodeB].communityId, + ); + expect(data.nodeMetadata![nodeA].communityId).toBeGreaterThanOrEqual(1); + }); + + it('F4 — files in different directories get different communityIds', () => { + const nodeA = normalizePath('/project/src/analyzer/a.ts'); + const nodeB = normalizePath('/project/src/webview/b.tsx'); + + const data: GraphData = { + nodes: [nodeA, nodeB], + edges: [], + parentCounts: {}, + }; + + computeNodeMetadata(data); + + expect(data.nodeMetadata![nodeA].communityId).not.toBe( + data.nodeMetadata![nodeB].communityId, + ); + }); + + it('F4 — every file gets a communityId (path-based, never undefined)', () => { + // With path-based detection, communityId 0 only occurs when dirParts is + // empty after stripping the workspace prefix — which requires the file to + // sit at the exact workspace root with no subdirectory. In practice, files + // always have at least one directory component, so communityId ≥ 0 always. + const nodeA = normalizePath('/project/README.md'); + + const data: GraphData = { + nodes: [nodeA], + edges: [], + parentCounts: {}, + }; + + computeNodeMetadata(data); + + expect(data.nodeMetadata![nodeA].communityId).toBeDefined(); + }); + + it('F4 — graceful degrade: nodeMetadata still set when detectPathCommunities throws', async () => { + // Mock PathCommunityDetector to throw + vi.doMock('../../src/analyzer/community/PathCommunityDetector.js', () => ({ + detectPathCommunities: () => { throw new Error('unexpected error'); }, + })); + + // Re-import after mock + const { computeNodeMetadata: computeFresh } = await import( + '../../src/analyzer/NodeMetadataBuilder.js?v=fail' + ).catch(() => import('../../src/analyzer/NodeMetadataBuilder.js')); + + const nodeA = normalizePath('/project/src/analyzer/a.ts'); + const data: GraphData = { + nodes: [nodeA], + edges: [], + parentCounts: { [nodeA]: 3 }, + }; + + // Should not throw + expect(() => computeFresh(data)).not.toThrow(); + + // hubScore should still be computed + expect(data.nodeMetadata).toBeDefined(); + expect(data.nodeMetadata![nodeA].hubScore).toBeDefined(); + + vi.doUnmock('../../src/analyzer/community/PathCommunityDetector.js'); + }); + + it('F4 — communityId absent when nodes array is empty', () => { + const data: GraphData = { nodes: [], edges: [] }; + computeNodeMetadata(data); + expect(data.nodeMetadata).toBeUndefined(); + }); +}); diff --git a/tests/analyzer/PythonSymbolAnalyzer.properties.test.ts b/tests/analyzer/PythonSymbolAnalyzer.properties.test.ts index a494f0a9..df00e8d5 100644 --- a/tests/analyzer/PythonSymbolAnalyzer.properties.test.ts +++ b/tests/analyzer/PythonSymbolAnalyzer.properties.test.ts @@ -1,7 +1,7 @@ import fc from 'fast-check'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type Parser from 'web-tree-sitter'; import { PythonSymbolAnalyzer } from '../../src/analyzer/languages/PythonSymbolAnalyzer'; import { normalizePath } from '../../src/shared/path'; @@ -20,6 +20,26 @@ import { normalizePath } from '../../src/shared/path'; const mockExtensionPath = path.resolve(process.cwd()); const fixturesDir = path.resolve(__dirname, '../fixtures/python-project'); +/** + * Crash-safe sweep of temp_*.py fixtures left behind by interrupted runs. + * Per-test `finally`/unlink handles the happy path; this catches orphans + * when the process dies between writeFile and unlink. Cross-OS: uses + * fs.readdir + path.join + unlink (no shell/glob). + */ +async function sweepTempFixtures(): Promise { + let entries: string[]; + try { + entries = await fs.readdir(fixturesDir); + } catch { + return; // dir missing → nothing to sweep + } + await Promise.all( + entries + .filter((name) => name.startsWith('temp_') && name.endsWith('.py')) + .map((name) => fs.unlink(path.join(fixturesDir, name)).catch(() => {})), + ); +} + // Create a mock parser instance that will be reused let mockParserInstance: any = null; @@ -312,6 +332,11 @@ vi.mock('../../src/analyzer/languages/WasmParserFactory', () => { describe('PythonSymbolAnalyzer Property-Based Tests', { timeout: 30_000 }, () => { let analyzer: PythonSymbolAnalyzer; + // Sweep orphans from any prior interrupted run before starting, and clean + // up after the suite regardless of per-test unlink outcome. + beforeAll(sweepTempFixtures); + afterAll(sweepTempFixtures); + beforeEach(async () => { vi.clearAllMocks(); diff --git a/tests/analyzer/community/LouvainDetector.test.ts b/tests/analyzer/community/LouvainDetector.test.ts new file mode 100644 index 00000000..d9e71436 --- /dev/null +++ b/tests/analyzer/community/LouvainDetector.test.ts @@ -0,0 +1,151 @@ +/** + * Unit tests for LouvainDetector.detectCommunities (F4 feature). + * Coverage targets: triangle graph, 2 disjoint clusters, isolated node, empty graph, + * normalizePath on keys. + */ +import { describe, it, expect } from 'vitest'; +import { detectCommunities } from '../../../src/analyzer/community/LouvainDetector.js'; +import { normalizePath } from '../../../src/shared/path.js'; + +describe('detectCommunities', () => { + it('triangle graph (3 nodes, 3 edges) → 1 community, all ids >= 1', () => { + const a = normalizePath('/project/a.ts'); + const b = normalizePath('/project/b.ts'); + const c = normalizePath('/project/c.ts'); + + const result = detectCommunities({ + nodes: [a, b, c], + edges: [ + { source: a, target: b }, + { source: b, target: c }, + { source: c, target: a }, + ], + }); + + expect(result.count).toBe(1); + expect(result.assignments.get(a)).toBeGreaterThanOrEqual(1); + expect(result.assignments.get(b)).toBeGreaterThanOrEqual(1); + expect(result.assignments.get(c)).toBeGreaterThanOrEqual(1); + // All should be in the same community + expect(result.assignments.get(a)).toBe(result.assignments.get(b)); + expect(result.assignments.get(b)).toBe(result.assignments.get(c)); + }); + + it('2 disjoint clusters (A-B-C and D-E-F) → 2 distinct communities', () => { + const a = normalizePath('/project/a.ts'); + const b = normalizePath('/project/b.ts'); + const c = normalizePath('/project/c.ts'); + const d = normalizePath('/project/d.ts'); + const e = normalizePath('/project/e.ts'); + const f = normalizePath('/project/f.ts'); + + const result = detectCommunities({ + nodes: [a, b, c, d, e, f], + edges: [ + { source: a, target: b }, + { source: b, target: c }, + { source: c, target: a }, + { source: d, target: e }, + { source: e, target: f }, + { source: f, target: d }, + ], + }); + + expect(result.count).toBe(2); + + // Cluster 1: a, b, c should all share the same communityId + const commABC = result.assignments.get(a)!; + expect(commABC).toBeGreaterThanOrEqual(1); + expect(result.assignments.get(b)).toBe(commABC); + expect(result.assignments.get(c)).toBe(commABC); + + // Cluster 2: d, e, f should all share the same communityId + const commDEF = result.assignments.get(d)!; + expect(commDEF).toBeGreaterThanOrEqual(1); + expect(result.assignments.get(e)).toBe(commDEF); + expect(result.assignments.get(f)).toBe(commDEF); + + // The two clusters must have different community ids + expect(commABC).not.toBe(commDEF); + }); + + it('isolated node → communityId === 0', () => { + const isolated = normalizePath('/project/isolated.ts'); + + const result = detectCommunities({ + nodes: [isolated], + edges: [], + }); + + expect(result.assignments.get(isolated)).toBe(0); + expect(result.count).toBe(0); + }); + + it('empty graph (0 nodes) → count 0', () => { + const result = detectCommunities({ nodes: [], edges: [] }); + + expect(result.count).toBe(0); + expect(result.assignments.size).toBe(0); + }); + + it('normalizePath is applied to output keys', () => { + // Pass raw paths — keys in assignments must be normalized + const rawA = '/project/src/a.ts'; + const rawB = '/project/src/b.ts'; + + const result = detectCommunities({ + nodes: [rawA, rawB], + edges: [{ source: rawA, target: rawB }], + }); + + // Keys must be normalized + expect(result.assignments.has(normalizePath(rawA))).toBe(true); + expect(result.assignments.has(normalizePath(rawB))).toBe(true); + }); + + it('mixed graph: connected nodes get community >= 1, isolated get 0', () => { + const connected1 = normalizePath('/project/x.ts'); + const connected2 = normalizePath('/project/y.ts'); + const iso = normalizePath('/project/z.ts'); + + const result = detectCommunities({ + nodes: [connected1, connected2, iso], + edges: [{ source: connected1, target: connected2 }], + }); + + expect(result.assignments.get(connected1)).toBeGreaterThanOrEqual(1); + expect(result.assignments.get(connected2)).toBeGreaterThanOrEqual(1); + expect(result.assignments.get(iso)).toBe(0); + }); + + it('community ids are contiguous 1-indexed after remapping', () => { + const a = normalizePath('/p/a.ts'); + const b = normalizePath('/p/b.ts'); + const c = normalizePath('/p/c.ts'); + const d = normalizePath('/p/d.ts'); + + // Two separate pairs → 2 communities + const result = detectCommunities({ + nodes: [a, b, c, d], + edges: [ + { source: a, target: b }, + { source: c, target: d }, + ], + }); + + const ids = new Set([ + result.assignments.get(a), + result.assignments.get(b), + result.assignments.get(c), + result.assignments.get(d), + ]); + + // Should have exactly 2 distinct community ids, both >= 1 + expect(ids.size).toBe(2); + for (const id of ids) { + expect(id).toBeGreaterThanOrEqual(1); + } + // count matches + expect(result.count).toBe(2); + }); +}); diff --git a/tests/analyzer/community/PathCommunityDetector.test.ts b/tests/analyzer/community/PathCommunityDetector.test.ts new file mode 100644 index 00000000..7f43f311 --- /dev/null +++ b/tests/analyzer/community/PathCommunityDetector.test.ts @@ -0,0 +1,160 @@ +/** + * Unit tests for PathCommunityDetector.detectPathCommunities. + * Groups files by first functional subdirectory (skipping umbrella dirs: src, tests…). + * Root-level / umbrella-only files → communityId 0 (isolated). + * All others → 1-indexed, contiguous. + */ +import { describe, it, expect } from 'vitest'; +import { detectPathCommunities } from '../../../src/analyzer/community/PathCommunityDetector.js'; +import { normalizePath } from '../../../src/shared/path.js'; + +const ROOT = '/project'; + +describe('detectPathCommunities', () => { + it('empty array → empty map', () => { + expect(detectPathCommunities([])).toEqual(new Map()); + }); + + it('single node with workspaceRoot → does not crash', () => { + const node = normalizePath('/project/src/analyzer/index.ts'); + const result = detectPathCommunities([node], ROOT); + expect(result.has(node)).toBe(true); + }); + + it('src/analyzer → domain "analyzer" (umbrella src skipped)', () => { + const a = normalizePath('/project/src/analyzer/Spider.ts'); + const b = normalizePath('/project/src/analyzer/PathResolver.ts'); + const result = detectPathCommunities([a, b], ROOT); + expect(result.get(a)).toBe(result.get(b)); + expect(result.get(a)).toBeGreaterThanOrEqual(1); + }); + + it('src/webview → domain "webview", different from analyzer', () => { + const a = normalizePath('/project/src/analyzer/Spider.ts'); + const b = normalizePath('/project/src/webview/index.tsx'); + const result = detectPathCommunities([a, b], ROOT); + expect(result.get(a)).not.toBe(result.get(b)); + expect(result.get(a)).toBeGreaterThanOrEqual(1); + expect(result.get(b)).toBeGreaterThanOrEqual(1); + }); + + it('tests/analyzer → same domain as src/analyzer (both "analyzer")', () => { + const src = normalizePath('/project/src/analyzer/Spider.ts'); + const test = normalizePath('/project/tests/analyzer/Spider.test.ts'); + const result = detectPathCommunities([src, test], ROOT); + expect(result.get(src)).toBe(result.get(test)); + expect(result.get(src)).toBeGreaterThanOrEqual(1); + }); + + it('communityIds are contiguous starting at 1 (no gaps)', () => { + const nodes = [ + normalizePath('/project/src/analyzer/a.ts'), + normalizePath('/project/src/webview/b.tsx'), + normalizePath('/project/src/mcp/c.ts'), + ]; + const result = detectPathCommunities(nodes, ROOT); + const ids = [...new Set(result.values())].sort((a, b) => a - b); + expect(ids).toEqual([1, 2, 3]); + }); + + it('normalizePath applied — key is normalized form', () => { + const raw = '/project/src/analyzer/Spider.ts'; + const normalized = normalizePath(raw); + const result = detectPathCommunities([raw], ROOT); + expect(result.has(normalized)).toBe(true); + }); + + it('files deeper than 2 dirs still group by first domain (src/analyzer/callgraph → "analyzer")', () => { + const a = normalizePath('/project/src/analyzer/callgraph/GraphExtractor.ts'); + const b = normalizePath('/project/src/analyzer/callgraph/CallGraphIndexer.ts'); + const c = normalizePath('/project/src/analyzer/Spider.ts'); + const result = detectPathCommunities([a, b, c], ROOT); + // a, b, c all under src/analyzer → same domain "analyzer" + expect(result.get(a)).toBe(result.get(b)); + expect(result.get(a)).toBe(result.get(c)); + expect(result.get(a)).toBeGreaterThanOrEqual(1); + }); + + it('src/index.ts (umbrella-only) → communityId 0 (isolated)', () => { + const shallow = normalizePath('/project/src/index.ts'); + const deep = normalizePath('/project/src/analyzer/Spider.ts'); + const result = detectPathCommunities([shallow, deep], ROOT); + expect(result.get(shallow)).toBe(0); + expect(result.get(deep)).toBeGreaterThanOrEqual(1); + expect(result.get(shallow)).not.toBe(result.get(deep)); + }); + + it('root-level file (no dir) → communityId 0', () => { + const root = normalizePath('/project/package.json'); + const deep = normalizePath('/project/src/analyzer/Spider.ts'); + const result = detectPathCommunities([root, deep], ROOT); + expect(result.get(root)).toBe(0); + expect(result.get(deep)).toBeGreaterThanOrEqual(1); + }); + + it('all nodes in same domain → all get same communityId 1', () => { + const a = normalizePath('/project/src/analyzer/a.ts'); + const b = normalizePath('/project/src/analyzer/b.ts'); + const c = normalizePath('/project/src/analyzer/sub/c.ts'); + const result = detectPathCommunities([a, b, c], ROOT); + expect(result.get(a)).toBe(1); + expect(result.get(b)).toBe(1); + expect(result.get(c)).toBe(1); + }); + + it('inference fallback (no workspaceRoot) — does not crash', () => { + const a = normalizePath('/project/src/analyzer/Spider.ts'); + const b = normalizePath('/project/src/webview/App.tsx'); + // No workspaceRoot: uses common prefix inference, best-effort + const result = detectPathCommunities([a, b]); + expect(result.has(a)).toBe(true); + expect(result.has(b)).toBe(true); + }); + + it('monorepo: non-umbrella container dir (vue/src/…) → distinct communities', () => { + const a = normalizePath('/project/vue/src/services/userServices.ts'); + const b = normalizePath('/project/vue/src/store/store.ts'); + const c = normalizePath('/project/vue/src/types/generics.types.ts'); + const result = detectPathCommunities([a, b, c], ROOT); + // All three should have distinct, non-zero communityIds + expect(result.get(a)).toBeGreaterThanOrEqual(1); + expect(result.get(b)).toBeGreaterThanOrEqual(1); + expect(result.get(c)).toBeGreaterThanOrEqual(1); + expect(result.get(a)).not.toBe(result.get(b)); // services ≠ store + expect(result.get(a)).not.toBe(result.get(c)); // services ≠ types + expect(result.get(b)).not.toBe(result.get(c)); // store ≠ types + }); + + it('monorepo: files in same subdir get same communityId', () => { + const a = normalizePath('/project/vue/src/services/userServices.ts'); + const b = normalizePath('/project/vue/src/services/companyServices.ts'); + const c = normalizePath('/project/vue/src/store/store.ts'); + const result = detectPathCommunities([a, b, c], ROOT); + expect(result.get(a)).toBe(result.get(b)); // both services + expect(result.get(a)).not.toBe(result.get(c)); // services ≠ store + }); + + it('divergent roots (vue/src/ + backend/): nested src split by subdir', () => { + // app-bobbee case: two top-level roots, no common prefix, vue not umbrella + const views = normalizePath('/project/vue/src/views/GeneralLedger2.vue'); + const store = normalizePath('/project/vue/src/store/store.ts'); + const types = normalizePath('/project/vue/src/types/x.types.ts'); + const back = normalizePath('/project/backend/handlers/h.ts'); + const result = detectPathCommunities([views, store, types, back], ROOT); + // vue subdirs must be distinct (not all collapsed to 'vue') + expect(result.get(views)).not.toBe(result.get(store)); + expect(result.get(views)).not.toBe(result.get(types)); + expect(result.get(store)).not.toBe(result.get(types)); + // backend is its own cluster (no umbrella marker → first segment) + expect(result.get(back)).toBeGreaterThanOrEqual(1); + expect(result.get(back)).not.toBe(result.get(views)); + }); + + it('deeply nested container: packages/app/src/store/ → store', () => { + const a = normalizePath('/project/packages/app/src/store/s.ts'); + const b = normalizePath('/project/packages/app/src/views/v.ts'); + const result = detectPathCommunities([a, b], ROOT); + expect(result.get(a)).not.toBe(result.get(b)); // store ≠ views + expect(result.get(a)).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/tests/analyzer/export/HtmlExporter.test.ts b/tests/analyzer/export/HtmlExporter.test.ts new file mode 100644 index 00000000..0ac85ce5 --- /dev/null +++ b/tests/analyzer/export/HtmlExporter.test.ts @@ -0,0 +1,414 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const mockReadFileSync = vi.hoisted(() => vi.fn(() => '// vis-network mock')); +const mockWriteFileSync = vi.hoisted(() => vi.fn()); +const mockMkdirSync = vi.hoisted(() => vi.fn()); + +vi.mock('node:fs', () => ({ + default: { + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync, + }, + readFileSync: mockReadFileSync, + writeFileSync: mockWriteFileSync, + mkdirSync: mockMkdirSync, +})); + +import { + htmlEscape, + hubScoreColor, + hubScoreBorderWidth, + nodeColor, + buildCommunityLegend, + exportHtml, + type HtmlExporterConfig, + type HtmlNodeData, +} from '../../../src/analyzer/export/HtmlExporter'; +import { COMMUNITY_PALETTE } from '../../../src/shared/communityPalette'; + +describe('htmlEscape', () => { + it('escapes &', () => { + expect(htmlEscape('a & b')).toBe('a & b'); + }); + + it('escapes < and >', () => { + expect(htmlEscape('')).toBe('<tag>'); + }); + + it('escapes double quotes', () => { + expect(htmlEscape('"quoted"')).toBe('"quoted"'); + }); + + it("escapes single quotes", () => { + expect(htmlEscape("it's")).toBe('it's'); + }); + + it('returns plain string unchanged', () => { + expect(htmlEscape('hello world')).toBe('hello world'); + }); +}); + +describe('hubScoreColor', () => { + it('tier 0: undefined → dark grey', () => { + const c = hubScoreColor(undefined); + expect(c.background).toBe('#2d2d2d'); + expect(c.border).toBe('#555'); + }); + + it('tier 0: score < 0.2 → dark grey', () => { + const c = hubScoreColor(0.1); + expect(c.background).toBe('#2d2d2d'); + expect(c.border).toBe('#555'); + }); + + it('tier 1: score 0.2 → blue', () => { + const c = hubScoreColor(0.2); + expect(c.background).toBe('#1a3a5c'); + expect(c.border).toBe('#4a9eff'); + }); + + it('tier 1: score 0.4 → blue', () => { + const c = hubScoreColor(0.4); + expect(c.background).toBe('#1a3a5c'); + expect(c.border).toBe('#4a9eff'); + }); + + it('tier 2: score 0.5 → orange', () => { + const c = hubScoreColor(0.5); + expect(c.background).toBe('#3a2a00'); + expect(c.border).toBe('#ffaa00'); + }); + + it('tier 2: score 0.7 → orange', () => { + const c = hubScoreColor(0.7); + expect(c.background).toBe('#3a2a00'); + expect(c.border).toBe('#ffaa00'); + }); + + it('tier 3: score 0.8 → red', () => { + const c = hubScoreColor(0.8); + expect(c.background).toBe('#3a0000'); + expect(c.border).toBe('#ff4444'); + }); + + it('tier 3: score 1.0 → red', () => { + const c = hubScoreColor(1.0); + expect(c.background).toBe('#3a0000'); + expect(c.border).toBe('#ff4444'); + }); +}); + +describe('hubScoreBorderWidth', () => { + it('tier 0: undefined → 1', () => { + expect(hubScoreBorderWidth(undefined)).toBe(1); + }); + + it('tier 0: score 0.0 → 1', () => { + expect(hubScoreBorderWidth(0.0)).toBe(1); + }); + + it('tier 0: score 0.19 → 1', () => { + expect(hubScoreBorderWidth(0.19)).toBe(1); + }); + + it('tier 1: score 0.2 → 2', () => { + expect(hubScoreBorderWidth(0.2)).toBe(2); + }); + + it('tier 1: score 0.49 → 2', () => { + expect(hubScoreBorderWidth(0.49)).toBe(2); + }); + + it('tier 2: score 0.5 → 3', () => { + expect(hubScoreBorderWidth(0.5)).toBe(3); + }); + + it('tier 2: score 0.79 → 3', () => { + expect(hubScoreBorderWidth(0.79)).toBe(3); + }); + + it('tier 3: score 0.8 → 4', () => { + expect(hubScoreBorderWidth(0.8)).toBe(4); + }); + + it('tier 3: score 1.0 → 4', () => { + expect(hubScoreBorderWidth(1.0)).toBe(4); + }); +}); + +describe('nodeColor', () => { + it('uses community palette when communityId > 0', () => { + const c = nodeColor(1, 0.1); + expect(c.background).toBe(COMMUNITY_PALETTE[0]); + expect(c.border).toBe('#333'); + }); + + it('wraps palette index modulo 12', () => { + const c = nodeColor(13, 0.1); // (13-1) % 12 = 0 + expect(c.background).toBe(COMMUNITY_PALETTE[0]); + }); + + it('falls back to hubScoreColor when communityId is 0', () => { + const c = nodeColor(0, 0.9); + expect(c.background).toBe('#3a0000'); + }); + + it('falls back to hubScoreColor when communityId is undefined', () => { + const c = nodeColor(undefined, undefined); + expect(c.background).toBe('#2d2d2d'); + }); +}); + +describe('buildCommunityLegend', () => { + it('returns empty string when no nodes have communityId > 0', () => { + const nodes: HtmlNodeData[] = [ + { id: '/src/a.ts', label: 'a.ts', hubScore: 0.5 }, + { id: '/src/b.ts', label: 'b.ts', communityId: 0 }, + ]; + expect(buildCommunityLegend(nodes)).toBe(''); + }); + + it('returns empty string for empty nodes array', () => { + expect(buildCommunityLegend([])).toBe(''); + }); + + it('renders the "Import clusters" title when communities exist', () => { + const nodes: HtmlNodeData[] = [ + { id: '/src/a.ts', label: 'a.ts', communityId: 1, hubScore: 0.8 }, + ]; + const html = buildCommunityLegend(nodes); + expect(html).toContain('Import clusters'); + }); + + it('renders one cluster entry per unique communityId', () => { + const nodes: HtmlNodeData[] = [ + { id: '/src/a.ts', label: 'a.ts', communityId: 1, hubScore: 0.5 }, + { id: '/src/b.ts', label: 'b.ts', communityId: 2, hubScore: 0.3 }, + { id: '/src/c.ts', label: 'c.ts', communityId: 1, hubScore: 0.2 }, + ]; + const html = buildCommunityLegend(nodes); + expect(html).toContain('Cluster 1'); + expect(html).toContain('Cluster 2'); + // Should not duplicate cluster 1 + expect(html.split('Cluster 1').length - 1).toBe(1); + }); + + it('uses directory path from any community node as label', () => { + const nodes: HtmlNodeData[] = [ + { id: '/workspace/src/analyzer/low.ts', label: 'low.ts', communityId: 1, hubScore: 0.1 }, + { id: '/workspace/src/analyzer/high.ts', label: 'high.ts', communityId: 1, hubScore: 0.9 }, + { id: '/workspace/src/analyzer/mid.ts', label: 'mid.ts', communityId: 1, hubScore: 0.5 }, + ]; + const html = buildCommunityLegend(nodes); + // Label should be the common directory path, not a specific file + expect(html).toContain('analyzer'); + // No individual filenames should appear as labels + expect(html).not.toContain('low.ts'); + expect(html).not.toContain('high.ts'); + expect(html).not.toContain('mid.ts'); + }); + + it('uses directory path label regardless of hubScore', () => { + const nodes: HtmlNodeData[] = [ + { id: '/workspace/src/webview/a.ts', label: 'a.ts', communityId: 1 }, + { id: '/workspace/src/webview/b.ts', label: 'b.ts', communityId: 1, hubScore: 0.4 }, + ]; + const html = buildCommunityLegend(nodes); + expect(html).toContain('webview'); + }); + + it('sorts clusters by communityId ascending', () => { + const nodes: HtmlNodeData[] = [ + { id: '/src/z.ts', label: 'z.ts', communityId: 3, hubScore: 0.5 }, + { id: '/src/a.ts', label: 'a.ts', communityId: 1, hubScore: 0.5 }, + { id: '/src/m.ts', label: 'm.ts', communityId: 2, hubScore: 0.5 }, + ]; + const html = buildCommunityLegend(nodes); + const pos1 = html.indexOf('Cluster 1'); + const pos2 = html.indexOf('Cluster 2'); + const pos3 = html.indexOf('Cluster 3'); + expect(pos1).toBeLessThan(pos2); + expect(pos2).toBeLessThan(pos3); + }); + + it('uses the correct palette color for communityId', () => { + const nodes: HtmlNodeData[] = [ + { id: '/src/a.ts', label: 'a.ts', communityId: 2, hubScore: 0.5 }, + ]; + const html = buildCommunityLegend(nodes); + expect(html).toContain(COMMUNITY_PALETTE[1]); // index (2-1) % 12 = 1 + }); + + it('escapes special characters in node paths (Règle 10)', () => { + const nodes: HtmlNodeData[] = [ + { id: '/src/&dir/a.ts', label: 'a.ts', communityId: 1, hubScore: 0.5 }, + ]; + const html = buildCommunityLegend(nodes); + expect(html).not.toContain(''); + expect(html).toContain('<evil>'); + expect(html).toContain('&dir'); + }); + + it('is a fixed-position overlay with inline CSS only', () => { + const nodes: HtmlNodeData[] = [ + { id: '/src/a.ts', label: 'a.ts', communityId: 1, hubScore: 0.5 }, + ]; + const html = buildCommunityLegend(nodes); + expect(html).toContain('position:fixed'); + // No external stylesheet references + expect(html).not.toContain('href='); + expect(html).not.toContain('url('); + }); +}); + +describe('exportHtml', () => { + beforeEach(() => { + mockReadFileSync.mockReset(); + mockWriteFileSync.mockReset(); + mockMkdirSync.mockReset(); + mockReadFileSync.mockReturnValue('// vis-network mock'); + const mockRequire = Object.assign(vi.fn(), { + resolve: vi.fn().mockReturnValue('/mock/vis-network.min.js'), + }) as unknown as NodeRequire; + vi.stubGlobal('require', mockRequire); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const baseConfig: HtmlExporterConfig = { + nodes: [ + { id: '/src/a.ts', label: 'a.ts', hubScore: 0.1 }, + { id: '/src/b.ts', label: 'b.ts', hubScore: 0.6 }, + ], + edges: [ + { from: '/src/a.ts', to: '/src/b.ts', id: '/src/a.ts::/src/b.ts' }, + { from: '/src/b.ts', to: '/src/a.ts', id: '/src/b.ts::/src/a.ts' }, + ], + unusedEdges: ['/src/b.ts::/src/a.ts'], + workspaceName: 'my-project', + outputPath: '/output/graph.html', + }; + + it('creates output directory', () => { + exportHtml(baseConfig); + expect(mockMkdirSync).toHaveBeenCalledWith('/output', { recursive: true }); + }); + + it('writes to correct outputPath', () => { + exportHtml(baseConfig); + expect(mockWriteFileSync).toHaveBeenCalledWith( + '/output/graph.html', + expect.any(String), + 'utf-8', + ); + }); + + it('inlines vis-network source', () => { + exportHtml(baseConfig); + const html = mockWriteFileSync.mock.calls[0][1] as string; + expect(html).toContain('vis-network mock'); + }); + + it('marks unused edges as dashes:true', () => { + exportHtml(baseConfig); + const html = mockWriteFileSync.mock.calls[0][1] as string; + expect(html).toContain('"dashes":true'); + }); + + it('marks used edges as dashes:false', () => { + exportHtml(baseConfig); + const html = mockWriteFileSync.mock.calls[0][1] as string; + expect(html).toContain('"dashes":false'); + }); + + it('escapes workspaceName in HTML title', () => { + const config = { ...baseConfig, workspaceName: '' }; + exportHtml(config); + const html = mockWriteFileSync.mock.calls[0][1] as string; + expect(html).toContain('<My & Project>'); + expect(html).not.toContain(''); + }); + + it('includes node labels in output', () => { + exportHtml(baseConfig); + const html = mockWriteFileSync.mock.calls[0][1] as string; + expect(html).toContain('a.ts'); + expect(html).toContain('b.ts'); + }); + + it('uses custom outputPath', () => { + const config = { ...baseConfig, outputPath: '/custom/output/result.html' }; + exportHtml(config); + expect(mockMkdirSync).toHaveBeenCalledWith('/custom/output', { recursive: true }); + expect(mockWriteFileSync).toHaveBeenCalledWith( + '/custom/output/result.html', + expect.any(String), + 'utf-8', + ); + }); + + it('produces valid HTML structure', () => { + exportHtml(baseConfig); + const html = mockWriteFileSync.mock.calls[0][1] as string; + expect(html).toContain(''); + expect(html).toContain('
'); + expect(html).toContain('vis.Network'); + expect(html).toContain('vis.DataSet'); + }); + + it('does NOT include legend when no node has communityId > 0', () => { + exportHtml(baseConfig); + const html = mockWriteFileSync.mock.calls[0][1] as string; + expect(html).not.toContain('Import clusters'); + }); + + it('includes legend when at least one node has communityId > 0', () => { + const config: HtmlExporterConfig = { + ...baseConfig, + nodes: [ + { id: '/src/a.ts', label: 'a.ts', hubScore: 0.1, communityId: 1 }, + { id: '/src/b.ts', label: 'b.ts', hubScore: 0.6, communityId: 2 }, + ], + }; + exportHtml(config); + const html = mockWriteFileSync.mock.calls[0][1] as string; + expect(html).toContain('Import clusters'); + expect(html).toContain('Cluster 1'); + expect(html).toContain('Cluster 2'); + }); + + it('legend appears before ', () => { + const config: HtmlExporterConfig = { + ...baseConfig, + nodes: [ + { id: '/src/a.ts', label: 'a.ts', communityId: 1, hubScore: 0.5 }, + ], + }; + exportHtml(config); + const html = mockWriteFileSync.mock.calls[0][1] as string; + const legendPos = html.indexOf('Import clusters'); + const bodyClosePos = html.indexOf(''); + expect(legendPos).toBeGreaterThan(0); + expect(legendPos).toBeLessThan(bodyClosePos); + }); + + it('legend uses inline CSS only (CSP safe)', () => { + const config: HtmlExporterConfig = { + ...baseConfig, + nodes: [ + { id: '/src/a.ts', label: 'a.ts', communityId: 1, hubScore: 0.5 }, + ], + }; + exportHtml(config); + const html = mockWriteFileSync.mock.calls[0][1] as string; + // Extract legend portion + const legendStart = html.indexOf('
{ expect.objectContaining({ command: "updateGraph", filePath: mainTsPath, - data: mockGraphData, + data: expect.objectContaining(mockGraphData), expandAll: undefined, // globalState.get returns undefined by default mock }), ); @@ -647,4 +647,58 @@ describe("GraphProvider", () => { const onDidSaveSpy = vi.mocked(vscode.workspace.onDidSaveTextDocument); expect(onDidSaveSpy).not.toHaveBeenCalled(); }); + + it("_sendGraphUpdate: enrichedMessage contains showCommunities when checkUsage=true (unusedFilter active + mode=hide)", async () => { + // Patch _configSnapshot directly — no provider rebuild needed. + const snap = (provider as any)["_configSnapshot"]; + snap.unusedDependencyMode = "hide"; + snap.showCommunities = true; + + // Force unusedFilterActive=true so getUnusedFilterActive() → true → checkUsage=true. + (mockContext.globalState.get as ReturnType).mockImplementation( + (key: string, def?: unknown) => { + if (key === "unusedFilterActive") return true; + return def; + }, + ); + + // Replace graphViewService in the DI container with a stub so buildGraphData + // resolves synchronously without Spider or SymbolAnalyzer involvement. + const mockGraphData = { nodes: [], edges: [] }; + const mockGraphViewService = { + buildGraphData: vi.fn().mockResolvedValue(mockGraphData), + }; + (provider as any)._container.register( + graphProviderServiceTokens.graphViewService, + () => mockGraphViewService, + ); + + const webview = { + postMessage: vi.fn(), + asWebviewUri: vi.fn(), + options: {}, + html: "", + onDidReceiveMessage: vi.fn(), + cspSource: "test-csp", + }; + const view = { webview, onDidDispose: vi.fn() }; + provider.resolveWebviewView(view as any, {} as any, {} as any); + + // Call _sendGraphUpdate directly — it's private but accessible via (provider as any). + const mainTsPath = path.join(testRootDir, "src", "main.ts"); + await (provider as any)._sendGraphUpdate(mainTsPath, false, "unknown"); + + // The enrichedMessage (second postMessage call) must carry showCommunities. + const calls = (webview.postMessage as ReturnType).mock.calls; + const enrichedCall = calls.find( + ([msg]: [any]) => + msg.command === "updateGraph" && msg.refreshReason === "usage-analysis", + ); + expect(enrichedCall).toBeDefined(); + expect(enrichedCall![0]).toMatchObject({ + command: "updateGraph", + refreshReason: "usage-analysis", + showCommunities: true, + }); + }); }); diff --git a/tests/extension/ProviderStateManager.test.ts b/tests/extension/ProviderStateManager.test.ts index dfe224b4..c13ff59f 100644 --- a/tests/extension/ProviderStateManager.test.ts +++ b/tests/extension/ProviderStateManager.test.ts @@ -180,4 +180,28 @@ describe('ProviderStateManager', () => { expect(manager.getLastActiveFilePath()).toBe('/workspace/src/main.ts'); expect(context.workspaceState.update).toHaveBeenCalledWith('lastActiveFilePath', '/workspace/src/main.ts'); }); + + describe('showCommunities', () => { + it('returns true by default when config does not override', () => { + const manager = new ProviderStateManager(context, 1000); + const snapshot = manager.loadConfiguration(); + expect(snapshot.showCommunities).toBe(true); + }); + + it('returns false when config sets showCommunities=false', () => { + configValues['showCommunities'] = false; + const manager = new ProviderStateManager(context, 1000); + const snapshot = manager.loadConfiguration(); + expect(snapshot.showCommunities).toBe(false); + delete configValues['showCommunities']; + }); + + it('returns true when config explicitly sets showCommunities=true', () => { + configValues['showCommunities'] = true; + const manager = new ProviderStateManager(context, 1000); + const snapshot = manager.loadConfiguration(); + expect(snapshot.showCommunities).toBe(true); + delete configValues['showCommunities']; + }); + }); }); diff --git a/tests/mcp/shared/helpers.test.ts b/tests/mcp/shared/helpers.test.ts index 6ba8b9b0..0038bd60 100644 --- a/tests/mcp/shared/helpers.test.ts +++ b/tests/mcp/shared/helpers.test.ts @@ -122,6 +122,62 @@ describe("MCP Worker Helpers", () => { expect(nodes[0].dependencyCount).toBe(0); expect(nodes[0].dependentCount).toBe(0); }); + + it("should not include hubScore/communityId when metadata is undefined", () => { + const nodePaths = [normalizePath("/proj/src/a.ts")]; + const nodes = buildNodeInfo(nodePaths, new Map(), new Map(), "/proj", undefined); + expect(nodes[0]).not.toHaveProperty("hubScore"); + expect(nodes[0]).not.toHaveProperty("communityId"); + }); + + it("should attach hubScore and communityId from metadata when present", () => { + const nodePathA = normalizePath("/proj/src/a.ts"); + const nodePathB = normalizePath("/proj/src/b.ts"); + const nodePaths = [nodePathA, nodePathB]; + const metadata = { + [nodePathA]: { hubScore: 0.9, communityId: 1 }, + [nodePathB]: { hubScore: 0.3, communityId: 2 }, + }; + + const nodes = buildNodeInfo(nodePaths, new Map(), new Map(), "/proj", metadata); + + expect(nodes[0].hubScore).toBe(0.9); + expect(nodes[0].communityId).toBe(1); + expect(nodes[1].hubScore).toBe(0.3); + expect(nodes[1].communityId).toBe(2); + }); + + it("should omit hubScore/communityId for nodes missing from metadata", () => { + const nodePathA = normalizePath("/proj/src/a.ts"); + const nodePathB = normalizePath("/proj/src/b.ts"); + const nodePaths = [nodePathA, nodePathB]; + // Only nodePathA has metadata + const metadata = { + [nodePathA]: { hubScore: 0.5, communityId: 0 }, + }; + + const nodes = buildNodeInfo(nodePaths, new Map(), new Map(), "/proj", metadata); + + expect(nodes[0].hubScore).toBe(0.5); + expect(nodes[0].communityId).toBe(0); + expect(nodes[1]).not.toHaveProperty("hubScore"); + expect(nodes[1]).not.toHaveProperty("communityId"); + }); + + it("should lookup metadata using normalized nodePaths keys (Règle 03)", () => { + // Simulate un chemin brut (backslash Windows-like) vs clé normalisée + const rawPath = "/proj/src/a.ts"; + const normalizedKey = normalizePath(rawPath); + const nodePaths = [normalizedKey]; + const metadata = { + [normalizedKey]: { hubScore: 0.75, communityId: 3 }, + }; + + const nodes = buildNodeInfo(nodePaths, new Map(), new Map(), "/proj", metadata); + + expect(nodes[0].hubScore).toBe(0.75); + expect(nodes[0].communityId).toBe(3); + }); }); describe("buildEdgeInfo", () => { diff --git a/tests/mcp/tools/graph.test.ts b/tests/mcp/tools/graph.test.ts index 2c2be04c..617a5ae3 100644 --- a/tests/mcp/tools/graph.test.ts +++ b/tests/mcp/tools/graph.test.ts @@ -69,6 +69,66 @@ describe("graph tools", () => { expect(result.edges).toHaveLength(1); expect(result.circularDependencies).toEqual([]); }); + + it("should populate hubScore on each returned node", async () => { + const entryFile = await createTempFile(tempDir, "entry.ts", ""); + const depFile = await createTempFile(tempDir, "dep.ts", ""); + + const spiderMock = { + config: { maxDepth: 5 }, + updateConfig: vi.fn(), + crawl: vi.fn(async () => ({ + nodes: [entryFile, depFile], + edges: [{ source: entryFile, target: depFile }], + })), + verifyDependencyUsage: vi.fn(async () => true), + }; + + setupWorkerState(spiderMock); + + const result = await executeCrawlDependencyGraph({ entryFile }); + + // computeNodeMetadata attache toujours hubScore (même 0) + for (const node of result.nodes) { + expect(node).toHaveProperty("hubScore"); + expect(typeof node.hubScore).toBe("number"); + } + }); + + it("should assign distinct communityIds to nodes in different directories", async () => { + // Crée deux fichiers dans des sous-dossiers différents pour forcer des communautés distinctes + const subDirA = path.join(tempDir, "moduleA"); + const subDirB = path.join(tempDir, "moduleB"); + await fs.mkdir(subDirA); + await fs.mkdir(subDirB); + + const fileA = await createTempFile(subDirA, "index.ts", ""); + const fileB = await createTempFile(subDirB, "index.ts", ""); + + const spiderMock = { + config: { maxDepth: 5 }, + updateConfig: vi.fn(), + crawl: vi.fn(async () => ({ + nodes: [fileA, fileB], + edges: [], + })), + verifyDependencyUsage: vi.fn(async () => true), + }; + + setupWorkerState(spiderMock); + + const result = await executeCrawlDependencyGraph({ entryFile: fileA }); + + // Les deux nodes doivent avoir un communityId défini + for (const node of result.nodes) { + expect(node).toHaveProperty("communityId"); + expect(typeof node.communityId).toBe("number"); + } + + // Les communityIds des deux nodes doivent être distincts (dossiers différents) + const communityIds = result.nodes.map((n) => n.communityId); + expect(communityIds[0]).not.toBe(communityIds[1]); + }); }); describe("executeExpandNode", () => { diff --git a/tests/mcp/types.test.ts b/tests/mcp/types.test.ts index 39220e09..52896f91 100644 --- a/tests/mcp/types.test.ts +++ b/tests/mcp/types.test.ts @@ -13,6 +13,8 @@ import { AnalyzeFileLogicParamsSchema, CrawlDependencyGraphParamsSchema, createErrorResponse, + GraphDataSchema, + GraphNodeMetadataSchema, createSuccessResponse, enrichDependency, ExpandNodeParamsSchema, @@ -1186,3 +1188,305 @@ describe('sanitizeString', () => { expect(() => sanitizeString(mediumString, 50)).toThrow('exceeds maximum length'); }); }); + +// ============================================================================ +// F2: GraphNodeMetadataSchema Tests +// ============================================================================ + +describe('GraphNodeMetadataSchema', () => { + it('validates minimal metadata with only hubScore', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.hubScore).toBe(0.5); + expect(result.data.loc).toBeUndefined(); + expect(result.data.fileExtension).toBeUndefined(); + } + }); + + it('validates metadata with all optional fields', () => { + const result = GraphNodeMetadataSchema.safeParse({ + hubScore: 0.8, + loc: 120, + fileExtension: 'ts', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.loc).toBe(120); + expect(result.data.fileExtension).toBe('ts'); + } + }); + + it('accepts hubScore boundary values 0 and 1', () => { + expect(GraphNodeMetadataSchema.safeParse({ hubScore: 0 }).success).toBe(true); + expect(GraphNodeMetadataSchema.safeParse({ hubScore: 1 }).success).toBe(true); + }); + + it('rejects hubScore below 0', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: -0.1 }); + expect(result.success).toBe(false); + }); + + it('rejects hubScore above 1', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 1.1 }); + expect(result.success).toBe(false); + }); + + it('rejects missing hubScore', () => { + const result = GraphNodeMetadataSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects non-positive loc', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5, loc: 0 }); + expect(result.success).toBe(false); + }); + + it('rejects non-integer loc', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5, loc: 1.5 }); + expect(result.success).toBe(false); + }); + + it('rejects negative loc', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5, loc: -1 }); + expect(result.success).toBe(false); + }); + + it('accepts hubScore exact boundary -0.001 rejected, 1.001 rejected (tighter bounds)', () => { + expect(GraphNodeMetadataSchema.safeParse({ hubScore: -0.001 }).success).toBe(false); + expect(GraphNodeMetadataSchema.safeParse({ hubScore: 1.001 }).success).toBe(false); + }); + + it('accepts fileExtension as empty string', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5, fileExtension: '' }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.fileExtension).toBe(''); + } + }); + + it('accepts valid positive integer loc', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5, loc: 1 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.loc).toBe(1); + } + }); + + it('accepts communityId: 0 (noeud isolé)', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5, communityId: 0 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.communityId).toBe(0); + } + }); + + it('accepts communityId: 5 (cluster)', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5, communityId: 5 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.communityId).toBe(5); + } + }); + + it('rejects communityId: -1 (min(0) violated)', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5, communityId: -1 }); + expect(result.success).toBe(false); + }); + + it('rejects communityId: 1.5 (int violated)', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5, communityId: 1.5 }); + expect(result.success).toBe(false); + }); + + it('accepts without communityId (optional)', () => { + const result = GraphNodeMetadataSchema.safeParse({ hubScore: 0.5 }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.communityId).toBeUndefined(); + } + }); +}); + +// ============================================================================ +// F2: GraphDataSchema Tests +// ============================================================================ + +describe('GraphDataSchema', () => { + it('validates minimal GraphData (no optional fields)', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts', '/src/b.ts'], + edges: [{ source: '/src/a.ts', target: '/src/b.ts' }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.nodes).toHaveLength(2); + expect(result.data.edges).toHaveLength(1); + expect(result.data.nodeMetadata).toBeUndefined(); + } + }); + + it('validates GraphData with nodeMetadata present (backward compat with F2)', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/hub.ts', '/src/leaf.ts'], + edges: [{ source: '/src/hub.ts', target: '/src/leaf.ts' }], + nodeMetadata: { + '/src/hub.ts': { hubScore: 0.9, loc: 350, fileExtension: 'ts' }, + '/src/leaf.ts': { hubScore: 0.1 }, + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.nodeMetadata?.['/src/hub.ts'].hubScore).toBe(0.9); + expect(result.data.nodeMetadata?.['/src/leaf.ts'].hubScore).toBe(0.1); + expect(result.data.nodeMetadata?.['/src/leaf.ts'].loc).toBeUndefined(); + } + }); + + it('validates GraphData with all optional fields including nodeMetadata', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts'], + edges: [], + nodeLabels: { '/src/a.ts': 'a' }, + parentCounts: { '/src/a.ts': 3 }, + unusedEdges: [], + nodeMetadata: { '/src/a.ts': { hubScore: 0.5 } }, + }); + expect(result.success).toBe(true); + }); + + it('validates GraphData without nodeMetadata (backward compat — pre-F2 data)', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts'], + edges: [], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.nodeMetadata).toBeUndefined(); + } + }); + + it('rejects GraphData with invalid nodeMetadata (hubScore out of range)', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts'], + edges: [], + nodeMetadata: { + '/src/a.ts': { hubScore: 2.5 }, + }, + }); + expect(result.success).toBe(false); + }); + + it('rejects GraphData with missing nodes array', () => { + const result = GraphDataSchema.safeParse({ edges: [] }); + expect(result.success).toBe(false); + }); + + it('rejects GraphData with missing edges array', () => { + const result = GraphDataSchema.safeParse({ nodes: [] }); + expect(result.success).toBe(false); + }); + + it('validates edge with relationType field', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts', '/src/b.ts'], + edges: [{ source: '/src/a.ts', target: '/src/b.ts', relationType: 'call' }], + }); + expect(result.success).toBe(true); + }); + + it('rejects edge with invalid relationType', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts', '/src/b.ts'], + edges: [{ source: '/src/a.ts', target: '/src/b.ts', relationType: 'unknown' }], + }); + expect(result.success).toBe(false); + }); + + it('validates edge with relationType dependency', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts', '/src/b.ts'], + edges: [{ source: '/src/a.ts', target: '/src/b.ts', relationType: 'dependency' }], + }); + expect(result.success).toBe(true); + }); + + it('validates edge with relationType reference', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts', '/src/b.ts'], + edges: [{ source: '/src/a.ts', target: '/src/b.ts', relationType: 'reference' }], + }); + expect(result.success).toBe(true); + }); + + it('validates edge without relationType (optional field absent)', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts', '/src/b.ts'], + edges: [{ source: '/src/a.ts', target: '/src/b.ts' }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.edges[0].relationType).toBeUndefined(); + } + }); + + it('rejects edge missing source', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts', '/src/b.ts'], + edges: [{ target: '/src/b.ts' }], + }); + expect(result.success).toBe(false); + }); + + it('rejects edge missing target', () => { + const result = GraphDataSchema.safeParse({ + nodes: ['/src/a.ts', '/src/b.ts'], + edges: [{ source: '/src/a.ts' }], + }); + expect(result.success).toBe(false); + }); + + it('validates nodeMetadata as empty Record', () => { + const result = GraphDataSchema.safeParse({ + nodes: [], + edges: [], + nodeMetadata: {}, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.nodeMetadata).toEqual({}); + } + }); +}); + +describe('validateToolParams — uncovered branches', () => { + it('returns failure for unknown tool name', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = validateToolParams('nonexistent_tool_xyz' as any, {}); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('nonexistent_tool_xyz'); + } + }); + + it('returns failure with root-level message when issue path is empty', () => { + // get_index_status takes no params — passing a wrong type triggers a root-level Zod error + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = validateToolParams('graphitlive_get_index_status' as any, 'not-an-object'); + expect(result.success).toBe(false); + }); +}); + +describe('validateFilePath — uncovered branches', () => { + it('accepts a valid relative path within rootDir', () => { + expect(() => + validateFilePath('src/index.ts', '/project') + ).not.toThrow(); + }); + + it('accepts an absolute path at exact rootDir boundary', () => { + expect(() => + validateFilePath('/project/src/deeply/nested/file.ts', '/project') + ).not.toThrow(); + }); +}); diff --git a/tests/shared/communityPalette.test.ts b/tests/shared/communityPalette.test.ts new file mode 100644 index 00000000..c2dd184f --- /dev/null +++ b/tests/shared/communityPalette.test.ts @@ -0,0 +1,29 @@ +/** + * Unit tests for communityPalette (F4 feature). + */ +import { describe, it, expect } from 'vitest'; +import { COMMUNITY_PALETTE } from '../../src/shared/communityPalette.js'; + +describe('COMMUNITY_PALETTE', () => { + it('has exactly 12 colors', () => { + expect(COMMUNITY_PALETTE.length).toBe(12); + }); + + it('each color is a valid 6-char hex string', () => { + for (const color of COMMUNITY_PALETTE) { + expect(color).toMatch(/^#[0-9A-Fa-f]{6}$/); + } + }); + + it('is readonly (cannot be reassigned via TypeScript type)', () => { + // Type-level check: TypeScript will prevent mutations at compile time. + // Runtime: the array is a plain array (const assertion doesn't freeze), + // but we can verify the type is readonly string[] by checking it is an array. + expect(Array.isArray(COMMUNITY_PALETTE)).toBe(true); + }); + + it('all colors are unique', () => { + const unique = new Set(COMMUNITY_PALETTE); + expect(unique.size).toBe(COMMUNITY_PALETTE.length); + }); +}); diff --git a/tests/shared/graph-types.test.ts b/tests/shared/graph-types.test.ts new file mode 100644 index 00000000..b0b9979e --- /dev/null +++ b/tests/shared/graph-types.test.ts @@ -0,0 +1,81 @@ +/** + * Unit tests for GraphNodeMetadata and GraphData.nodeMetadata (F2 feature). + * AC1: GraphData compiles and works without nodeMetadata present. + * AC2: nodeMetadata keys must be normalized paths. + */ +import { describe, it, expect } from 'vitest'; +import { normalizePath } from '../../src/shared/path.js'; +import type { GraphData, GraphNodeMetadata } from '../../src/shared/graph-types.js'; + +describe('GraphData.nodeMetadata', () => { + it('AC1 — GraphData is valid without nodeMetadata', () => { + const data: GraphData = { + nodes: ['src/index.ts'], + edges: [], + }; + expect(data.nodeMetadata).toBeUndefined(); + expect(data.nodes).toHaveLength(1); + }); + + it('AC1 — GraphData with only nodes and edges has no nodeMetadata by default', () => { + const data: GraphData = { + nodes: [], + edges: [], + }; + expect(data.nodeMetadata).toBeUndefined(); + }); + + it('AC2 — nodeMetadata keys should be normalizePath results', () => { + const filePath = 'src/utils.ts'; + const key = normalizePath(filePath); + const meta: GraphNodeMetadata = { hubScore: 0.5, fileExtension: 'ts' }; + const data: GraphData = { + nodes: [filePath], + edges: [], + nodeMetadata: { [key]: meta }, + }; + expect(data.nodeMetadata).toBeDefined(); + expect(data.nodeMetadata![key]).toEqual({ hubScore: 0.5, fileExtension: 'ts' }); + }); + + it('AC2 — normalizePath is idempotent for nodeMetadata keys', () => { + const path1 = 'src/foo.ts'; + const path2 = normalizePath(path1); + expect(normalizePath(path2)).toBe(path2); + }); + + describe('GraphNodeMetadata shape', () => { + it('hubScore is required', () => { + const meta: GraphNodeMetadata = { hubScore: 0 }; + expect(meta.hubScore).toBe(0); + }); + + it('loc is optional — absent means not computed, not zero', () => { + const metaWithLoc: GraphNodeMetadata = { hubScore: 0.3, loc: 42 }; + const metaWithoutLoc: GraphNodeMetadata = { hubScore: 0.3 }; + expect(metaWithLoc.loc).toBe(42); + expect(metaWithoutLoc.loc).toBeUndefined(); + }); + + it('fileExtension is optional — absent if unknown', () => { + const metaWithExt: GraphNodeMetadata = { hubScore: 0.1, fileExtension: 'ts' }; + const metaWithoutExt: GraphNodeMetadata = { hubScore: 0.1 }; + expect(metaWithExt.fileExtension).toBe('ts'); + expect(metaWithoutExt.fileExtension).toBeUndefined(); + }); + + it('hubScore guard: max === 0 → hubScore should be 0', () => { + const meta: GraphNodeMetadata = { hubScore: 0 }; + expect(meta.hubScore).toBe(0); + }); + + it('hubScore is in [0-1] range', () => { + const scores = [0, 0.001, 0.5, 0.999, 1]; + for (const s of scores) { + const meta: GraphNodeMetadata = { hubScore: s }; + expect(meta.hubScore).toBeGreaterThanOrEqual(0); + expect(meta.hubScore).toBeLessThanOrEqual(1); + } + }); + }); +}); diff --git a/tests/webview/components/ReactFlowGraph.showCommunities.test.tsx b/tests/webview/components/ReactFlowGraph.showCommunities.test.tsx new file mode 100644 index 00000000..0b1a2b38 --- /dev/null +++ b/tests/webview/components/ReactFlowGraph.showCommunities.test.tsx @@ -0,0 +1,47 @@ +/** + * ReactFlowGraph — showCommunities gate (unit test for the gating logic) + * + * @vitest-environment happy-dom + * + * Strategy: test the showCommunities gate directly via CommunityLegend conditional. + * We render CommunityLegend directly (as ReactFlowGraph does internally) to validate + * that the `showCommunities && ` pattern produces the expected output. + * Full ReactFlowGraph render is too complex to mock (1300+ lines, ReactFlow provider etc). + * + * Covered: + * - Gate produces no legend when showCommunities=false even if communities exist + * - Gate produces legend when showCommunities=true and communities exist + */ + +// @vitest-environment happy-dom + +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { CommunityLegend } from "../../../src/webview/components/reactflow/CommunityLegend"; + +afterEach(() => cleanup()); + +const communities = [{ id: 1, label: "Spider.ts", color: "#4E79A7" }]; + +function GatedLegend({ showCommunities }: { showCommunities: boolean }) { + return React.createElement( + "div", + null, + showCommunities + ? React.createElement(CommunityLegend, { communities }) + : null, + ); +} + +describe("ReactFlowGraph — showCommunities gate", () => { + it("renders CommunityLegend when showCommunities=true and communities exist", () => { + render(React.createElement(GatedLegend, { showCommunities: true })); + expect(screen.getByText("Spider.ts")).toBeTruthy(); + }); + + it("does NOT render CommunityLegend when showCommunities=false", () => { + render(React.createElement(GatedLegend, { showCommunities: false })); + expect(screen.queryByText("Spider.ts")).toBeNull(); + }); +}); diff --git a/tests/webview/components/reactflow/CommunityLegend.test.ts b/tests/webview/components/reactflow/CommunityLegend.test.ts new file mode 100644 index 00000000..07e49d53 --- /dev/null +++ b/tests/webview/components/reactflow/CommunityLegend.test.ts @@ -0,0 +1,108 @@ +/** + * CommunityLegend.tsx — coverage tests + * + * @vitest-environment happy-dom + * + * Branches covered: + * - communities: [] → returns null (nothing rendered) + * - communities with label → label text displayed, title = "Community N" + * - swatch uses provided color + */ + +// @vitest-environment happy-dom + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; + +import { CommunityLegend } from '../../../../src/webview/components/reactflow/CommunityLegend'; + +describe('CommunityLegend', () => { + it('renders nothing when communities is empty', () => { + const { container } = render( + React.createElement(CommunityLegend, { communities: [] }) + ); + expect(container.firstChild).toBeNull(); + }); + + it('renders a label with the hub node basename', () => { + render( + React.createElement(CommunityLegend, { + communities: [{ id: 1, label: 'Spider.ts', color: '#4E79A7' }], + }) + ); + expect(screen.getByText('Spider.ts')).toBeTruthy(); + }); + + it('sets title="Cluster N — M clusters total" on the label span', () => { + render( + React.createElement(CommunityLegend, { + communities: [{ id: 1, label: 'Spider.ts', color: '#4E79A7' }], + }) + ); + const span = screen.getByTitle('Cluster 1 — 1 clusters total'); + expect(span).toBeTruthy(); + expect(span.textContent).toBe('Spider.ts'); + }); + + it('renders correct swatch color from provided color prop', () => { + render( + React.createElement(CommunityLegend, { + communities: [{ id: 2, label: 'index.ts', color: '#F28E2B' }], + }) + ); + const swatch = screen.getByTestId('community-swatch-2') as HTMLElement; + expect(swatch.style.background).toBe('#F28E2B'); + }); + + it('renders multiple communities', () => { + render( + React.createElement(CommunityLegend, { + communities: [ + { id: 1, label: 'Spider.ts', color: '#4E79A7' }, + { id: 2, label: 'Indexer.ts', color: '#F28E2B' }, + { id: 3, label: 'Query.ts', color: '#E15759' }, + ], + }) + ); + expect(screen.getByTestId('community-swatch-1')).toBeTruthy(); + expect(screen.getByTestId('community-swatch-2')).toBeTruthy(); + expect(screen.getByTestId('community-swatch-3')).toBeTruthy(); + expect(screen.getByText('Spider.ts')).toBeTruthy(); + expect(screen.getByText('Indexer.ts')).toBeTruthy(); + expect(screen.getByText('Query.ts')).toBeTruthy(); + }); + + it('renders "Import clusters" header', () => { + render( + React.createElement(CommunityLegend, { + communities: [{ id: 1, label: 'Hub.ts', color: '#4E79A7' }], + }) + ); + expect(screen.getByText('Import clusters')).toBeTruthy(); + }); + + it('renders subtitle "Groups of closely connected files"', () => { + render( + React.createElement(CommunityLegend, { + communities: [{ id: 1, label: 'Hub.ts', color: '#4E79A7' }], + }) + ); + expect(screen.getByText('Groups of closely connected files')).toBeTruthy(); + }); + + it('title includes total cluster count for multiple communities', () => { + render( + React.createElement(CommunityLegend, { + communities: [ + { id: 1, label: 'A.ts', color: '#4E79A7' }, + { id: 2, label: 'B.ts', color: '#F28E2B' }, + ], + }) + ); + const span1 = screen.getByTitle('Cluster 1 — 2 clusters total'); + const span2 = screen.getByTitle('Cluster 2 — 2 clusters total'); + expect(span1.textContent).toBe('A.ts'); + expect(span2.textContent).toBe('B.ts'); + }); +}); diff --git a/tests/webview/components/reactflow/FileNode.test.ts b/tests/webview/components/reactflow/FileNode.test.ts new file mode 100644 index 00000000..69f8e310 --- /dev/null +++ b/tests/webview/components/reactflow/FileNode.test.ts @@ -0,0 +1,643 @@ +/** + * FileNode.tsx — coverage tests + * + * @vitest-environment happy-dom + * + * Strategy: use @testing-library/react (render) in a happy-dom environment + * so that all event handlers (handleClick, handleDoubleClick, handleKeyDown) + * can be exercised, giving us the required function coverage. + * + * Mocks declared before imports: + * - reactflow → Handle/Position no-ops + * - ./LanguageIcon → no-op component + * + * Branches covered: + * - isSelected → 4px solid #0078d4 + * - isExternal package → 2px dashed border + * - default → 2px solid border + * - isRoot → bold font + * - isInCycle → red badge "circular dependency" + * - hasChildren + expanded/collapsed → expand/collapse button labels + * - isRoot + hasReferencingFiles → parent toggle button + * - drill-down button always present + * - handleClick, handleDoubleClick, handleKeyDown event handlers + * - React.memo comparator (areEqual) — indirectly via render identity + * - getFileBorderColor — various extensions + * - isExternalPackage — various path shapes + */ + +// @vitest-environment happy-dom + +import { cleanup, fireEvent, render as rtlRender } from "@testing-library/react"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// --------------------------------------------------------------------------- +// Mocks — must be declared BEFORE the module import +// --------------------------------------------------------------------------- + +vi.mock("reactflow", () => ({ + Handle: () => null, + Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" }, +})); + +vi.mock( + "../../../../src/webview/components/reactflow/LanguageIcon", + () => ({ + LanguageIcon: () => null, + }), +); + +// --------------------------------------------------------------------------- +// Import the component AFTER mocks +// --------------------------------------------------------------------------- + +// eslint-disable-next-line import/order +import { FileNode } from "../../../../src/webview/components/reactflow/FileNode"; +import type { FileNodeData } from "../../../../src/webview/components/reactflow/FileNode"; + +afterEach(() => cleanup()); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const noop = vi.fn(); + +function makeData(overrides: Partial = {}): FileNodeData { + return { + label: "index.ts", + fullPath: "/workspace/src/index.ts", + isRoot: false, + isParent: false, + isInCycle: false, + hasChildren: false, + isExpanded: false, + hasReferencingFiles: false, + isParentsVisible: false, + onNodeClick: noop, + onDrillDown: noop, + onFindReferences: noop, + onToggle: noop, + onExpandRequest: noop, + ...overrides, + }; +} + +function renderNode(data: FileNodeData, id = "node-1") { + return rtlRender( + React.createElement(FileNode as React.ComponentType, { data, id }), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("FileNode", () => { + // ------------------------------------------------------------------ + // border style — isSelected + // ------------------------------------------------------------------ + describe("border style — isSelected", () => { + it("renders 4px solid #0078d4 when selectedNodeId matches nodeId", () => { + const { container } = renderNode( + makeData({ selectedNodeId: "node-1", nodeId: "node-1" }), + "node-1", + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).toContain("4px solid"); + // colour may be lowercased or normalized + expect(inner.style.border.toLowerCase()).toContain("0078d4"); + }); + + it("does NOT apply selected border when ids differ", () => { + const { container } = renderNode( + makeData({ selectedNodeId: "other", nodeId: "node-1" }), + "node-1", + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).not.toContain("0078d4"); + }); + + it("falls back to the id prop when nodeId is not provided", () => { + const data = makeData({ selectedNodeId: "my-node" }); + const { container } = renderNode(data, "my-node"); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border.toLowerCase()).toContain("0078d4"); + }); + }); + + // ------------------------------------------------------------------ + // border style — external package (dashed) + // ------------------------------------------------------------------ + describe("border style — external package", () => { + it("renders dashed border for a bare npm package name", () => { + const { container } = renderNode( + makeData({ label: "lodash", fullPath: "lodash" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).toContain("dashed"); + }); + + it("renders solid border for an absolute local path", () => { + const { container } = renderNode( + makeData({ label: "util.ts", fullPath: "/workspace/src/util.ts" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).toContain("solid"); + expect(inner.style.border).not.toContain("dashed"); + expect(inner.style.border).not.toContain("0078d4"); + }); + + it("renders solid for node_modules path (contains '/' AND node_modules)", () => { + const { container } = renderNode( + makeData({ label: "react", fullPath: "/workspace/node_modules/react/index.js" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).toContain("solid"); + }); + + it("renders solid for scoped package (@scope/pkg) — has '/' but no node_modules", () => { + // isExternalPackage: path includes '/' AND NOT node_modules → returns false (not external) + const { container } = renderNode( + makeData({ label: "@xyflow/react", fullPath: "@xyflow/react" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).toContain("solid"); + }); + + it("renders solid for a relative path starting with '.'", () => { + const { container } = renderNode( + makeData({ label: "utils", fullPath: "./utils" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).toContain("solid"); + }); + + it("renders dashed for Windows-style drive-letter path (external unknown extension)", () => { + // starts with drive letter → isExternalPackage returns false (local) + const { container } = renderNode( + makeData({ label: "config", fullPath: "config" }), + ); + const inner = container.querySelector("div") as HTMLElement; + // no extension match, no slash → external + expect(inner.style.border).toContain("dashed"); + }); + }); + + // ------------------------------------------------------------------ + // isRoot styling + // ------------------------------------------------------------------ + describe("isRoot styling", () => { + it("renders fontWeight bold when isRoot is true", () => { + const { container } = renderNode(makeData({ isRoot: true })); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.fontWeight).toBe("bold"); + }); + + it("renders fontWeight normal when isRoot is false", () => { + const { container } = renderNode( + makeData({ isRoot: false, label: "child.ts", fullPath: "/src/child.ts" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.fontWeight).toBe("normal"); + }); + }); + + // ------------------------------------------------------------------ + // isInCycle badge + // ------------------------------------------------------------------ + describe("isInCycle badge", () => { + it("renders the cycle indicator when isInCycle is true", () => { + const { container } = renderNode(makeData({ isInCycle: true })); + const badge = container.querySelector('[title="Part of circular dependency"]'); + expect(badge).not.toBeNull(); + }); + + it("does NOT render cycle indicator when isInCycle is false", () => { + const { container } = renderNode(makeData({ isInCycle: false })); + const badge = container.querySelector('[title="Part of circular dependency"]'); + expect(badge).toBeNull(); + }); + }); + + // ------------------------------------------------------------------ + // expand / collapse button + // ------------------------------------------------------------------ + describe("expand/collapse button", () => { + it("renders Expand node aria-label when hasChildren and not expanded", () => { + const { container } = renderNode( + makeData({ hasChildren: true, isExpanded: false }), + ); + const btn = container.querySelector('[aria-label="Expand node"]'); + expect(btn).not.toBeNull(); + }); + + it("renders Collapse node aria-label when hasChildren and expanded", () => { + const { container } = renderNode( + makeData({ hasChildren: true, isExpanded: true }), + ); + const btn = container.querySelector('[aria-label="Collapse node"]'); + expect(btn).not.toBeNull(); + }); + + it("does NOT render expand/collapse button when hasChildren is false", () => { + const { container } = renderNode(makeData({ hasChildren: false })); + const expandBtn = container.querySelector('[aria-label="Expand node"]'); + const collapseBtn = container.querySelector('[aria-label="Collapse node"]'); + expect(expandBtn).toBeNull(); + expect(collapseBtn).toBeNull(); + }); + }); + + // ------------------------------------------------------------------ + // parent toggle button + // ------------------------------------------------------------------ + describe("parent toggle button", () => { + it("renders Show referencing files button when isRoot + hasReferencingFiles", () => { + const { container } = renderNode( + makeData({ isRoot: true, hasReferencingFiles: true, isParentsVisible: false }), + ); + const btn = container.querySelector('[aria-label="Show referencing files"]'); + expect(btn).not.toBeNull(); + }); + + it("renders Hide referencing files button when isParentsVisible is true", () => { + const { container } = renderNode( + makeData({ isRoot: true, hasReferencingFiles: true, isParentsVisible: true }), + ); + const btn = container.querySelector('[aria-label="Hide referencing files"]'); + expect(btn).not.toBeNull(); + }); + + it("does NOT render parent button when not root", () => { + const { container } = renderNode( + makeData({ isRoot: false, hasReferencingFiles: true }), + ); + const btn = container.querySelector('[aria-label*="referencing files"]'); + expect(btn).toBeNull(); + }); + + it("does NOT render parent button when hasReferencingFiles is false", () => { + const { container } = renderNode( + makeData({ isRoot: true, hasReferencingFiles: false }), + ); + const btn = container.querySelector('[aria-label*="referencing files"]'); + expect(btn).toBeNull(); + }); + }); + + // ------------------------------------------------------------------ + // drill-down button (always present) + // ------------------------------------------------------------------ + describe("drill-down button", () => { + it("always renders the View symbols button", () => { + const { container } = renderNode(makeData()); + const btn = container.querySelector('[aria-label="View symbols"]'); + expect(btn).not.toBeNull(); + }); + }); + + // ------------------------------------------------------------------ + // title / tooltip + // ------------------------------------------------------------------ + describe("title / tooltip", () => { + it("includes fullPath as the wrapper button title", () => { + const { container } = renderNode( + makeData({ fullPath: "/workspace/src/utils.ts" }), + ); + const wrapper = container.querySelector("button[title]") as HTMLElement; + expect(wrapper.getAttribute("title")).toBe("/workspace/src/utils.ts"); + }); + }); + + // ------------------------------------------------------------------ + // label text + // ------------------------------------------------------------------ + describe("label rendering", () => { + it("renders the label string inside the component", () => { + const { container } = renderNode(makeData({ label: "MyComponent.tsx" })); + expect(container.textContent).toContain("MyComponent.tsx"); + }); + }); + + // ------------------------------------------------------------------ + // getFileBorderColor — extension-specific colour branches + // ------------------------------------------------------------------ + describe("getFileBorderColor — extension branches", () => { + it("TypeScript .ts file → solid border (no dashed)", () => { + const { container } = renderNode( + makeData({ label: "service.ts", fullPath: "/src/service.ts" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).toContain("solid"); + expect(inner.style.border).not.toContain("dashed"); + }); + + it("React .tsx file → solid border", () => { + const { container } = renderNode( + makeData({ label: "Button.tsx", fullPath: "/src/Button.tsx" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).toContain("solid"); + }); + + it("Unknown extension on absolute local path → solid border", () => { + const { container } = renderNode( + makeData({ label: "Makefile", fullPath: "/src/Makefile" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.border).toContain("solid"); + }); + }); + + // ------------------------------------------------------------------ + // fontStyle — italic for external packages, normal otherwise + // ------------------------------------------------------------------ + describe("fontStyle branch", () => { + it("renders fontStyle italic for an external package", () => { + const { container } = renderNode( + makeData({ label: "lodash", fullPath: "lodash" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.fontStyle).toBe("italic"); + }); + + it("renders fontStyle normal for a local file", () => { + const { container } = renderNode( + makeData({ label: "utils.ts", fullPath: "/src/utils.ts" }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.fontStyle).toBe("normal"); + }); + }); + + // ------------------------------------------------------------------ + // boxShadow — isSelected vs non-selected + // ------------------------------------------------------------------ + describe("boxShadow branch", () => { + it("renders boxShadow when isSelected", () => { + const { container } = renderNode( + makeData({ selectedNodeId: "node-1", nodeId: "node-1" }), + "node-1", + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.boxShadow).not.toBe("none"); + expect(inner.style.boxShadow.length).toBeGreaterThan(0); + }); + + it("renders boxShadow none when NOT selected", () => { + const { container } = renderNode( + makeData({ selectedNodeId: null, nodeId: "node-1" }), + "node-1", + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.boxShadow).toBe("none"); + }); + }); + + // ------------------------------------------------------------------ + // Event handlers — handleClick, handleDoubleClick, handleKeyDown + // ------------------------------------------------------------------ + describe("event handlers", () => { + it("calls onNodeClick when the wrapper button is clicked", () => { + const onNodeClick = vi.fn(); + const { container } = renderNode(makeData({ onNodeClick })); + const btn = container.querySelector("button[title]") as HTMLElement; + fireEvent.click(btn); + expect(onNodeClick).toHaveBeenCalledOnce(); + }); + + it("calls onDrillDown when the wrapper button is double-clicked", () => { + const onDrillDown = vi.fn(); + const { container } = renderNode(makeData({ onDrillDown })); + const btn = container.querySelector("button[title]") as HTMLElement; + fireEvent.dblClick(btn); + expect(onDrillDown).toHaveBeenCalledOnce(); + }); + + it("calls onNodeClick when Enter key is pressed on the wrapper button", () => { + const onNodeClick = vi.fn(); + const { container } = renderNode(makeData({ onNodeClick })); + const btn = container.querySelector("button[title]") as HTMLElement; + fireEvent.keyDown(btn, { key: "Enter" }); + expect(onNodeClick).toHaveBeenCalledOnce(); + }); + + it("does NOT call onNodeClick for non-Enter key presses", () => { + const onNodeClick = vi.fn(); + const { container } = renderNode(makeData({ onNodeClick })); + const btn = container.querySelector("button[title]") as HTMLElement; + fireEvent.keyDown(btn, { key: "Space" }); + expect(onNodeClick).not.toHaveBeenCalled(); + }); + + it("calls onToggle when collapse button clicked (isExpanded=true)", () => { + const onToggle = vi.fn(); + const { container } = renderNode( + makeData({ hasChildren: true, isExpanded: true, onToggle }), + ); + const collapseBtn = container.querySelector('[aria-label="Collapse node"]') as HTMLElement; + fireEvent.click(collapseBtn); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("calls onExpandRequest when expand button clicked (isExpanded=false)", () => { + const onExpandRequest = vi.fn(); + const { container } = renderNode( + makeData({ hasChildren: true, isExpanded: false, onExpandRequest }), + ); + const expandBtn = container.querySelector('[aria-label="Expand node"]') as HTMLElement; + fireEvent.click(expandBtn); + expect(onExpandRequest).toHaveBeenCalledOnce(); + }); + + it("calls onDrillDown when the drill-down (symbols) button is clicked", () => { + const onDrillDown = vi.fn(); + const { container } = renderNode(makeData({ onDrillDown })); + const symbolsBtn = container.querySelector('[aria-label="View symbols"]') as HTMLElement; + fireEvent.click(symbolsBtn); + expect(onDrillDown).toHaveBeenCalledOnce(); + }); + + it("calls onToggleParents when the parent toggle button is clicked", () => { + const onToggleParents = vi.fn(); + const { container } = renderNode( + makeData({ + isRoot: true, + hasReferencingFiles: true, + isParentsVisible: false, + onToggleParents, + }), + ); + const parentBtn = container.querySelector('[aria-label="Show referencing files"]') as HTMLElement; + fireEvent.click(parentBtn); + expect(onToggleParents).toHaveBeenCalledOnce(); + }); + }); + + // ------------------------------------------------------------------ + // React.memo comparator (areEqual) — invoke via rerender + // The comparator is only called by React during re-renders, so we use + // RTL's rerender() to trigger it. + // ------------------------------------------------------------------ + describe("community background color", () => { + it("applies non-transparent backgroundColor when communityId is 1", () => { + const { container } = renderNode(makeData({ communityId: 1, fullPath: '/src/index.ts' })); + const inner = container.querySelector('div') as HTMLElement; + // Should NOT be the default vscode editor background + expect(inner.style.background).not.toBe('var(--vscode-editor-background)'); + }); + + it("applies var(--vscode-editor-background) when communityId is 0", () => { + const { container } = renderNode(makeData({ communityId: 0, fullPath: '/src/index.ts' })); + const inner = container.querySelector('div') as HTMLElement; + expect(inner.style.background).toBe('var(--vscode-editor-background)'); + }); + + it("applies var(--vscode-editor-background) when communityId is undefined", () => { + const { container } = renderNode(makeData({ communityId: undefined, fullPath: '/src/index.ts' })); + const inner = container.querySelector('div') as HTMLElement; + expect(inner.style.background).toBe('var(--vscode-editor-background)'); + }); + }); + + describe("memo comparator — communityId", () => { + it("includes communityId in equality check", () => { + // Render with communityId=1, then re-render with communityId=2 + // The component should re-render (communityId changed) + const data1 = makeData({ communityId: 1, fullPath: '/src/index.ts' }); + const data2 = makeData({ communityId: 2, fullPath: '/src/index.ts' }); + const { container, rerender } = renderNode(data1); + const inner1 = container.querySelector('div') as HTMLElement; + const bg1 = inner1.style.background; + + rerender(React.createElement(FileNode as React.ComponentType, { data: data2, id: 'node-1' })); + const inner2 = container.querySelector('div') as HTMLElement; + const bg2 = inner2.style.background; + + expect(bg1).not.toBe(bg2); + }); + }); + + describe("React.memo comparator (areEqual)", () => { + it("does not re-render when all tracked data props are unchanged", () => { + const data = makeData({ label: "stable.ts" }); + const { container, rerender } = renderNode(data, "n1"); + const beforeHTML = container.innerHTML; + // Re-render with same props — areEqual returns true → no DOM change + rerender( + React.createElement(FileNode as React.ComponentType, { data, id: "n1" }), + ); + expect(container.innerHTML).toBe(beforeHTML); + }); + + it("re-renders when label changes (areEqual returns false)", () => { + const data1 = makeData({ label: "a.ts" }); + const { container, rerender } = renderNode(data1, "n1"); + const beforeHTML = container.innerHTML; + const data2 = makeData({ label: "b.ts" }); + rerender( + React.createElement(FileNode as React.ComponentType, { data: data2, id: "n1" }), + ); + expect(container.innerHTML).not.toBe(beforeHTML); + }); + + it("re-renders when isRoot changes", () => { + const data1 = makeData({ isRoot: false }); + const { container, rerender } = renderNode(data1, "n1"); + const beforeHTML = container.innerHTML; + const data2 = makeData({ isRoot: true }); + rerender( + React.createElement(FileNode as React.ComponentType, { data: data2, id: "n1" }), + ); + expect(container.innerHTML).not.toBe(beforeHTML); + }); + + it("re-renders when isInCycle changes", () => { + const data1 = makeData({ isInCycle: false }); + const { container, rerender } = renderNode(data1, "n1"); + const before = container.innerHTML; + const data2 = makeData({ isInCycle: true }); + rerender( + React.createElement(FileNode as React.ComponentType, { data: data2, id: "n1" }), + ); + expect(container.innerHTML).not.toBe(before); + }); + + it("re-renders when selectedNodeId changes", () => { + const data1 = makeData({ selectedNodeId: null, nodeId: "n1" }); + const { container, rerender } = renderNode(data1, "n1"); + const before = container.innerHTML; + const data2 = makeData({ selectedNodeId: "n1", nodeId: "n1" }); + rerender( + React.createElement(FileNode as React.ComponentType, { data: data2, id: "n1" }), + ); + expect(container.innerHTML).not.toBe(before); + }); + + it("re-renders when hasChildren changes", () => { + const data1 = makeData({ hasChildren: false }); + const { container, rerender } = renderNode(data1, "n1"); + const before = container.innerHTML; + const data2 = makeData({ hasChildren: true, isExpanded: false }); + rerender( + React.createElement(FileNode as React.ComponentType, { data: data2, id: "n1" }), + ); + expect(container.innerHTML).not.toBe(before); + }); + + it("re-renders when fullPath changes", () => { + const data1 = makeData({ fullPath: "/src/a.ts", label: "a.ts" }); + const { container, rerender } = renderNode(data1, "n1"); + const before = container.innerHTML; + const data2 = makeData({ fullPath: "/src/b.ts", label: "a.ts" }); + rerender( + React.createElement(FileNode as React.ComponentType, { data: data2, id: "n1" }), + ); + // fullPath appears in title attribute + expect(container.innerHTML).not.toBe(before); + }); + + it("re-renders when showCommunities changes", () => { + const data1 = makeData({ communityId: 3, showCommunities: true }); + const { container, rerender } = renderNode(data1, "n1"); + const before = container.innerHTML; + const data2 = makeData({ communityId: 3, showCommunities: false }); + rerender( + React.createElement(FileNode as React.ComponentType, { data: data2, id: "n1" }), + ); + expect(container.innerHTML).not.toBe(before); + }); + }); + + // ------------------------------------------------------------------ + // showCommunities tint + // ------------------------------------------------------------------ + describe("community tint — showCommunities", () => { + it("applies community tint when showCommunities is true and node has communityId", () => { + const { container } = renderNode( + makeData({ communityId: 1, showCommunities: true }), + ); + const inner = container.querySelector("div") as HTMLElement; + // community color is not transparent, so background should NOT be editor-background + expect(inner.style.background).not.toBe("var(--vscode-editor-background)"); + }); + + it("uses editor-background when showCommunities is false regardless of communityId", () => { + const { container } = renderNode( + makeData({ communityId: 1, showCommunities: false }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.background).toBe("var(--vscode-editor-background)"); + }); + + it("uses editor-background when showCommunities is undefined and communityId is undefined", () => { + const { container } = renderNode( + makeData({ communityId: undefined, showCommunities: undefined }), + ); + const inner = container.querySelector("div") as HTMLElement; + expect(inner.style.background).toBe("var(--vscode-editor-background)"); + }); + }); +}); diff --git a/tests/webview/components/reactflow/buildGraph.test.ts b/tests/webview/components/reactflow/buildGraph.test.ts index 73afdab3..cfaf86d4 100644 --- a/tests/webview/components/reactflow/buildGraph.test.ts +++ b/tests/webview/components/reactflow/buildGraph.test.ts @@ -795,4 +795,1016 @@ describe("buildReactFlowGraph", () => { expect(referenceEdges.length).toBeGreaterThan(0); }); }); + + // --------------------------------------------------------------------------- + // Additional coverage: internal helpers exercised via buildReactFlowGraph + // --------------------------------------------------------------------------- + + describe("unusedDependencyMode: hide — getEdgesForProcessing + createVisibleEdges", () => { + it("removes unused edges completely when mode is hide", () => { + const data: GraphData = { + nodes: ["a.ts", "b.ts", "c.ts"], + edges: [ + { source: "a.ts", target: "b.ts" }, + { source: "a.ts", target: "c.ts" }, + ], + nodeLabels: { "a.ts": "a.ts", "b.ts": "b.ts", "c.ts": "c.ts" }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "a.ts", + expandAll: true, + expandedNodes: new Set(["a.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + unusedEdges: ["a.ts->c.ts"], + unusedDependencyMode: "hide", + filterUnused: true, + }); + + // c.ts edge is completely removed + const cEdge = result.edges.find( + (e) => e.source === "a.ts" && e.target === "c.ts", + ); + expect(cEdge).toBeUndefined(); + + // b.ts edge is kept + const bEdge = result.edges.find( + (e) => e.source === "a.ts" && e.target === "b.ts", + ); + expect(bEdge).toBeDefined(); + }); + + it("keeps all edges when filterUnused is false regardless of mode", () => { + const data: GraphData = { + nodes: ["a.ts", "b.ts"], + edges: [{ source: "a.ts", target: "b.ts" }], + nodeLabels: { "a.ts": "a.ts", "b.ts": "b.ts" }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "a.ts", + expandAll: true, + expandedNodes: new Set(["a.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + unusedEdges: ["a.ts->b.ts"], + unusedDependencyMode: "hide", + filterUnused: false, + }); + + const edge = result.edges.find( + (e) => e.source === "a.ts" && e.target === "b.ts", + ); + expect(edge).toBeDefined(); + }); + + it("unusedDependencyMode: none keeps all edges without dim", () => { + const data: GraphData = { + nodes: ["a.ts", "b.ts"], + edges: [{ source: "a.ts", target: "b.ts" }], + nodeLabels: { "a.ts": "a.ts", "b.ts": "b.ts" }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "a.ts", + expandAll: true, + expandedNodes: new Set(["a.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + unusedEdges: ["a.ts->b.ts"], + unusedDependencyMode: "none", + filterUnused: true, + }); + + const edge = result.edges.find( + (e) => e.source === "a.ts" && e.target === "b.ts", + ); + expect(edge).toBeDefined(); + // No dim/opacity override + expect(edge?.style?.opacity).not.toBe(0.3); + }); + }); + + describe("layout variants — force and radial", () => { + it("builds graph with layout: force", () => { + const data = createBasicGraphData(); + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: true, + expandedNodes: new Set(["root.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + layout: "force", + }); + + expect(result.nodes.length).toBeGreaterThan(0); + expect(result.edgesTruncated).toBe(false); + }); + + it("builds graph with layout: radial", () => { + const data = createBasicGraphData(); + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: true, + expandedNodes: new Set(["root.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + layout: "radial", + }); + + expect(result.nodes.length).toBeGreaterThan(0); + }); + + it("builds graph with layout: hierarchical (default)", () => { + const data = createBasicGraphData(); + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: true, + expandedNodes: new Set(["root.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + layout: "hierarchical", + }); + + expect(result.nodes.length).toBeGreaterThan(0); + }); + }); + + describe("symbol mode — createNodeData symbol branch", () => { + it("builds symbol nodes from symbolData", () => { + const data: GraphData = { + nodes: ["file.ts:FunctionA", "file.ts:FunctionB"], + edges: [ + { source: "file.ts:FunctionA", target: "file.ts:FunctionB" }, + ], + nodeLabels: { + "file.ts:FunctionA": "FunctionA", + "file.ts:FunctionB": "FunctionB", + }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:FunctionA", + name: "FunctionA", + kind: "function", + category: "function" as const, + line: 10, + isExported: true, + }, + { + id: "file.ts:FunctionB", + name: "FunctionB", + kind: "function", + category: "function" as const, + line: 20, + isExported: false, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:FunctionA", + expandAll: true, + expandedNodes: new Set(["file.ts:FunctionA"]), + showParents: false, + callbacks: createMockCallbacks(), + mode: "symbol", + symbolData, + }); + + expect(result.nodes.length).toBeGreaterThan(0); + const nodeA = result.nodes.find((n) => + n.id.includes("FunctionA"), + ); + expect(nodeA).toBeDefined(); + expect(nodeA?.type).toBe("symbol"); + }); + + it("handles external symbols (not found in symbolData.symbols)", () => { + const data: GraphData = { + nodes: ["file.ts:LocalFn", "external-lib:ExternalFn"], + edges: [ + { source: "file.ts:LocalFn", target: "external-lib:ExternalFn" }, + ], + nodeLabels: { + "file.ts:LocalFn": "LocalFn", + "external-lib:ExternalFn": "ExternalFn", + }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:LocalFn", + name: "LocalFn", + kind: "function", + category: "function" as const, + line: 5, + isExported: true, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:LocalFn", + expandAll: true, + expandedNodes: new Set(["file.ts:LocalFn"]), + showParents: false, + callbacks: createMockCallbacks(), + mode: "symbol", + symbolData, + }); + + // ExternalFn is an external symbol — should still be a node + const externalNode = result.nodes.find((n) => + n.id.includes("ExternalFn"), + ); + expect(externalNode).toBeDefined(); + }); + + it("infers category: method for dotted external symbol labels", () => { + // label "service.doThing" → contains '.' → category inferred as 'method' + const data: GraphData = { + nodes: ["file.ts:LocalFn", "lib:service.doThing"], + edges: [ + { source: "file.ts:LocalFn", target: "lib:service.doThing" }, + ], + nodeLabels: { + "file.ts:LocalFn": "LocalFn", + "lib:service.doThing": "service.doThing", + }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:LocalFn", + name: "LocalFn", + kind: "function", + category: "function" as const, + line: 1, + isExported: false, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:LocalFn", + expandAll: true, + expandedNodes: new Set(["file.ts:LocalFn"]), + showParents: false, + callbacks: createMockCallbacks(), + mode: "symbol", + symbolData, + }); + + const methodNode = result.nodes.find((n) => + n.id.includes("service.doThing"), + ); + expect(methodNode).toBeDefined(); + }); + }); + + describe("highlightState — étape 4 highlight props", () => { + it("marks highlighted nodes when highlightState is provided", () => { + const data: GraphData = { + nodes: ["file.ts:FnA", "file.ts:FnB"], + edges: [{ source: "file.ts:FnA", target: "file.ts:FnB" }], + nodeLabels: { + "file.ts:FnA": "FnA", + "file.ts:FnB": "FnB", + }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:FnA", + name: "FnA", + kind: "function", + category: "function" as const, + line: 1, + isExported: true, + }, + { + id: "file.ts:FnB", + name: "FnB", + kind: "function", + category: "function" as const, + line: 5, + isExported: false, + }, + ], + dependencies: [], + }; + + const highlightState = { + highlightedNodes: new Set(["file.ts:FnA"]), + highlightedEdges: new Set(["file.ts:FnA->file.ts:FnB"]), + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:FnA", + expandAll: true, + expandedNodes: new Set(["file.ts:FnA"]), + showParents: false, + callbacks: createMockCallbacks(), + mode: "symbol", + symbolData, + highlightState, + }); + + const nodeA = result.nodes.find((n) => n.id === "file.ts:FnA"); + expect((nodeA?.data as any).isHighlighted).toBe(true); + expect((nodeA?.data as any).isHighlightActive).toBe(true); + + const nodeB = result.nodes.find((n) => n.id === "file.ts:FnB"); + expect((nodeB?.data as any).isHighlighted).toBe(false); + expect((nodeB?.data as any).isHighlightActive).toBe(true); + }); + + it("sets isHighlightActive to false when highlightState is null", () => { + const data: GraphData = { + nodes: ["file.ts:FnA"], + edges: [], + nodeLabels: { "file.ts:FnA": "FnA" }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:FnA", + name: "FnA", + kind: "function", + category: "function" as const, + line: 1, + isExported: true, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:FnA", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks: createMockCallbacks(), + mode: "symbol", + symbolData, + highlightState: null, + }); + + const nodeA = result.nodes.find((n) => n.id === "file.ts:FnA"); + expect((nodeA?.data as any).isHighlightActive).toBe(false); + }); + }); + + describe("addParentNodes — parent truncation", () => { + it("marks nodesTruncated when parents exceed MAX_RENDER_NODES", () => { + // Build a graph where root has more parents than the limit + const parentCount = GRAPH_LIMITS.MAX_RENDER_NODES + 10; + const parents = Array.from( + { length: parentCount }, + (_, i) => `parent${i}.ts`, + ); + const data: GraphData = { + nodes: ["root.ts", ...parents], + edges: parents.map((p) => ({ source: p, target: "root.ts" })), + nodeLabels: { + "root.ts": "root.ts", + ...Object.fromEntries(parents.map((p) => [p, p])), + }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: false, + expandedNodes: new Set(), + showParents: true, + callbacks: createMockCallbacks(), + }); + + expect(result.nodesTruncated).toBe(true); + }); + }); + + describe("buildRelationshipMaps — edge processing", () => { + it("handles graph where a node has both incoming and outgoing edges", () => { + const data: GraphData = { + nodes: ["a.ts", "b.ts", "c.ts"], + edges: [ + { source: "a.ts", target: "b.ts" }, + { source: "b.ts", target: "c.ts" }, + ], + nodeLabels: { "a.ts": "a.ts", "b.ts": "b.ts", "c.ts": "c.ts" }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "b.ts", + expandAll: true, + expandedNodes: new Set(["a.ts", "b.ts"]), + showParents: true, + callbacks: createMockCallbacks(), + }); + + // b.ts is root, should have both a.ts (parent) and c.ts (child) visible + expect(result.nodes.some((n) => n.id === "a.ts")).toBe(true); + expect(result.nodes.some((n) => n.id === "c.ts")).toBe(true); + }); + }); + + describe("MAX_CYCLE_DETECT_EDGES threshold", () => { + it("skips cycle detection when edge count exceeds MAX_CYCLE_DETECT_EDGES", () => { + // Build a graph that exceeds the cycle detection threshold + const edgeCount = GRAPH_LIMITS.MAX_CYCLE_DETECT_EDGES + 1; + const nodes = Array.from({ length: edgeCount + 1 }, (_, i) => `n${i}.ts`); + const edges = Array.from({ length: edgeCount }, (_, i) => ({ + source: `n${i}.ts`, + target: `n${i + 1}.ts`, + })); + + const data: GraphData = { + nodes, + edges, + nodeLabels: Object.fromEntries(nodes.map((n) => [n, n])), + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "n0.ts", + expandAll: true, + expandedNodes: new Set(nodes), + showParents: false, + callbacks: createMockCallbacks(), + }); + + // cycles should be empty when edge count exceeds threshold + expect(result.cycles.size).toBe(0); + }); + }); + + describe("nodeLabels fallback — path.pop()", () => { + it("falls back to the last path segment when nodeLabels is undefined", () => { + const data: GraphData = { + nodes: ["src/utils/helper.ts"], + edges: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "src/utils/helper.ts", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks: createMockCallbacks(), + }); + + const node = result.nodes[0]; + expect(node?.data.label).toBe("helper.ts"); + }); + }); + + describe("communityId propagation", () => { + it("passes communityId from nodeMetadata to FileNodeData", () => { + const data = createBasicGraphData(); + data.nodeMetadata = { "root.ts": { hubScore: 0.5, communityId: 3 } }; + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks: createMockCallbacks(), + }); + const rootNode = result.nodes.find((n) => n.id === "root.ts"); + expect((rootNode?.data as any).communityId).toBe(3); + }); + + it("passes undefined communityId when not in nodeMetadata", () => { + const data = createBasicGraphData(); + data.nodeMetadata = { "root.ts": { hubScore: 0.5 } }; + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks: createMockCallbacks(), + }); + const rootNode = result.nodes.find((n) => n.id === "root.ts"); + expect((rootNode?.data as any).communityId).toBeUndefined(); + }); + + it("passes undefined communityId when nodeMetadata absent", () => { + const data = createBasicGraphData(); + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks: createMockCallbacks(), + }); + const rootNode = result.nodes.find((n) => n.id === "root.ts"); + expect((rootNode?.data as any).communityId).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // Callback invocation — covers the inline arrow functions in createNodeData + // These are created but never called unless we explicitly invoke them. + // --------------------------------------------------------------------------- + + describe("file mode node callbacks", () => { + it("onFindReferences callback calls the underlying callbacks.onFindReferences", () => { + const callbacks = createMockCallbacks(); + const data = createBasicGraphData(); + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: true, + expandedNodes: new Set(["root.ts"]), + showParents: false, + callbacks, + }); + + const rootNode = result.nodes.find((n) => n.id === "root.ts"); + (rootNode?.data as any).onFindReferences?.(); + expect(callbacks.onFindReferences).toHaveBeenCalledWith("root.ts"); + }); + + it("onToggleParents callback calls the underlying callbacks.onToggleParents", () => { + const callbacks = createMockCallbacks(); + const data = createBasicGraphData(); + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks, + }); + + const rootNode = result.nodes.find((n) => n.id === "root.ts"); + (rootNode?.data as any).onToggleParents?.(); + expect(callbacks.onToggleParents).toHaveBeenCalledWith("root.ts"); + }); + + it("onNodeClick (file mode) callback calls callbacks.onNodeClick", () => { + const callbacks = createMockCallbacks(); + const data = createBasicGraphData(); + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks, + }); + + const rootNode = result.nodes.find((n) => n.id === "root.ts"); + (rootNode?.data as any).onNodeClick?.(); + expect(callbacks.onNodeClick).toHaveBeenCalledWith("root.ts"); + }); + + it("onDrillDown (file mode) callback calls callbacks.onDrillDown", () => { + const callbacks = createMockCallbacks(); + const data = createBasicGraphData(); + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks, + }); + + const rootNode = result.nodes.find((n) => n.id === "root.ts"); + (rootNode?.data as any).onDrillDown?.(); + expect(callbacks.onDrillDown).toHaveBeenCalledWith("root.ts"); + }); + }); + + describe("symbol mode external node callbacks", () => { + it("onNodeClick on external symbol calls callbacks.onNodeClick with filePath", () => { + const callbacks = createMockCallbacks(); + const data: GraphData = { + nodes: ["file.ts:LocalFn", "external-lib:ExternalFn"], + edges: [{ source: "file.ts:LocalFn", target: "external-lib:ExternalFn" }], + nodeLabels: { + "file.ts:LocalFn": "LocalFn", + "external-lib:ExternalFn": "ExternalFn", + }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:LocalFn", + name: "LocalFn", + kind: "function", + category: "function" as const, + line: 1, + isExported: true, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:LocalFn", + expandAll: true, + expandedNodes: new Set(["file.ts:LocalFn"]), + showParents: false, + callbacks, + mode: "symbol", + symbolData, + }); + + const externalNode = result.nodes.find((n) => n.id.includes("ExternalFn")); + (externalNode?.data as any).onNodeClick?.(); + expect(callbacks.onNodeClick).toHaveBeenCalledWith("external-lib", 0); + }); + + it("onDrillDown on external symbol calls callbacks.onDrillDown with path", () => { + const callbacks = createMockCallbacks(); + const data: GraphData = { + nodes: ["file.ts:LocalFn", "external-lib:ExternalFn"], + edges: [{ source: "file.ts:LocalFn", target: "external-lib:ExternalFn" }], + nodeLabels: { + "file.ts:LocalFn": "LocalFn", + "external-lib:ExternalFn": "ExternalFn", + }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:LocalFn", + name: "LocalFn", + kind: "function", + category: "function" as const, + line: 1, + isExported: true, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:LocalFn", + expandAll: true, + expandedNodes: new Set(["file.ts:LocalFn"]), + showParents: false, + callbacks, + mode: "symbol", + symbolData, + }); + + const externalNode = result.nodes.find((n) => n.id.includes("ExternalFn")); + (externalNode?.data as any).onDrillDown?.(); + expect(callbacks.onDrillDown).toHaveBeenCalledWith("external-lib:ExternalFn"); + }); + + it("onToggle on external symbol calls callbacks.onToggle with path", () => { + const callbacks = createMockCallbacks(); + const data: GraphData = { + nodes: ["file.ts:LocalFn", "external-lib:ExternalFn"], + edges: [{ source: "file.ts:LocalFn", target: "external-lib:ExternalFn" }], + nodeLabels: { + "file.ts:LocalFn": "LocalFn", + "external-lib:ExternalFn": "ExternalFn", + }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:LocalFn", + name: "LocalFn", + kind: "function", + category: "function" as const, + line: 1, + isExported: true, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:LocalFn", + expandAll: true, + expandedNodes: new Set(["file.ts:LocalFn"]), + showParents: false, + callbacks, + mode: "symbol", + symbolData, + }); + + const externalNode = result.nodes.find((n) => n.id.includes("ExternalFn")); + (externalNode?.data as any).onToggle?.(); + expect(callbacks.onToggle).toHaveBeenCalledWith("external-lib:ExternalFn"); + }); + + it("infers class category for uppercase external symbol", () => { + const data: GraphData = { + nodes: ["file.ts:LocalFn", "lib:MyClass"], + edges: [{ source: "file.ts:LocalFn", target: "lib:MyClass" }], + nodeLabels: { + "file.ts:LocalFn": "LocalFn", + "lib:MyClass": "MyClass", + }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:LocalFn", + name: "LocalFn", + kind: "function", + category: "function" as const, + line: 1, + isExported: false, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:LocalFn", + expandAll: true, + expandedNodes: new Set(["file.ts:LocalFn"]), + showParents: false, + callbacks: createMockCallbacks(), + mode: "symbol", + symbolData, + }); + + const classNode = result.nodes.find((n) => n.id.includes("MyClass")); + expect(classNode).toBeDefined(); + // category should be 'class' (label starts with uppercase) + expect((classNode?.data as any).category).toBe("class"); + }); + }); + + describe("symbol mode known node callbacks", () => { + it("onNodeClick on known symbol calls callbacks.onNodeClick with symbol.id and line", () => { + const callbacks = createMockCallbacks(); + const data: GraphData = { + nodes: ["file.ts:FnA"], + edges: [], + nodeLabels: { "file.ts:FnA": "FnA" }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:FnA", + name: "FnA", + kind: "function", + category: "function" as const, + line: 42, + isExported: true, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:FnA", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks, + mode: "symbol", + symbolData, + }); + + const node = result.nodes.find((n) => n.id === "file.ts:FnA"); + (node?.data as any).onNodeClick?.(); + expect(callbacks.onNodeClick).toHaveBeenCalledWith("file.ts:FnA", 42); + }); + + it("onDrillDown on known symbol calls callbacks.onDrillDown", () => { + const callbacks = createMockCallbacks(); + const data: GraphData = { + nodes: ["file.ts:FnA"], + edges: [], + nodeLabels: { "file.ts:FnA": "FnA" }, + }; + + const symbolData = { + symbols: [ + { + id: "file.ts:FnA", + name: "FnA", + kind: "function", + category: "function" as const, + line: 5, + isExported: false, + }, + ], + dependencies: [], + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "file.ts:FnA", + expandAll: false, + expandedNodes: new Set(), + showParents: false, + callbacks, + mode: "symbol", + symbolData, + }); + + const node = result.nodes.find((n) => n.id === "file.ts:FnA"); + (node?.data as any).onDrillDown?.(); + expect(callbacks.onDrillDown).toHaveBeenCalledWith("file.ts:FnA"); + }); + }); + + describe("F2: hubScore from nodeMetadata (ADR-F2-01)", () => { + // AC3: nodeMetadata[path].hubScore = 0.42 → FileNodeData.hubScore = 42 + it("AC3: reads hubScore from nodeMetadata and converts [0-1] to [0-100]", () => { + const data: GraphData = { + nodes: ["root.ts", "hub.ts"], + edges: [{ source: "root.ts", target: "hub.ts" }], + nodeLabels: { "root.ts": "root.ts", "hub.ts": "hub.ts" }, + nodeMetadata: { + "root.ts": { hubScore: 1.0 }, + "hub.ts": { hubScore: 0.42 }, + }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: true, + expandedNodes: new Set(["root.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + }); + + const hubNode = result.nodes.find((n) => n.id === "hub.ts"); + expect((hubNode?.data as any).hubScore).toBe(42); + const rootNode = result.nodes.find((n) => n.id === "root.ts"); + expect((rootNode?.data as any).hubScore).toBe(100); + }); + + // AC3 rounding: Math.round(0.425 * 100) = 43 + it("AC3: rounds hubScore correctly (0.425 → 43, 0.005 → 1)", () => { + const data: GraphData = { + nodes: ["root.ts", "a.ts", "b.ts"], + edges: [ + { source: "root.ts", target: "a.ts" }, + { source: "root.ts", target: "b.ts" }, + ], + nodeMetadata: { + "root.ts": { hubScore: 1.0 }, + "a.ts": { hubScore: 0.425 }, + "b.ts": { hubScore: 0.005 }, + }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: true, + expandedNodes: new Set(["root.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + }); + + const aNode = result.nodes.find((n) => n.id === "a.ts"); + expect((aNode?.data as any).hubScore).toBe(43); + const bNode = result.nodes.find((n) => n.id === "b.ts"); + expect((bNode?.data as any).hubScore).toBe(1); + }); + + // AC4: without nodeMetadata → fallback computeHubScores → same result as F1 + it("AC4: falls back to computeHubScores when nodeMetadata is absent", () => { + const parentCounts = { "root.ts": 0, "hub.ts": 5 }; + const data: GraphData = { + nodes: ["root.ts", "hub.ts"], + edges: [{ source: "root.ts", target: "hub.ts" }], + parentCounts, + // no nodeMetadata + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: true, + expandedNodes: new Set(["root.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + }); + + // hub.ts has 5 parents, root.ts has 0 → hub.ts should get score 100, root.ts 0 + const hubNode = result.nodes.find((n) => n.id === "hub.ts"); + expect((hubNode?.data as any).hubScore).toBe(100); + const rootNode = result.nodes.find((n) => n.id === "root.ts"); + expect((rootNode?.data as any).hubScore).toBe(0); + }); + + // AC4: nodeMetadata present but hubScore undefined on a node → falls back to 0 + it("AC4: nodeMetadata present but entry missing hubScore → returns 0", () => { + const data: GraphData = { + nodes: ["root.ts", "other.ts"], + edges: [{ source: "root.ts", target: "other.ts" }], + nodeMetadata: { + "root.ts": { hubScore: 0.8 }, + // other.ts has no hubScore defined + "other.ts": {}, + }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: true, + expandedNodes: new Set(["root.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + }); + + const otherNode = result.nodes.find((n) => n.id === "other.ts"); + expect((otherNode?.data as any).hubScore).toBe(0); + }); + + // AC7: lookup uses normalizePath — node not found in nodeMetadata due to no key → returns 0 + it("AC7: nodeMetadata lookup uses normalizePath — absent key returns 0 not undefined", () => { + const data: GraphData = { + nodes: ["src/root.ts", "src/hub.ts"], + edges: [{ source: "src/root.ts", target: "src/hub.ts" }], + nodeMetadata: { + "src/root.ts": { hubScore: 1.0 }, + "src/hub.ts": { hubScore: 0.77 }, + }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "src/root.ts", + expandAll: true, + expandedNodes: new Set(["src/root.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + }); + + // hub.ts score must be correctly read + const hubNode = result.nodes.find((n) => n.id === "src/hub.ts"); + expect((hubNode?.data as any).hubScore).toBe(77); + }); + + // AC7: normalizePath applied — node absent from nodeMetadata returns 0 (not undefined) + it("AC7: nodeMetadata absent for a node → hubScore is 0, not undefined", () => { + const data: GraphData = { + nodes: ["root.ts", "orphan.ts"], + edges: [{ source: "root.ts", target: "orphan.ts" }], + nodeMetadata: { + "root.ts": { hubScore: 0.5 }, + // orphan.ts not in nodeMetadata at all + }, + }; + + const result = buildReactFlowGraph({ + data, + currentFilePath: "root.ts", + expandAll: true, + expandedNodes: new Set(["root.ts"]), + showParents: false, + callbacks: createMockCallbacks(), + }); + + const orphanNode = result.nodes.find((n) => n.id === "orphan.ts"); + expect((orphanNode?.data as any).hubScore).toBe(0); + }); + }); }); diff --git a/tests/webview/utils/communityColor.test.ts b/tests/webview/utils/communityColor.test.ts new file mode 100644 index 00000000..34ac45f0 --- /dev/null +++ b/tests/webview/utils/communityColor.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { COMMUNITY_PALETTE } from '../../../src/shared/communityPalette'; +import { communityColor } from '../../../src/webview/utils/communityColor'; + +describe('communityColor', () => { + it('returns transparent for undefined', () => { + expect(communityColor(undefined)).toBe('transparent'); + }); + + it('returns transparent for communityId 0 (isolated)', () => { + expect(communityColor(0)).toBe('transparent'); + }); + + it('returns COMMUNITY_PALETTE[0] for communityId 1', () => { + expect(communityColor(1)).toBe(COMMUNITY_PALETTE[0]); + }); + + it('returns COMMUNITY_PALETTE[11] for communityId 12', () => { + expect(communityColor(12)).toBe(COMMUNITY_PALETTE[11]); + }); + + it('cycles back to COMMUNITY_PALETTE[0] for communityId 13', () => { + expect(communityColor(13)).toBe(COMMUNITY_PALETTE[0]); + }); + + it('cycles correctly for communityId 25 (2 full cycles + 1)', () => { + expect(communityColor(25)).toBe(COMMUNITY_PALETTE[0]); + }); +});