diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index d345c8d1..adbae7d2 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -3,7 +3,7 @@ name: Bug report
about: Create a report for a bug, regression, or unexpected behavior
title: "[BUG] "
labels: bug
-assignees: ''
+assignees: ""
---
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index b66f0348..b1005a7d 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -3,7 +3,7 @@ name: Feature request
about: Suggest an idea for this project
title: "[FEATURE] "
labels: enhancement
-assignees: ''
+assignees: ""
---
diff --git a/.github/workflows/lint_test_compile.yml b/.github/workflows/lint_test_compile.yml
index 3db7e5e8..879214e0 100644
--- a/.github/workflows/lint_test_compile.yml
+++ b/.github/workflows/lint_test_compile.yml
@@ -32,7 +32,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- - name: Set up Node.js
+ - name: Set up Deno
uses: denoland/setup-deno@v2
with:
deno-version: "2.4.0"
@@ -50,7 +50,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- - name: Set up Node.js
+ - name: Set up Deno
uses: denoland/setup-deno@v2
with:
deno-version: "2.4.0"
diff --git a/README.md b/README.md
index e7e647d4..a69f13c2 100644
--- a/README.md
+++ b/README.md
@@ -1,154 +1,281 @@

-- [Documentation](https://docs.nanoapi.io)
-
# napi - Better Software Architecture for the AI Age
-`napi` is a versatile tool built by NanoAPI and designed to automatically
-provide insights into the architectural complexity of your software, while
-allowing for the novel extraction of functionality from codebases into smaller
-units. With both a powerful CLI and an intuitive UI, `napi` is compatible with
-all major CI/CD platforms, allowing seamless integration into your development
-and deployment pipelines.
-
-Historically, tools like this have only been built by large consulting firms or
-contractors and kept behind the paywalls of consulting fees. `napi` aims to make
-these tools accessible to developers of all skill levels, without the cost. Our
-vision is to help you gain deeper insights into system architecture-level
-concerns before they become hundred-million-dollar problems. The added benefit?
-No more black-box tools running on your code and the confidence of a 100%
-determinstic tool.
+`napi` is a fully offline CLI that analyzes your codebase's architecture --
+dependencies, complexity, and structure -- then lets you visualize and refactor
+it, all without sending your code anywhere.
+
+It generates dependency manifests from your source code, stores them locally,
+and serves an interactive graph visualizer directly from the CLI.

## Features
-- **🚨 Audit**: Pinpoint areas of your code that need refactoring or cleanup.
-- **📝 Refactor**: Extract functionality using the UI to improve architecture.
-- **🏗️ Build**: Generate modular microservices ready for deployment.
-- **⚙️ Integrate**: Use CLI commands compatible with all CI/CD workflows for
- automation.
-- **🔍 Architecture**: Get a live view of all your software and their
- interactions; scoped to a specific moment in time.
-
-
-
-## Why `napi`?
-
-- **Application Library**: `napi` is not just a CLI tool; it is a comprehensive
- application library of all projects and their interactions within your
- organization.
-- **Enables discovery into legacy systems**: indentify problematic code and
- potential improvements early.
-- **Modular Monoliths**: Simplifies the process of extracting functionality
- using non-AI strangler refactoring.
-- **Risk assessment**: Improve understanding, maintainability, and robustness at
- both the architecture and code level.
-- **Refactoring ROI**: Reduces dependency on outside sources for complex
- refactoring tasks.
-- **From black box to open-book**: Gain a deeper trust of what your system is
- doing today - even in the face of AI-generated code.
+- **🔍 Dependency Analysis**: Map every file, symbol, and dependency in your
+ codebase automatically.
+- **🚨 Audit**: Detect files and symbols that exceed complexity, size, or
+ coupling thresholds.
+- **📊 Interactive Visualizer**: Explore your architecture through Cytoscape.js
+ graphs served locally in your browser.
+- **📝 Symbol Extraction**: Extract specific functions, classes, or symbols into
+ standalone files for refactoring.
+- **🏷️ AI Labeling** (optional): Use OpenAI, Google, or Anthropic models to
+ auto-label dependencies.
+- **⚙️ CI/CD Ready**: Integrates into any pipeline -- generate manifests on
+ every push and track architecture over time.
+- **🔒 Fully Offline**: No accounts, no servers, no data leaves your machine.
## Supported Languages
-`napi` aims to support all major programming languages. Here is the current
-status:
+| Language | Status |
+| -------- | -------------- |
+| Python | ✅ Supported |
+| C# | ✅ Supported |
+| C | ✅ Supported |
+| Java | ✅ Supported |
+| C++ | 🚧 In Progress |
+| PHP | 🚧 In Progress |
+| JS/TS | 🚧 In Progress |
-| Language/Framework | Status |
-| ------------------ | -------------- |
-| Python | ✅ Supported |
-| C# | ✅ Supported |
-| C | ✅ Supported |
-| Java | ✅ Supported |
-| C++ | 🚧 In Progress |
-| PHP | 🚧 In Progress |
-| JavaScript | 🚧 In Progress |
-| TypeScript | 🚧 In Progress |
+## Installation
-For the latest updates, visit our [project board](/projects).
+### Unix (macOS, Linux)
-## Installation
+```bash
+curl -fsSL https://raw.githubusercontent.com/nanoapi-io/napi/refs/heads/main/install_scripts/install.sh | bash
+```
-`napi` works out of the box on both mac, linux, and windows systems.
+Or download a binary directly from
+[GitHub Releases](https://github.com/nanoapi-io/napi/releases/latest).
-To install `napi`, you can use our installation script:
+### Windows
-### Unix Systems (MacOS, Linux)
+Use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) to run napi.
+Native Windows support is in progress.
-Wyou to install napi using our convenience script:
+## Quick Start
```bash
-curl -fsSL https://raw.githubusercontent.com/nanoapi-io/napi/refs/heads/main/install_scripts/install.sh | bash
+# 1. Initialize your project (creates .napirc)
+napi init
+
+# 2. Generate a dependency manifest
+napi generate
+
+# 3. Open the visualizer in your browser
+napi view
```
-You can also download and install the latest release manually directly from our
-GitHub repository:
+That's it. Your manifest is saved locally in `.napi/manifests/` and the
+visualizer opens at `http://localhost:3000`.
-https://github.com/nanoapi-io/napi/releases/latest
+## CLI Commands
-### Windows
+### `napi init`
+
+Interactive setup that creates a `.napirc` configuration file in your project
+root.
-You can run napi using Windows Subsystem for Linux (WSL)
-https://learn.microsoft.com/en-us/windows/wsl/install
+Prompts you for:
-We are actively working on supporting windows natievely.
+- **Language** -- Python, C#, C, or Java
+- **Include/exclude patterns** -- which files to analyze
+- **Output directory** -- where extracted symbols are written
+- **AI labeling** (optional) -- provider and concurrency settings
-### Troubleshooting
+```bash
+napi init
+```
+
+### `napi generate`
+
+Analyzes your codebase and generates a dependency manifest. The manifest
+captures every file, symbol, dependency, and metric (lines, complexity,
+coupling).
+
+Manifests are saved as JSON files in `.napi/manifests/` with the naming pattern
+`{timestamp}-{commitSha}.json`.
+
+```bash
+# Interactive (prompts for branch/commit if not in git)
+napi generate
-If you encounter any issues during installation, please refer to our
-[Troubleshooting Guide](https://docs.nanoapi.io/default-guide/troubleshooting)
+# Non-interactive (for CI)
+napi generate --branch main --commit-sha abc1234 --commit-sha-date 2026-01-01T00:00:00Z
+```
+
+Options:
-## CLI Usage
+- `--branch` -- Git branch name (auto-detected if omitted)
+- `--commit-sha` -- Git commit hash (auto-detected if omitted)
+- `--commit-sha-date` -- Commit date in ISO 8601 format (auto-detected if
+ omitted)
+- `--labelingApiKey` -- API key for AI labeling (overrides global config)
-`napi` provides a streamlined Command-Line Interface (CLI) to interact with and
-refactor your software projects quickly and efficiently.
+### `napi view`
-For a full list of commands, run:
+Starts a local web server and opens an interactive dependency visualizer in your
+browser.
```bash
-napi --help
+napi view
+napi view --port 8080
```
-## Overview of all commands
+The viewer provides:
-### `napi login`
+- **Manifest list** -- browse all locally stored manifests by branch, commit,
+ and date
+- **Project graph** -- file-level dependency map with Cytoscape.js
+- **File graph** -- symbol-level view within a file (functions, classes,
+ variables)
+- **Symbol graph** -- transitive dependency chain for a specific symbol
+- **File explorer sidebar** -- navigate your codebase structure
+- **Audit alerts** -- visual indicators for files/symbols exceeding thresholds
-Authenticate with the NanoAPI service. This step is required to access the
-NanoAPI UI and to use certain features of `napi`.
+### `napi extract`
-### `napi init`
+Extracts specific symbols from your codebase into separate files using a local
+manifest.
-Initialize the project. This step is required before running any other command.
+```bash
+# Extract a function from a specific file
+napi extract --symbol "src/auth/login.py|authenticate"
-This will create a .napirc configuration file in the project root, storing paths
-and settings necessary for further commands.
+# Extract multiple symbols
+napi extract --symbol "src/models.py|User" --symbol "src/models.py|Session"
-### `napi manifest generate`
+# Use a specific manifest (defaults to latest)
+napi extract --symbol "src/main.py|run" --manifestId 1712500000000-a1b2c3d
+```
-Generate a manifest of your codebase that captures its structure, dependencies,
-and relationships and pushes it to your NanoAPI workspace in the app.
+Output is written to `{outDir}/extracted-{timestamp}/`.
-### `napi extract`
+### `napi set apiKey`
-Extract specific symbols (functions, classes, etc.) from your codebase into
-separate files. Use the format `--symbol file|symbol` where file is the path
-relative to your project root and symbol is the name to extract. The UI can
-generate these commands for convenient copy-pasting when browsing your code.
+Configure API keys for AI-powered dependency labeling. Keys are stored in the
+global config (not in your project).
-> **Important**: Run `napi manifest generate` whenever you make significant
-> changes to your codebase to ensure your manifest stays up-to-date. The
-> manifest data can be integrated into CI/CD workflows to track architectural
-> changes over time.
+```bash
+napi set apiKey
+```
+
+Prompts for:
+
+- **Provider** -- Google, OpenAI, or Anthropic
+- **API key** -- your provider API key
+
+## Local Manifest Storage
+
+All manifests are stored in `.napi/manifests/` relative to your project root.
+Each manifest is a self-contained JSON file:
+
+```json
+{
+ "id": "1712500000000-a1b2c3d",
+ "branch": "main",
+ "commitSha": "a1b2c3d4e5f6...",
+ "commitShaDate": "2026-04-07T10:00:00Z",
+ "createdAt": "2026-04-07T10:01:00Z",
+ "manifest": {}
+}
+```
+
+Add `.napi/` to your `.gitignore` or commit it to track architecture history in
+version control -- your choice.
## CI/CD Integration
-`napi` works seamlessly with CI/CD platforms like GitHub Actions, GitLab CI/CD,
-and Jenkins. This allows us to build the code manifest needed for visualization
-and refactoring in the background, without needing to wait for it to run locally
-in the case of very large codebases (>1M lines of code).
+Generate manifests automatically on every push:
+
+```yaml
+# .github/workflows/napi.yml
+name: Generate Manifest
+on: [push]
+jobs:
+ manifest:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install napi
+ run: curl -fsSL https://raw.githubusercontent.com/nanoapi-io/napi/refs/heads/main/install_scripts/install.sh | bash
+
+ - name: Generate manifest
+ run: napi generate --branch ${{ github.ref_name }} --commit-sha ${{ github.sha }} --commit-sha-date "$(git log -1 --format=%cI)"
+```
+
+## Configuration Reference
+
+### `.napirc`
+
+Project-level configuration created by `napi init`:
+
+```json
+{
+ "language": "python",
+ "python": { "version": "3.10" },
+ "project": {
+ "include": ["src/**/*.py"],
+ "exclude": [".git/**", "**/__pycache__/**", "napi_out/**"]
+ },
+ "outDir": "napi_out",
+ "labeling": {
+ "modelProvider": "openai",
+ "maxConcurrency": 5
+ }
+}
+```
+
+### Global Config
+
+Stored in your OS config directory (`~/.config/napi/config.json` on Linux,
+`~/Library/Application Support/napi/config.json` on macOS). Managed via
+`napi set apiKey`.
+
+```json
+{
+ "labeling": {
+ "apiKeys": {
+ "openai": "sk-...",
+ "google": "AIza...",
+ "anthropic": "sk-ant-..."
+ }
+ }
+}
+```
+
+## Development
+
+Requires [Deno](https://deno.land/) v2.4+.
+
+```bash
+# Install dependencies
+deno install --allow-scripts
-More information
+# Run CLI in dev mode
+deno task dev
+
+# Run viewer dev server (hot-reload)
+deno task dev:viewer
+
+# Build viewer for production
+deno task build:viewer
+
+# Compile binary (includes viewer)
+deno task compile
+
+# Run tests
+deno task test
+
+# Lint
+deno lint
+
+# Format
+deno fmt
+```
## Contributing
diff --git a/deno.json b/deno.json
index 674249e3..46c2a5c9 100644
--- a/deno.json
+++ b/deno.json
@@ -5,6 +5,7 @@
"nodeModulesDir": "auto",
"lock": false,
"imports": {
+ "@deno/vite-plugin": "npm:@deno/vite-plugin@^1.0.4",
"@inquirer/prompts": "npm:@inquirer/prompts@^7.5.3",
"@langchain/anthropic": "npm:@langchain/anthropic@^0.3.23",
"@langchain/core": "npm:@langchain/core@^0.3.61",
@@ -13,26 +14,59 @@
"@langchain/langgraph": "npm:@langchain/langgraph@^0.3.5",
"@langchain/openai": "npm:@langchain/openai@^0.5.15",
"@oak/oak": "jsr:@oak/oak@^17.1.4",
+ "@radix-ui/react-dialog": "npm:@radix-ui/react-dialog@^1.1.14",
+ "@radix-ui/react-dropdown-menu": "npm:@radix-ui/react-dropdown-menu@^2.1.15",
+ "@radix-ui/react-label": "npm:@radix-ui/react-label@^2.1.7",
+ "@radix-ui/react-scroll-area": "npm:@radix-ui/react-scroll-area@^1.2.9",
+ "@radix-ui/react-separator": "npm:@radix-ui/react-separator@^1.1.7",
+ "@radix-ui/react-slider": "npm:@radix-ui/react-slider@^1.3.5",
+ "@radix-ui/react-slot": "npm:@radix-ui/react-slot@^1.2.3",
+ "@radix-ui/react-tooltip": "npm:@radix-ui/react-tooltip@^1.2.7",
"@std/expect": "jsr:@std/expect@^1.0.16",
"@std/path": "jsr:@std/path@^1.0.9",
"@std/testing": "jsr:@std/testing@^1.0.14",
+ "@tailwindcss/vite": "npm:@tailwindcss/vite@^4.1.8",
+ "@types/cytoscape-fcose": "npm:@types/cytoscape-fcose@^2.2.4",
+ "@types/react": "npm:@types/react@^19.1.6",
+ "@types/react-dom": "npm:@types/react-dom@^19.1.3",
+ "@vitejs/plugin-react": "npm:@vitejs/plugin-react@^4.4.1",
+ "class-variance-authority": "npm:class-variance-authority@^0.7.1",
+ "clsx": "npm:clsx@^2.1.1",
+ "cytoscape": "npm:cytoscape@^3.32.0",
+ "cytoscape-fcose": "npm:cytoscape-fcose@^2.2.0",
"glob": "npm:glob@^11.0.2",
+ "lucide-react": "npm:lucide-react@^0.513.0",
+ "react": "npm:react@^19.1.0",
+ "react-dom": "npm:react-dom@^19.1.0",
+ "react-router-dom": "npm:react-router-dom@^7.5.3",
+ "tailwind-merge": "npm:tailwind-merge@^3.3.0",
+ "tailwindcss": "npm:tailwindcss@^4.1.8",
"tree-sitter": "npm:tree-sitter@^0.22.4",
"tree-sitter-c": "npm:tree-sitter-c@0.23.6",
"tree-sitter-c-sharp": "npm:tree-sitter-c-sharp@^0.23.1",
"tree-sitter-python": "npm:tree-sitter-python@^0.23.6",
"tree-sitter-java": "npm:tree-sitter-java@^0.23.5",
+ "tw-animate-css": "npm:tw-animate-css@^1.3.4",
+ "vite": "npm:vite@^6.3.5",
"yargs": "https://deno.land/x/yargs@v18.0.0-deno/deno.ts",
"yargs-types": "https://deno.land/x/yargs@v18.0.0-deno/deno-types.ts",
"zod": "npm:zod@^3.24.4"
},
+ "exclude": ["viewer/"],
"tasks": {
"dev": "deno run -A src/index.ts",
- "compile": "deno compile -A --output=dist/napi src/index.ts",
- "compile-linux": "deno compile -A --output=dist/napi.linux --target=x86_64-unknown-linux-gnu src/index.ts",
- "compile-macos": "deno compile -A --output=dist/napi.macos --target=x86_64-apple-darwin src/index.ts",
- "compile-windows": "deno compile -A --output=dist/napi.exe --target=x86_64-pc-windows-msvc src/index.ts",
+ "dev:viewer": "cd viewer && deno run -A npm:vite",
+ "build:viewer": "cd viewer && deno run -A npm:vite build",
+ "compile": "deno task build:viewer && deno compile -A --include=viewer/dist --output=dist/napi src/index.ts",
+ "compile-linux": "deno task build:viewer && deno compile -A --include=viewer/dist --output=dist/napi.linux --target=x86_64-unknown-linux-gnu src/index.ts",
+ "compile-macos": "deno task build:viewer && deno compile -A --include=viewer/dist --output=dist/napi.macos --target=x86_64-apple-darwin src/index.ts",
+ "compile-windows": "deno task build:viewer && deno compile -A --include=viewer/dist --output=dist/napi.exe --target=x86_64-pc-windows-msvc src/index.ts",
"compile:all": "deno task 'compile-*'",
"test": "deno test -A"
+ },
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "jsxImportSource": "react",
+ "jsxImportSourceTypes": "@types/react"
}
}
diff --git a/examples/c/network/.napirc b/examples/c/network/.napirc
index e213da94..a2cb8775 100644
--- a/examples/c/network/.napirc
+++ b/examples/c/network/.napirc
@@ -11,31 +11,12 @@
"**/build/**",
"**/bin/**",
"**/obj/**",
- "**/packages/**",
- "**/.vs/**",
- "**/TestResults/**",
- "**/*.user",
- "**/*.suo",
- "**/.nuget/**",
- "**/artifacts/**",
- "**/packages/**",
- "**/util/**",
- "**/test/**",
- "**/perf/**",
"**/napi_out/**"
]
},
"outDir": "napi_out",
- "metrics": {
- "file": {
- "maxChar": 100000,
- "maxLine": 1000,
- "maxDep": 10
- },
- "symbol": {
- "maxChar": 50000,
- "maxLine": 500,
- "maxDep": 5
- }
+ "audit": {
+ "file": { "maxCodeChar": 5000, "maxCodeLine": 300, "maxDependency": 20, "maxCyclomaticComplexity": 50 },
+ "symbol": { "maxCodeChar": 1000, "maxCodeLine": 80, "maxDependency": 10, "maxCyclomaticComplexity": 15 }
}
}
diff --git a/examples/csharp/EndpointExample/.napirc b/examples/csharp/EndpointExample/.napirc
index f34005b9..27d25646 100644
--- a/examples/csharp/EndpointExample/.napirc
+++ b/examples/csharp/EndpointExample/.napirc
@@ -5,16 +5,8 @@
"exclude": ["bin/**", "obj/**", "napi-output/**"]
},
"outDir": "napi-output",
- "metrics": {
- "file": {
- "maxChar": 100000,
- "maxLine": 1000,
- "maxDep": 10
- },
- "symbol": {
- "maxChar": 50000,
- "maxLine": 500,
- "maxDep": 5
- }
+ "audit": {
+ "file": { "maxCodeChar": 5000, "maxCodeLine": 300, "maxDependency": 20, "maxCyclomaticComplexity": 50 },
+ "symbol": { "maxCodeChar": 1000, "maxCodeLine": 80, "maxDependency": 10, "maxCyclomaticComplexity": 15 }
}
}
diff --git a/examples/java/websocket/src/main/resources/static/echo.html b/examples/java/websocket/src/main/resources/static/echo.html
index 3a37f451..b0a6406a 100644
--- a/examples/java/websocket/src/main/resources/static/echo.html
+++ b/examples/java/websocket/src/main/resources/static/echo.html
@@ -125,8 +125,8 @@
/>
- Connect
-
+ Connect
+
Disconnect
@@ -135,7 +135,7 @@
>Here is a message!
-
+
Echo message
diff --git a/examples/python/flask/.napi/manifests/1775598176791-d5c2217.json b/examples/python/flask/.napi/manifests/1775598176791-d5c2217.json
new file mode 100644
index 00000000..b52885ce
--- /dev/null
+++ b/examples/python/flask/.napi/manifests/1775598176791-d5c2217.json
@@ -0,0 +1,2128 @@
+{
+ "id": "1775598176791-d5c2217",
+ "branch": "main",
+ "commitSha": "d5c22175a36c5de08482bfa3e1ea59758e888405",
+ "commitShaDate": "2025-07-07T11:42:05+02:00",
+ "createdAt": "2026-04-07T21:42:56.791Z",
+ "manifest": {
+ "api/data/elves.py": {
+ "id": "api/data/elves.py",
+ "filePath": "api/data/elves.py",
+ "metrics": {
+ "linesCount": 11,
+ "codeLineCount": 11,
+ "characterCount": 297,
+ "codeCharacterCount": 239,
+ "dependencyCount": 0,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "language": "python",
+ "dependencies": {},
+ "dependents": {
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "symbols": {
+ "Elf": "Elf",
+ "elves": "elves",
+ "ElfService": "ElfService"
+ }
+ }
+ },
+ "symbols": {
+ "Elf": {
+ "id": "Elf",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 0,
+ "row": 0,
+ "column": 0
+ },
+ "end": {
+ "index": 176,
+ "row": 6,
+ "column": 36
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 7,
+ "codeLineCount": 6,
+ "characterCount": 176,
+ "codeCharacterCount": 138,
+ "dependencyCount": 0,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {},
+ "dependents": {
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "symbols": {
+ "ElfService": "ElfService"
+ }
+ }
+ }
+ },
+ "elves": {
+ "id": "elves",
+ "type": "variable",
+ "positions": [
+ {
+ "start": {
+ "index": 179,
+ "row": 9,
+ "column": 0
+ },
+ "end": {
+ "index": 296,
+ "row": 13,
+ "column": 1
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 5,
+ "codeLineCount": 5,
+ "characterCount": 117,
+ "codeCharacterCount": 101,
+ "dependencyCount": 0,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {},
+ "dependents": {
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "symbols": {
+ "ElfService": "ElfService"
+ }
+ }
+ }
+ }
+ }
+ },
+ "api/data/hobbits.py": {
+ "id": "api/data/hobbits.py",
+ "filePath": "api/data/hobbits.py",
+ "metrics": {
+ "linesCount": 11,
+ "codeLineCount": 11,
+ "characterCount": 323,
+ "codeCharacterCount": 265,
+ "dependencyCount": 0,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "language": "python",
+ "dependencies": {},
+ "dependents": {
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "symbols": {
+ "Hobbit": "Hobbit",
+ "hobbits": "hobbits",
+ "HobbitService": "HobbitService"
+ }
+ }
+ },
+ "symbols": {
+ "Hobbit": {
+ "id": "Hobbit",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 0,
+ "row": 0,
+ "column": 0
+ },
+ "end": {
+ "index": 179,
+ "row": 6,
+ "column": 36
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 7,
+ "codeLineCount": 6,
+ "characterCount": 179,
+ "codeCharacterCount": 141,
+ "dependencyCount": 0,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {},
+ "dependents": {
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "symbols": {
+ "HobbitService": "HobbitService"
+ }
+ }
+ }
+ },
+ "hobbits": {
+ "id": "hobbits",
+ "type": "variable",
+ "positions": [
+ {
+ "start": {
+ "index": 182,
+ "row": 9,
+ "column": 0
+ },
+ "end": {
+ "index": 322,
+ "row": 13,
+ "column": 1
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 5,
+ "codeLineCount": 5,
+ "characterCount": 140,
+ "codeCharacterCount": 124,
+ "dependencyCount": 0,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {},
+ "dependents": {
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "symbols": {
+ "HobbitService": "HobbitService"
+ }
+ }
+ }
+ }
+ }
+ },
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "filePath": "api/services/elves.py",
+ "metrics": {
+ "linesCount": 21,
+ "codeLineCount": 21,
+ "characterCount": 602,
+ "codeCharacterCount": 419,
+ "dependencyCount": 1,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 5
+ },
+ "language": "python",
+ "dependencies": {
+ "api/data/elves.py": {
+ "id": "api/data/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "Elf": "Elf",
+ "elves": "elves"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "symbols": {
+ "ElfService": "ElfService",
+ "ElfListView": "ElfListView",
+ "ElfCreateView": "ElfCreateView",
+ "ElfDetailView": "ElfDetailView",
+ "ElfUpdateView": "ElfUpdateView",
+ "ElfDeleteView": "ElfDeleteView"
+ }
+ }
+ },
+ "symbols": {
+ "ElfService": {
+ "id": "ElfService",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 40,
+ "row": 3,
+ "column": 0
+ },
+ "end": {
+ "index": 601,
+ "row": 26,
+ "column": 33
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 24,
+ "codeLineCount": 20,
+ "characterCount": 561,
+ "codeCharacterCount": 382,
+ "dependencyCount": 1,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 5
+ },
+ "dependencies": {
+ "api/data/elves.py": {
+ "id": "api/data/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "Elf": "Elf",
+ "elves": "elves"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "symbols": {
+ "ElfListView": "ElfListView",
+ "ElfCreateView": "ElfCreateView",
+ "ElfDetailView": "ElfDetailView",
+ "ElfUpdateView": "ElfUpdateView",
+ "ElfDeleteView": "ElfDeleteView"
+ }
+ }
+ }
+ }
+ }
+ },
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "filePath": "api/services/hobbits.py",
+ "metrics": {
+ "linesCount": 21,
+ "codeLineCount": 21,
+ "characterCount": 698,
+ "codeCharacterCount": 515,
+ "dependencyCount": 1,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 5
+ },
+ "language": "python",
+ "dependencies": {
+ "api/data/hobbits.py": {
+ "id": "api/data/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "Hobbit": "Hobbit",
+ "hobbits": "hobbits"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "symbols": {
+ "HobbitService": "HobbitService",
+ "HobbitListView": "HobbitListView",
+ "HobbitCreateView": "HobbitCreateView",
+ "HobbitDetailView": "HobbitDetailView",
+ "HobbitUpdateView": "HobbitUpdateView",
+ "HobbitDeleteView": "HobbitDeleteView"
+ }
+ }
+ },
+ "symbols": {
+ "HobbitService": {
+ "id": "HobbitService",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 47,
+ "row": 3,
+ "column": 0
+ },
+ "end": {
+ "index": 697,
+ "row": 26,
+ "column": 38
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 24,
+ "codeLineCount": 20,
+ "characterCount": 650,
+ "codeCharacterCount": 471,
+ "dependencyCount": 1,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 5
+ },
+ "dependencies": {
+ "api/data/hobbits.py": {
+ "id": "api/data/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "Hobbit": "Hobbit",
+ "hobbits": "hobbits"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "symbols": {
+ "HobbitListView": "HobbitListView",
+ "HobbitCreateView": "HobbitCreateView",
+ "HobbitDetailView": "HobbitDetailView",
+ "HobbitUpdateView": "HobbitUpdateView",
+ "HobbitDeleteView": "HobbitDeleteView"
+ }
+ }
+ }
+ }
+ }
+ },
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "filePath": "api/views/elves.py",
+ "metrics": {
+ "linesCount": 39,
+ "codeLineCount": 39,
+ "characterCount": 1770,
+ "codeCharacterCount": 1279,
+ "dependencyCount": 3,
+ "dependentCount": 2,
+ "cyclomaticComplexity": 1
+ },
+ "language": "python",
+ "dependencies": {
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "ElfService": "ElfService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "request": "request",
+ "Blueprint": "Blueprint"
+ }
+ }
+ },
+ "dependents": {
+ "app.py": {
+ "id": "app.py",
+ "symbols": {
+ "elves_bp": "elves_bp",
+ "app": "app"
+ }
+ },
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "symbols": {
+ "elves_bp": "elves_bp"
+ }
+ }
+ },
+ "symbols": {
+ "ElfCreateView": {
+ "id": "ElfCreateView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 430,
+ "row": 17,
+ "column": 0
+ },
+ "end": {
+ "index": 604,
+ "row": 21,
+ "column": 22
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 5,
+ "codeLineCount": 5,
+ "characterCount": 174,
+ "codeCharacterCount": 142,
+ "dependencyCount": 3,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "ElfService": "ElfService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "request": "request"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "symbols": {
+ "elves_bp": "elves_bp"
+ }
+ }
+ }
+ },
+ "ElfDeleteView": {
+ "id": "ElfDeleteView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 1450,
+ "row": 55,
+ "column": 0
+ },
+ "end": {
+ "index": 1589,
+ "row": 58,
+ "column": 19
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 139,
+ "codeCharacterCount": 116,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "ElfService": "ElfService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "symbols": {
+ "elves_bp": "elves_bp"
+ }
+ }
+ }
+ },
+ "ElfDetailView": {
+ "id": "ElfDetailView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 762,
+ "row": 30,
+ "column": 0
+ },
+ "end": {
+ "index": 900,
+ "row": 33,
+ "column": 18
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 138,
+ "codeCharacterCount": 115,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "ElfService": "ElfService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "symbols": {
+ "elves_bp": "elves_bp"
+ }
+ }
+ }
+ },
+ "ElfListView": {
+ "id": "ElfListView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 157,
+ "row": 7,
+ "column": 0
+ },
+ "end": {
+ "index": 285,
+ "row": 10,
+ "column": 20
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 128,
+ "codeCharacterCount": 105,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "ElfService": "ElfService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "symbols": {
+ "elves_bp": "elves_bp"
+ }
+ }
+ }
+ },
+ "ElfUpdateView": {
+ "id": "ElfUpdateView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 1076,
+ "row": 42,
+ "column": 0
+ },
+ "end": {
+ "index": 1273,
+ "row": 46,
+ "column": 26
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 5,
+ "codeLineCount": 5,
+ "characterCount": 197,
+ "codeCharacterCount": 165,
+ "dependencyCount": 3,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/elves.py": {
+ "id": "api/services/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "ElfService": "ElfService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "request": "request"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "symbols": {
+ "elves_bp": "elves_bp"
+ }
+ }
+ }
+ },
+ "elves_bp": {
+ "id": "elves_bp",
+ "type": "variable",
+ "positions": [
+ {
+ "start": {
+ "index": 115,
+ "row": 4,
+ "column": 0
+ },
+ "end": {
+ "index": 154,
+ "row": 4,
+ "column": 39
+ }
+ },
+ {
+ "start": {
+ "index": 341,
+ "row": 14,
+ "column": 0
+ },
+ "end": {
+ "index": 427,
+ "row": 14,
+ "column": 86
+ }
+ },
+ {
+ "start": {
+ "index": 662,
+ "row": 25,
+ "column": 0
+ },
+ "end": {
+ "index": 759,
+ "row": 27,
+ "column": 1
+ }
+ },
+ {
+ "start": {
+ "index": 965,
+ "row": 37,
+ "column": 0
+ },
+ "end": {
+ "index": 1073,
+ "row": 39,
+ "column": 1
+ }
+ },
+ {
+ "start": {
+ "index": 1339,
+ "row": 50,
+ "column": 0
+ },
+ "end": {
+ "index": 1447,
+ "row": 52,
+ "column": 1
+ }
+ },
+ {
+ "start": {
+ "index": 1658,
+ "row": 62,
+ "column": 0
+ },
+ "end": {
+ "index": 1769,
+ "row": 64,
+ "column": 1
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 14,
+ "codeLineCount": 14,
+ "characterCount": 549,
+ "codeCharacterCount": 525,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "ElfListView": "ElfListView",
+ "ElfCreateView": "ElfCreateView",
+ "ElfDetailView": "ElfDetailView",
+ "ElfUpdateView": "ElfUpdateView",
+ "ElfDeleteView": "ElfDeleteView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "Blueprint": "Blueprint"
+ }
+ }
+ },
+ "dependents": {
+ "app.py": {
+ "id": "app.py",
+ "symbols": {
+ "app": "app"
+ }
+ }
+ }
+ }
+ }
+ },
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "filePath": "api/views/hobbits.py",
+ "metrics": {
+ "linesCount": 47,
+ "codeLineCount": 47,
+ "characterCount": 1979,
+ "codeCharacterCount": 1418,
+ "dependencyCount": 3,
+ "dependentCount": 2,
+ "cyclomaticComplexity": 1
+ },
+ "language": "python",
+ "dependencies": {
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "HobbitService": "HobbitService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "request": "request",
+ "Blueprint": "Blueprint"
+ }
+ }
+ },
+ "dependents": {
+ "app.py": {
+ "id": "app.py",
+ "symbols": {
+ "hobbits_bp": "hobbits_bp",
+ "app": "app"
+ }
+ },
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "symbols": {
+ "hobbits_bp": "hobbits_bp"
+ }
+ }
+ },
+ "symbols": {
+ "HobbitCreateView": {
+ "id": "HobbitCreateView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 470,
+ "row": 19,
+ "column": 0
+ },
+ "end": {
+ "index": 659,
+ "row": 23,
+ "column": 25
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 5,
+ "codeLineCount": 5,
+ "characterCount": 189,
+ "codeCharacterCount": 157,
+ "dependencyCount": 3,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "HobbitService": "HobbitService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "request": "request"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "symbols": {
+ "hobbits_bp": "hobbits_bp"
+ }
+ }
+ }
+ },
+ "HobbitDeleteView": {
+ "id": "HobbitDeleteView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 1616,
+ "row": 61,
+ "column": 0
+ },
+ "end": {
+ "index": 1770,
+ "row": 64,
+ "column": 19
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 154,
+ "codeCharacterCount": 131,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "HobbitService": "HobbitService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "symbols": {
+ "hobbits_bp": "hobbits_bp"
+ }
+ }
+ }
+ },
+ "HobbitDetailView": {
+ "id": "HobbitDetailView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 830,
+ "row": 32,
+ "column": 0
+ },
+ "end": {
+ "index": 989,
+ "row": 35,
+ "column": 21
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 159,
+ "codeCharacterCount": 136,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "HobbitService": "HobbitService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "symbols": {
+ "hobbits_bp": "hobbits_bp"
+ }
+ }
+ }
+ },
+ "HobbitListView": {
+ "id": "HobbitListView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 166,
+ "row": 7,
+ "column": 0
+ },
+ "end": {
+ "index": 306,
+ "row": 10,
+ "column": 22
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 140,
+ "codeCharacterCount": 117,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "HobbitService": "HobbitService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "symbols": {
+ "hobbits_bp": "hobbits_bp"
+ }
+ }
+ }
+ },
+ "hobbits_bp": {
+ "id": "hobbits_bp",
+ "type": "variable",
+ "positions": [
+ {
+ "start": {
+ "index": 120,
+ "row": 4,
+ "column": 0
+ },
+ "end": {
+ "index": 163,
+ "row": 4,
+ "column": 43
+ }
+ },
+ {
+ "start": {
+ "index": 367,
+ "row": 14,
+ "column": 0
+ },
+ "end": {
+ "index": 467,
+ "row": 16,
+ "column": 1
+ }
+ },
+ {
+ "start": {
+ "index": 722,
+ "row": 27,
+ "column": 0
+ },
+ "end": {
+ "index": 827,
+ "row": 29,
+ "column": 1
+ }
+ },
+ {
+ "start": {
+ "index": 1062,
+ "row": 39,
+ "column": 0
+ },
+ "end": {
+ "index": 1190,
+ "row": 43,
+ "column": 1
+ }
+ },
+ {
+ "start": {
+ "index": 1485,
+ "row": 54,
+ "column": 0
+ },
+ "end": {
+ "index": 1613,
+ "row": 58,
+ "column": 1
+ }
+ },
+ {
+ "start": {
+ "index": 1847,
+ "row": 68,
+ "column": 0
+ },
+ "end": {
+ "index": 1978,
+ "row": 72,
+ "column": 1
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 22,
+ "codeLineCount": 22,
+ "characterCount": 635,
+ "codeCharacterCount": 575,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "HobbitListView": "HobbitListView",
+ "HobbitCreateView": "HobbitCreateView",
+ "HobbitDetailView": "HobbitDetailView",
+ "HobbitUpdateView": "HobbitUpdateView",
+ "HobbitDeleteView": "HobbitDeleteView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "Blueprint": "Blueprint"
+ }
+ }
+ },
+ "dependents": {
+ "app.py": {
+ "id": "app.py",
+ "symbols": {
+ "app": "app"
+ }
+ }
+ }
+ },
+ "HobbitUpdateView": {
+ "id": "HobbitUpdateView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 1193,
+ "row": 46,
+ "column": 0
+ },
+ "end": {
+ "index": 1411,
+ "row": 50,
+ "column": 29
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 5,
+ "codeLineCount": 5,
+ "characterCount": 218,
+ "codeCharacterCount": 186,
+ "dependencyCount": 3,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/services/hobbits.py": {
+ "id": "api/services/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "HobbitService": "HobbitService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "request": "request"
+ }
+ }
+ },
+ "dependents": {
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "symbols": {
+ "hobbits_bp": "hobbits_bp"
+ }
+ }
+ }
+ }
+ }
+ },
+ "api/wizards/data.py": {
+ "id": "api/wizards/data.py",
+ "filePath": "api/wizards/data.py",
+ "metrics": {
+ "linesCount": 11,
+ "codeLineCount": 11,
+ "characterCount": 316,
+ "codeCharacterCount": 258,
+ "dependencyCount": 0,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "language": "python",
+ "dependencies": {},
+ "dependents": {
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "symbols": {
+ "Wizard": "Wizard",
+ "wizards": "wizards",
+ "WizardService": "WizardService"
+ }
+ }
+ },
+ "symbols": {
+ "Wizard": {
+ "id": "Wizard",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 0,
+ "row": 0,
+ "column": 0
+ },
+ "end": {
+ "index": 179,
+ "row": 6,
+ "column": 36
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 7,
+ "codeLineCount": 6,
+ "characterCount": 179,
+ "codeCharacterCount": 141,
+ "dependencyCount": 0,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {},
+ "dependents": {
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "symbols": {
+ "WizardService": "WizardService"
+ }
+ }
+ }
+ },
+ "wizards": {
+ "id": "wizards",
+ "type": "variable",
+ "positions": [
+ {
+ "start": {
+ "index": 182,
+ "row": 9,
+ "column": 0
+ },
+ "end": {
+ "index": 315,
+ "row": 13,
+ "column": 1
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 5,
+ "codeLineCount": 5,
+ "characterCount": 133,
+ "codeCharacterCount": 117,
+ "dependencyCount": 0,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {},
+ "dependents": {
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "symbols": {
+ "WizardService": "WizardService"
+ }
+ }
+ }
+ }
+ }
+ },
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "filePath": "api/wizards/services.py",
+ "metrics": {
+ "linesCount": 22,
+ "codeLineCount": 22,
+ "characterCount": 742,
+ "codeCharacterCount": 559,
+ "dependencyCount": 1,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 5
+ },
+ "language": "python",
+ "dependencies": {
+ "api/wizards/data.py": {
+ "id": "api/wizards/data.py",
+ "isExternal": false,
+ "symbols": {
+ "Wizard": "Wizard",
+ "wizards": "wizards"
+ }
+ }
+ },
+ "dependents": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "symbols": {
+ "WizardService": "WizardService",
+ "WizardListView": "WizardListView",
+ "WizardCreateView": "WizardCreateView",
+ "WizardDetailView": "WizardDetailView",
+ "WizardUpdateView": "WizardUpdateView",
+ "WizardDeleteView": "WizardDeleteView"
+ }
+ }
+ },
+ "symbols": {
+ "WizardService": {
+ "id": "WizardService",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 91,
+ "row": 3,
+ "column": 0
+ },
+ "end": {
+ "index": 741,
+ "row": 26,
+ "column": 38
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 24,
+ "codeLineCount": 20,
+ "characterCount": 650,
+ "codeCharacterCount": 471,
+ "dependencyCount": 1,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 5
+ },
+ "dependencies": {
+ "api/wizards/data.py": {
+ "id": "api/wizards/data.py",
+ "isExternal": false,
+ "symbols": {
+ "Wizard": "Wizard",
+ "wizards": "wizards"
+ }
+ }
+ },
+ "dependents": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "symbols": {
+ "WizardListView": "WizardListView",
+ "WizardCreateView": "WizardCreateView",
+ "WizardDetailView": "WizardDetailView",
+ "WizardUpdateView": "WizardUpdateView",
+ "WizardDeleteView": "WizardDeleteView"
+ }
+ }
+ }
+ }
+ }
+ },
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "filePath": "api/wizards/views.py",
+ "metrics": {
+ "linesCount": 49,
+ "codeLineCount": 49,
+ "characterCount": 2120,
+ "codeCharacterCount": 1446,
+ "dependencyCount": 3,
+ "dependentCount": 2,
+ "cyclomaticComplexity": 1
+ },
+ "language": "python",
+ "dependencies": {
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "isExternal": false,
+ "symbols": {
+ "WizardService": "WizardService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "request": "request",
+ "Blueprint": "Blueprint"
+ }
+ }
+ },
+ "dependents": {
+ "app.py": {
+ "id": "app.py",
+ "symbols": {
+ "get_wizzards_bp": "get_wizzards_bp",
+ "app": "app"
+ }
+ },
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "symbols": {
+ "get_wizzards_bp": "get_wizzards_bp"
+ }
+ }
+ },
+ "symbols": {
+ "get_wizzards_bp": {
+ "id": "get_wizzards_bp",
+ "type": "function",
+ "positions": [
+ {
+ "start": {
+ "index": 984,
+ "row": 36,
+ "column": 0
+ },
+ "end": {
+ "index": 2120,
+ "row": 70,
+ "column": 21
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 35,
+ "codeLineCount": 24,
+ "characterCount": 1136,
+ "codeCharacterCount": 614,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "isExternal": false,
+ "symbols": {
+ "WizardListView": "WizardListView",
+ "WizardCreateView": "WizardCreateView",
+ "WizardDetailView": "WizardDetailView",
+ "WizardUpdateView": "WizardUpdateView",
+ "WizardDeleteView": "WizardDeleteView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "Blueprint": "Blueprint"
+ }
+ }
+ },
+ "dependents": {
+ "app.py": {
+ "id": "app.py",
+ "symbols": {
+ "app": "app"
+ }
+ }
+ }
+ },
+ "WizardCreateView": {
+ "id": "WizardCreateView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 252,
+ "row": 10,
+ "column": 0
+ },
+ "end": {
+ "index": 441,
+ "row": 14,
+ "column": 25
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 5,
+ "codeLineCount": 5,
+ "characterCount": 189,
+ "codeCharacterCount": 157,
+ "dependencyCount": 3,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "isExternal": false,
+ "symbols": {
+ "WizardService": "WizardService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "request": "request"
+ }
+ }
+ },
+ "dependents": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "symbols": {
+ "get_wizzards_bp": "get_wizzards_bp"
+ }
+ }
+ }
+ },
+ "WizardDeleteView": {
+ "id": "WizardDeleteView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 827,
+ "row": 30,
+ "column": 0
+ },
+ "end": {
+ "index": 981,
+ "row": 33,
+ "column": 19
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 154,
+ "codeCharacterCount": 131,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "isExternal": false,
+ "symbols": {
+ "WizardService": "WizardService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ }
+ },
+ "dependents": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "symbols": {
+ "get_wizzards_bp": "get_wizzards_bp"
+ }
+ }
+ }
+ },
+ "WizardDetailView": {
+ "id": "WizardDetailView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 444,
+ "row": 17,
+ "column": 0
+ },
+ "end": {
+ "index": 603,
+ "row": 20,
+ "column": 21
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 159,
+ "codeCharacterCount": 136,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "isExternal": false,
+ "symbols": {
+ "WizardService": "WizardService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ }
+ },
+ "dependents": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "symbols": {
+ "get_wizzards_bp": "get_wizzards_bp"
+ }
+ }
+ }
+ },
+ "WizardListView": {
+ "id": "WizardListView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 109,
+ "row": 4,
+ "column": 0
+ },
+ "end": {
+ "index": 249,
+ "row": 7,
+ "column": 22
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 140,
+ "codeCharacterCount": 117,
+ "dependencyCount": 2,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "isExternal": false,
+ "symbols": {
+ "WizardService": "WizardService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ }
+ },
+ "dependents": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "symbols": {
+ "get_wizzards_bp": "get_wizzards_bp"
+ }
+ }
+ }
+ },
+ "WizardUpdateView": {
+ "id": "WizardUpdateView",
+ "type": "class",
+ "positions": [
+ {
+ "start": {
+ "index": 606,
+ "row": 23,
+ "column": 0
+ },
+ "end": {
+ "index": 824,
+ "row": 27,
+ "column": 29
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 5,
+ "codeLineCount": 5,
+ "characterCount": 218,
+ "codeCharacterCount": 186,
+ "dependencyCount": 3,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/wizards/services.py": {
+ "id": "api/wizards/services.py",
+ "isExternal": false,
+ "symbols": {
+ "WizardService": "WizardService"
+ }
+ },
+ "flask.views": {
+ "id": "flask.views",
+ "isExternal": true,
+ "symbols": {
+ "MethodView": "MethodView"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "request": "request"
+ }
+ }
+ },
+ "dependents": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "symbols": {
+ "get_wizzards_bp": "get_wizzards_bp"
+ }
+ }
+ }
+ }
+ }
+ },
+ "app.py": {
+ "id": "app.py",
+ "filePath": "app.py",
+ "metrics": {
+ "linesCount": 17,
+ "codeLineCount": 17,
+ "characterCount": 698,
+ "codeCharacterCount": 538,
+ "dependencyCount": 4,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "language": "python",
+ "dependencies": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "isExternal": false,
+ "symbols": {
+ "get_wizzards_bp": "get_wizzards_bp"
+ }
+ },
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "elves_bp": "elves_bp"
+ }
+ },
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "hobbits_bp": "hobbits_bp"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "Flask": "Flask"
+ }
+ }
+ },
+ "dependents": {
+ "app.py": {
+ "id": "app.py",
+ "symbols": {
+ "hello_world": "hello_world",
+ "liveness": "liveness",
+ "readiness": "readiness"
+ }
+ }
+ },
+ "symbols": {
+ "app": {
+ "id": "app",
+ "type": "variable",
+ "positions": [
+ {
+ "start": {
+ "index": 150,
+ "row": 5,
+ "column": 0
+ },
+ "end": {
+ "index": 171,
+ "row": 5,
+ "column": 21
+ }
+ },
+ {
+ "start": {
+ "index": 451,
+ "row": 23,
+ "column": 0
+ },
+ "end": {
+ "index": 519,
+ "row": 23,
+ "column": 68
+ }
+ },
+ {
+ "start": {
+ "index": 548,
+ "row": 26,
+ "column": 0
+ },
+ "end": {
+ "index": 605,
+ "row": 26,
+ "column": 57
+ }
+ },
+ {
+ "start": {
+ "index": 636,
+ "row": 29,
+ "column": 0
+ },
+ "end": {
+ "index": 697,
+ "row": 29,
+ "column": 61
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 4,
+ "codeLineCount": 4,
+ "characterCount": 207,
+ "codeCharacterCount": 207,
+ "dependencyCount": 4,
+ "dependentCount": 1,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "api/wizards/views.py": {
+ "id": "api/wizards/views.py",
+ "isExternal": false,
+ "symbols": {
+ "get_wizzards_bp": "get_wizzards_bp"
+ }
+ },
+ "api/views/elves.py": {
+ "id": "api/views/elves.py",
+ "isExternal": false,
+ "symbols": {
+ "elves_bp": "elves_bp"
+ }
+ },
+ "api/views/hobbits.py": {
+ "id": "api/views/hobbits.py",
+ "isExternal": false,
+ "symbols": {
+ "hobbits_bp": "hobbits_bp"
+ }
+ },
+ "flask": {
+ "id": "flask",
+ "isExternal": true,
+ "symbols": {
+ "Flask": "Flask"
+ }
+ }
+ },
+ "dependents": {
+ "app.py": {
+ "id": "app.py",
+ "symbols": {
+ "hello_world": "hello_world",
+ "liveness": "liveness",
+ "readiness": "readiness"
+ }
+ }
+ }
+ },
+ "hello_world": {
+ "id": "hello_world",
+ "type": "function",
+ "positions": [
+ {
+ "start": {
+ "index": 174,
+ "row": 8,
+ "column": 0
+ },
+ "end": {
+ "index": 246,
+ "row": 10,
+ "column": 39
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 3,
+ "codeLineCount": 3,
+ "characterCount": 72,
+ "codeCharacterCount": 66,
+ "dependencyCount": 1,
+ "dependentCount": 0,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "app.py": {
+ "id": "app.py",
+ "isExternal": false,
+ "symbols": {
+ "app": "app"
+ }
+ }
+ },
+ "dependents": {}
+ },
+ "liveness": {
+ "id": "liveness",
+ "type": "function",
+ "positions": [
+ {
+ "start": {
+ "index": 249,
+ "row": 13,
+ "column": 0
+ },
+ "end": {
+ "index": 314,
+ "row": 15,
+ "column": 27
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 3,
+ "codeLineCount": 3,
+ "characterCount": 65,
+ "codeCharacterCount": 59,
+ "dependencyCount": 1,
+ "dependentCount": 0,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "app.py": {
+ "id": "app.py",
+ "isExternal": false,
+ "symbols": {
+ "app": "app"
+ }
+ }
+ },
+ "dependents": {}
+ },
+ "readiness": {
+ "id": "readiness",
+ "type": "function",
+ "positions": [
+ {
+ "start": {
+ "index": 353,
+ "row": 18,
+ "column": 0
+ },
+ "end": {
+ "index": 420,
+ "row": 20,
+ "column": 27
+ }
+ }
+ ],
+ "description": "",
+ "metrics": {
+ "linesCount": 3,
+ "codeLineCount": 3,
+ "characterCount": 67,
+ "codeCharacterCount": 61,
+ "dependencyCount": 1,
+ "dependentCount": 0,
+ "cyclomaticComplexity": 1
+ },
+ "dependencies": {
+ "app.py": {
+ "id": "app.py",
+ "isExternal": false,
+ "symbols": {
+ "app": "app"
+ }
+ }
+ },
+ "dependents": {}
+ }
+ }
+ }
+ }
+}
diff --git a/examples/python/flask/.napirc b/examples/python/flask/.napirc
index 1f2c6bd5..326fd1d8 100644
--- a/examples/python/flask/.napirc
+++ b/examples/python/flask/.napirc
@@ -8,16 +8,8 @@
"exclude": []
},
"outDir": "napi-output",
- "metrics": {
- "file": {
- "maxChar": 100000,
- "maxLine": 1000,
- "maxDep": 10
- },
- "symbol": {
- "maxChar": 50000,
- "maxLine": 500,
- "maxDep": 5
- }
+ "audit": {
+ "file": { "maxCodeChar": 5000, "maxCodeLine": 300, "maxDependency": 20, "maxCyclomaticComplexity": 50 },
+ "symbol": { "maxCodeChar": 1000, "maxCodeLine": 80, "maxDependency": 10, "maxCyclomaticComplexity": 15 }
}
}
diff --git a/src/apiService/index.ts b/src/apiService/index.ts
deleted file mode 100644
index 5caa9e37..00000000
--- a/src/apiService/index.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import type { z } from "zod";
-import type { globalConfigSchema } from "../cli/middlewares/globalConfig.ts";
-
-export class ApiService {
- private readonly apiHost: string;
- private readonly jwt: string | undefined;
- private readonly token: string | undefined;
-
- constructor(
- globalConfig: z.infer,
- ) {
- this.apiHost = globalConfig.apiHost;
- this.token = globalConfig.token;
- this.jwt = globalConfig.jwt;
- }
-
- private getHeaders() {
- const headers = new Headers();
- if (this.token) {
- headers.set("x-api-token", this.token);
- } else if (this.jwt) {
- headers.set("Authorization", `Bearer ${this.jwt}`);
- }
- return headers;
- }
-
- public async performRequest(
- method: "GET" | "POST" | "PUT" | "DELETE",
- path: string,
- body?: object,
- ) {
- const response = await fetch(`${this.apiHost}${path}`, {
- method,
- headers: this.getHeaders(),
- body: body ? JSON.stringify(body) : undefined,
- });
- return response;
- }
-}
diff --git a/src/cli/handlers/extract/index.ts b/src/cli/handlers/extract/index.ts
index c1813d99..1bc979c1 100644
--- a/src/cli/handlers/extract/index.ts
+++ b/src/cli/handlers/extract/index.ts
@@ -10,12 +10,48 @@ import {
} from "../../../helpers/fileSystem/index.ts";
import { join } from "@std/path";
import { napiConfigMiddleware } from "../../middlewares/napiConfig.ts";
-import { ApiService } from "../../../apiService/index.ts";
-import type { globalConfigSchema } from "../../middlewares/globalConfig.ts";
import type { DependencyManifest } from "../../../manifest/dependencyManifest/types.ts";
-import { isAuthenticatedMiddleware } from "../../middlewares/isAuthenticated.ts";
+import type { globalConfigSchema } from "../../middlewares/globalConfig.ts";
+
+const NAPI_DIR = ".napi";
+const MANIFESTS_DIR = "manifests";
+
+interface ManifestEnvelope {
+ id: string;
+ branch: string;
+ commitSha: string;
+ commitShaDate: string;
+ createdAt: string;
+ manifest: DependencyManifest;
+}
+
+function getLatestManifestId(workdir: string): string | null {
+ const manifestsDir = join(workdir, NAPI_DIR, MANIFESTS_DIR);
+ try {
+ const entries: { name: string }[] = [];
+ for (const entry of Deno.readDirSync(manifestsDir)) {
+ if (entry.isFile && entry.name.endsWith(".json")) {
+ entries.push(entry);
+ }
+ }
+ if (entries.length === 0) return null;
+ entries.sort((a, b) => b.name.localeCompare(a.name));
+ return entries[0].name.replace(".json", "");
+ } catch {
+ return null;
+ }
+}
-// Type for the symbol option
+function loadManifest(workdir: string, manifestId: string): ManifestEnvelope {
+ const manifestPath = join(
+ workdir,
+ NAPI_DIR,
+ MANIFESTS_DIR,
+ `${manifestId}.json`,
+ );
+ const content = Deno.readTextFileSync(manifestPath);
+ return JSON.parse(content) as ManifestEnvelope;
+}
function builderFunction(
yargs: Arguments & {
@@ -24,7 +60,6 @@ function builderFunction(
) {
return yargs
.middleware(napiConfigMiddleware)
- .middleware(isAuthenticatedMiddleware)
.option("symbol", {
type: "array" as const,
description:
@@ -35,9 +70,9 @@ function builderFunction(
})
.option("manifestId", {
type: "string",
- description: "The manifest ID to use for the extraction",
+ description:
+ "The manifest ID to use for the extraction (defaults to latest)",
requiresArg: true,
- demandOption: true,
})
.check(
(
@@ -53,7 +88,6 @@ function builderFunction(
throw new Error("At least one symbol must be specified");
}
- // Validate each symbol format
for (const symbolSpec of symbols) {
const splitSymbol = symbolSpec.split("|");
if (splitSymbol.length !== 2) {
@@ -68,56 +102,52 @@ function builderFunction(
);
}
-async function handler(
+function handler(
argv: Arguments & {
globalConfig: z.infer;
} & {
symbol: string[];
- manifestId: string;
+ manifestId?: string;
},
) {
const napiConfig = argv.napiConfig as z.infer;
- const globalConfig = argv.globalConfig as z.infer;
const start = Date.now();
console.info("🎯 Starting symbol extraction...");
try {
- console.info(`📄 Fetching manifest from API (ID: ${argv.manifestId})...`);
-
- // Create API service instance
- const apiService = new ApiService(
- globalConfig,
- );
-
- // Fetch manifest from API
- const response = await apiService.performRequest(
- "GET",
- `/manifests/${argv.manifestId}`,
- );
+ let manifestId = argv.manifestId;
+
+ if (!manifestId) {
+ manifestId = getLatestManifestId(argv.workdir);
+ if (!manifestId) {
+ console.error("❌ No manifests found in .napi/manifests/");
+ console.error(" Run 'napi generate' first to create a manifest.");
+ Deno.exit(1);
+ }
+ console.info(`📄 Using latest manifest: ${manifestId}`);
+ } else {
+ console.info(`📄 Using manifest: ${manifestId}`);
+ }
- if (response.status !== 200) {
- console.error("❌ Failed to fetch manifest from API");
- console.error(` Status: ${response.status}`);
- try {
- const errorBody = await response.json();
- if (errorBody.error) {
- console.error(` Error: ${errorBody.error}`);
- }
- } catch {
- // Ignore JSON parsing errors
+ let envelope: ManifestEnvelope;
+ try {
+ envelope = loadManifest(argv.workdir, manifestId);
+ } catch (error) {
+ console.error(`❌ Failed to load manifest: ${manifestId}`);
+ if (error instanceof Deno.errors.NotFound) {
+ console.error(
+ ` File not found: .napi/manifests/${manifestId}.json`,
+ );
+ } else {
+ console.error(
+ ` Error: ${error instanceof Error ? error.message : String(error)}`,
+ );
}
- console.error("");
- console.error("💡 Common solutions:");
- console.error(" • Check that the manifest ID is correct");
- console.error(" • Verify the project exists and you have access");
Deno.exit(1);
}
- const responseData = await response.json() as {
- manifest: DependencyManifest;
- };
- const dependencyManifest = responseData.manifest;
+ const dependencyManifest = envelope.manifest;
console.info("🔍 Validating symbol specifications...");
const symbolsToExtract = new Map<
@@ -128,7 +158,6 @@ async function handler(
for (const symbolSpec of argv.symbol) {
const [filePath, symbolName] = symbolSpec.split("|", 2);
- // Check if the file exists in the manifest
if (!dependencyManifest[filePath]) {
console.warn(`⚠️ File not found in manifest: ${filePath}`);
console.warn(
@@ -136,16 +165,12 @@ async function handler(
);
}
- // Get existing entry or create new one
const existingEntry = symbolsToExtract.get(filePath) || {
filePath,
symbols: new Set(),
};
- // Add the symbol to the set
existingEntry.symbols.add(symbolName);
-
- // Set/update the entry
symbolsToExtract.set(filePath, existingEntry);
}
@@ -163,7 +188,7 @@ async function handler(
includes: napiConfig.project.include,
excludes: napiConfig.project.exclude,
extensions: fileExtensions,
- logMessages: false, // We'll handle our own logging
+ logMessages: false,
});
console.info(`📁 Found ${files.size} files to process`);
@@ -204,7 +229,7 @@ async function handler(
" • Check your symbol specifications (format: file|symbol)",
);
console.error(
- " • Ensure the manifest is up to date: napi manifest generate",
+ " • Ensure the manifest is up to date: napi generate",
);
console.error(
" • Verify the specified files contain the requested symbols",
diff --git a/src/cli/handlers/manifest/generate.ts b/src/cli/handlers/generate/index.ts
similarity index 65%
rename from src/cli/handlers/manifest/generate.ts
rename to src/cli/handlers/generate/index.ts
index a754e6b7..2a4ac9a2 100644
--- a/src/cli/handlers/manifest/generate.ts
+++ b/src/cli/handlers/generate/index.ts
@@ -11,12 +11,11 @@ import {
generateDependencyManifest,
} from "../../../manifest/dependencyManifest/index.ts";
import type { z } from "zod";
-import { ApiService } from "../../../apiService/index.ts";
-import {
- defaultApiHost,
- type globalConfigSchema,
-} from "../../middlewares/globalConfig.ts";
-import { isAuthenticatedMiddleware } from "../../middlewares/isAuthenticated.ts";
+import type { globalConfigSchema } from "../../middlewares/globalConfig.ts";
+import { join } from "@std/path";
+
+const NAPI_DIR = ".napi";
+const MANIFESTS_DIR = "manifests";
function builder(
yargs: Arguments & {
@@ -25,7 +24,6 @@ function builder(
) {
return yargs
.middleware(napiConfigMiddleware)
- .middleware(isAuthenticatedMiddleware)
.option("branch", {
type: "string",
description: "The branch to use for the manifest",
@@ -36,7 +34,6 @@ function builder(
type: "string",
description: "The commit SHA date to use for the manifest",
coerce: (value: string) => {
- // Validate date format
const date = new Date(value);
if (isNaN(date.getTime())) {
throw new Error(
@@ -51,9 +48,6 @@ function builder(
});
}
-/**
- * Get the current Git branch name
- */
async function getGitBranch(workDir: string): Promise {
try {
const command = new Deno.Command("git", {
@@ -67,18 +61,15 @@ async function getGitBranch(workDir: string): Promise {
if (code === 0) {
const branch = new TextDecoder().decode(stdout).trim();
- return branch || "main"; // fallback to 'main' if branch name is empty
+ return branch || "main";
}
- return "main"; // fallback branch name
+ return "main";
} catch {
- return "main"; // fallback if git command fails
+ return "main";
}
}
-/**
- * Get the current Git commit hash
- */
async function getGitCommitSha(workDir: string): Promise {
try {
const command = new Deno.Command("git", {
@@ -94,15 +85,12 @@ async function getGitCommitSha(workDir: string): Promise {
return new TextDecoder().decode(stdout).trim();
}
- return ""; // return empty string if no commit found
+ return "";
} catch {
- return ""; // return empty string if git command fails
+ return "";
}
}
-/**
- * Get the current Git commit date in ISO format
- */
async function getGitCommitDate(workDir: string): Promise {
try {
const command = new Deno.Command("git", {
@@ -118,10 +106,22 @@ async function getGitCommitDate(workDir: string): Promise {
return new TextDecoder().decode(stdout).trim();
}
- return new Date().toISOString(); // fallback to current date
+ return new Date().toISOString();
} catch {
- return new Date().toISOString(); // fallback to current date if git command fails
+ return new Date().toISOString();
+ }
+}
+
+function ensureManifestsDir(workdir: string): string {
+ const manifestsDir = join(workdir, NAPI_DIR, MANIFESTS_DIR);
+ try {
+ Deno.mkdirSync(manifestsDir, { recursive: true });
+ } catch (error) {
+ if (!(error instanceof Deno.errors.AlreadyExists)) {
+ throw error;
+ }
}
+ return manifestsDir;
}
async function handler(
@@ -141,7 +141,6 @@ async function handler(
let commitSha: string;
let commitShaDate: string;
- // Handle branch
if (argv.branch) {
branch = argv.branch;
console.info(`🌿 Using provided branch: ${branch}`);
@@ -156,7 +155,6 @@ async function handler(
console.info(`🌿 Branch: ${branch}`);
}
- // Handle commit SHA
if (argv.commitSha) {
commitSha = argv.commitSha;
console.info(`📝 Using provided commit: ${commitSha.substring(0, 8)}...`);
@@ -173,7 +171,6 @@ async function handler(
}
}
- // Handle commit SHA date
if (argv.commitShaDate) {
commitShaDate = argv.commitShaDate;
console.info(`📅 Using provided commit date: ${commitShaDate}`);
@@ -228,71 +225,37 @@ async function handler(
argv.labelingApiKey,
);
- // Upload manifest to API instead of writing to disk
- const apiService = new ApiService(
- globalConfig,
+ const manifestsDir = ensureManifestsDir(argv.workdir);
+
+ const timestamp = Date.now();
+ const shortSha = commitSha ? commitSha.substring(0, 7) : "unknown";
+ const manifestId = `${timestamp}-${shortSha}`;
+ const manifestFileName = `${manifestId}.json`;
+
+ const manifestEnvelope = {
+ id: manifestId,
+ branch,
+ commitSha,
+ commitShaDate,
+ createdAt: new Date().toISOString(),
+ manifest: dependencyManifest,
+ };
+
+ const manifestPath = join(manifestsDir, manifestFileName);
+ Deno.writeTextFileSync(
+ manifestPath,
+ JSON.stringify(manifestEnvelope, null, 2),
);
- for (const projectId of napiConfig.projectIds) {
- try {
- const response = await apiService.performRequest(
- "POST",
- "/manifests",
- {
- projectId,
- branch,
- commitSha,
- commitShaDate,
- manifest: dependencyManifest,
- },
- );
- if (response.status !== 201) {
- console.error(
- `❌ Failed to upload manifest to API for project id: ${projectId}`,
- );
- const responseBody = await response.json();
- if (responseBody.error) {
- if (responseBody.error.includes("access_disabled")) {
- console.error(
- "\n💳 Your workspace has been disabled you need to add or change your payment method to continue uploading manifests",
- );
- console.error(
- "Go to your workspace settings and add or update a payment method: https://app.nanoapi.io",
- );
- } else {
- console.error(` Error: ${responseBody.error}`);
- }
- } else {
- console.error(` Status: ${response.status}`);
- }
- Deno.exit(1);
- }
-
- const responseBody = await response.json() as { id: number };
-
- const duration = Date.now() - start;
- console.info(
- `✅ Manifest uploaded successfully for project id: ${projectId} in ${duration}ms`,
- );
- console.info(`📄 Generated manifest contains:`);
- console.info(` • ${Object.keys(dependencyManifest).length} files`);
- console.info(` • Dependencies and relationships mapped`);
-
- if (globalConfig.apiHost === defaultApiHost) {
- console.info(
- `\nView it here: https://app.nanoapi.io/projects/${projectId}/manifests/${responseBody.id}`,
- );
- }
- } catch (error) {
- console.error(
- `❌ Failed to upload manifest to API for project id: ${projectId}`,
- );
- console.error(
- ` Error: ${error instanceof Error ? error.message : String(error)}`,
- );
- Deno.exit(1);
- }
- }
+ const duration = Date.now() - start;
+ console.info(
+ `✅ Manifest saved successfully in ${duration}ms`,
+ );
+ console.info(`📄 Generated manifest contains:`);
+ console.info(` • ${Object.keys(dependencyManifest).length} files`);
+ console.info(` • Dependencies and relationships mapped`);
+ console.info(`\n💾 Saved to: ${manifestPath}`);
+ console.info(`🔍 View it with: napi view`);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
@@ -302,7 +265,6 @@ async function handler(
console.error("💡 Common solutions:");
console.error(" • Check that your project files are accessible");
console.error(" • Verify your .napirc configuration");
- console.error(" • Ensure you're logged in (run 'napi login')");
Deno.exit(1);
}
@@ -310,7 +272,7 @@ async function handler(
export default {
command: "generate",
- describe: "generate a manifest for your program",
+ describe: "generate a dependency manifest for your program",
builder,
handler,
};
diff --git a/src/cli/handlers/init/index.ts b/src/cli/handlers/init/index.ts
index 5844e1ef..f70073df 100644
--- a/src/cli/handlers/init/index.ts
+++ b/src/cli/handlers/init/index.ts
@@ -4,7 +4,7 @@ import {
getConfigFromWorkDir,
} from "../../middlewares/napiConfig.ts";
import { join, normalize, relative, SEPARATOR } from "@std/path";
-import z from "zod";
+import type z from "zod";
import type { localConfigSchema } from "../../middlewares/napiConfig.ts";
import pythonStdlibList from "../../../scripts/generate_python_stdlib_list/output.json" with {
type: "json",
@@ -17,22 +17,20 @@ import {
javaLanguage,
pythonLanguage,
} from "../../../helpers/treeSitter/parsers.ts";
-import { ApiService } from "../../../apiService/index.ts";
import type { globalConfigSchema } from "../../middlewares/globalConfig.ts";
-import { isAuthenticatedMiddleware } from "../../middlewares/isAuthenticated.ts";
import {
ANTHROPIC_PROVIDER,
GOOGLE_PROVIDER,
OPENAI_PROVIDER,
} from "../../../manifest/dependencyManifest/labeling/model.ts";
+import { defaultAuditConfig } from "../../../manifest/auditManifest/types.ts";
function builder(
yargs: Arguments & {
globalConfig: z.infer;
},
) {
- return yargs
- .middleware(isAuthenticatedMiddleware);
+ return yargs;
}
async function handler(
@@ -40,10 +38,7 @@ async function handler(
globalConfig: z.infer;
},
) {
- const globalConfig = argv.globalConfig as z.infer;
-
try {
- // Check if config already exists
try {
if (getConfigFromWorkDir(argv.workdir)) {
const confirmOverwrite = await confirm({
@@ -58,7 +53,6 @@ async function handler(
console.info("🔄 Proceeding with configuration overwrite");
}
} catch {
- // Config doesn't exist, continue with initialization
console.info(
"📝 No existing valid configuration found, creating new one",
);
@@ -66,13 +60,8 @@ async function handler(
console.info("\n🔧 Starting interactive configuration...");
- // Generate the config using the interactive prompts
- const napiConfig = await generateConfig(
- argv.workdir,
- globalConfig,
- );
+ const napiConfig = await generateConfig(argv.workdir);
- // Confirm and show the config
console.info("\n📋 Generated configuration:");
console.info("─".repeat(50));
console.info(JSON.stringify(napiConfig, null, 2));
@@ -88,6 +77,9 @@ async function handler(
console.info("\n✅ Configuration saved successfully!");
console.info(`📄 Created: ${argv.workdir}${SEPARATOR}.napirc`);
console.info("🎉 Your NanoAPI project is ready!");
+ console.info("\n💡 Next steps:");
+ console.info(" 1. Run: napi generate");
+ console.info(" 2. Run: napi view");
} else {
console.info("❌ Configuration not saved");
console.info(" Run 'napi init' again when you're ready to configure");
@@ -114,16 +106,12 @@ export default {
handler,
};
-/**
- * Shows files that match a given glob pattern
- */
function showMatchingFiles(
workDir: string,
pattern: string,
maxFilesToShow = 10,
) {
try {
- // Find matching files using glob
const files = globSync(pattern, {
cwd: workDir,
nodir: true,
@@ -148,9 +136,6 @@ function showMatchingFiles(
}
}
-/**
- * Shows the final set of files that will be included after applying include and exclude patterns
- */
function showFinalFileSelection(
workDir: string,
includePatterns: string[],
@@ -158,14 +143,12 @@ function showFinalFileSelection(
maxFilesToShow = 20,
) {
try {
- // Get all included files
const files = globSync(includePatterns, {
cwd: workDir,
nodir: true,
- ignore: excludePatterns, // Default ignores
+ ignore: excludePatterns,
});
- // Display results
console.info("\n🔍 FINAL FILE SELECTION");
console.info(
`After applying all patterns, ${files.length} files will be processed:`,
@@ -188,9 +171,6 @@ function showFinalFileSelection(
}
}
-/**
- * Get an overview of the project structure
- */
function getProjectStructureOverview(workDir: string): string[] {
const overview: string[] = [];
@@ -215,7 +195,6 @@ function getProjectStructureOverview(workDir: string): string[] {
traverseDirectory(fullPath, depth + 1);
}
} catch (error) {
- // Skip entries we can't access
console.info(`Could not access ${fullPath}: ${error}`);
}
}
@@ -229,14 +208,10 @@ function getProjectStructureOverview(workDir: string): string[] {
return overview;
}
-/**
- * Utility function to collect glob patterns for include patterns
- */
async function collectIncludePatterns(
workDir: string,
language: string,
): Promise {
- // Show project structure overview
const projectStructure = getProjectStructureOverview(workDir);
console.info(
@@ -250,18 +225,15 @@ Examples:
`,
);
- // Suggest intelligent defaults based on project structure
const suggestedIncludes = suggestIncludePatterns(projectStructure, language);
console.info("\nSuggested include patterns (based on project structure):");
suggestedIncludes.forEach((pattern) => console.info(`- ${pattern}`));
- // Show preview of files that would be included with suggested patterns
console.info("\nPreview of files that would be included:");
for (const pattern of suggestedIncludes) {
showMatchingFiles(workDir, pattern);
}
- // Prompt user to use suggested includes or customize
const useSuggested = await confirm({
message: "Do you want to use the suggested include patterns?",
default: true,
@@ -276,7 +248,6 @@ Examples:
"Please enter the glob patterns for files to include (one per line):",
);
- // Start with empty pattern list
let includePatterns: string[] = [];
let continueAdding = true;
let validSelection = false;
@@ -292,10 +263,8 @@ Examples:
validate: (value) => {
if (!value.trim()) return "Pattern cannot be empty";
try {
- // Basic validation - a more thorough validation could use a glob validation library
new RegExp(value.replace(/\*\*/g, "*").replace(/\*/g, ".*"));
- // Show preview of files that match this pattern
console.info(`\nPreviewing files matching '${value}':`);
const files = globSync(value, {
cwd: workDir,
@@ -338,10 +307,8 @@ Examples:
console.info("\nSelected include patterns:");
includePatterns.forEach((pattern) => console.info(`- ${pattern}`));
- // Show a preview of all files that match the patterns collectively
showFinalFileSelection(workDir, includePatterns, []);
- // Ask user to validate the selection
validSelection = await confirm({
message: "Are you satisfied with this file selection?",
default: true,
@@ -355,9 +322,6 @@ Examples:
return includePatterns;
}
-/**
- * Utility function to collect glob patterns for exclude patterns
- */
async function collectExcludePatterns(
workDir: string,
includePatterns: string[],
@@ -376,7 +340,6 @@ Examples:
`,
);
- // Suggest intelligent defaults based on include patterns
const suggestedExcludes = suggestExcludePatterns(
includePatterns,
language,
@@ -385,13 +348,11 @@ Examples:
console.info("\nSuggested exclude patterns (based on included files):");
suggestedExcludes.forEach((pattern) => console.info(`- ${pattern}`));
- // Show preview of files that would be excluded with suggested patterns
console.info("\nPreview of files that would be excluded:");
for (const pattern of suggestedExcludes) {
showMatchingFiles(workDir, pattern);
}
- // Prompt user to use suggested excludes or customize
const useSuggested = await confirm({
message: "Do you want to use the suggested exclude patterns?",
default: true,
@@ -406,7 +367,6 @@ Examples:
"Please enter the glob patterns for files to exclude (one per line):",
);
- // Start with empty pattern list
let excludePatterns: string[] = [];
let continueAdding = true;
let validSelection = false;
@@ -422,10 +382,8 @@ Examples:
validate: (value) => {
if (!value.trim()) return "Pattern cannot be empty";
try {
- // Basic validation - a more thorough validation could use a glob validation library
new RegExp(value.replace(/\*\*/g, "*").replace(/\*/g, ".*"));
- // Show preview of files that match this pattern
console.info(`\nPreviewing files matching '${value}':`);
const files = globSync(value, {
cwd: workDir,
@@ -436,7 +394,6 @@ Examples:
console.info(
`Note: No files currently match the pattern '${value}'`,
);
- // Allow empty matches for exclude patterns as they may be preventative
return true;
}
@@ -467,10 +424,8 @@ Examples:
console.info("\nSelected exclude patterns:");
excludePatterns.forEach((pattern) => console.info(`- ${pattern}`));
- // Show final file selection after applying include and exclude patterns
showFinalFileSelection(workDir, includePatterns, excludePatterns);
- // Ask user to validate the selection
validSelection = await confirm({
message: "Are you satisfied with this file selection?",
default: true,
@@ -484,18 +439,13 @@ Examples:
return excludePatterns;
}
-/**
- * Suggest include patterns based on project structure and language
- */
function suggestIncludePatterns(
projectStructure: string[],
language: string,
): string[] {
const suggestions: string[] = [];
- // Language-specific suggestions
if (language === pythonLanguage) {
- // Check for common Python project structures
if (
projectStructure.some((entry) => entry.includes(`📂 src${SEPARATOR}`))
) {
@@ -513,13 +463,10 @@ function suggestIncludePatterns(
suggestions.push(`app${SEPARATOR}**${SEPARATOR}*.py`);
}
}
-
- // If no specific directories found, suggest all Python files
if (suggestions.length === 0) {
suggestions.push(`**${SEPARATOR}*.py`);
}
} else if (language === csharpLanguage) {
- // Check for common C# project structures
if (
projectStructure.some((entry) => entry.includes(`📂 src${SEPARATOR}`))
) {
@@ -553,13 +500,10 @@ function suggestIncludePatterns(
suggestions.push(`Services${SEPARATOR}**${SEPARATOR}*.cs`);
}
}
-
- // If no specific directories found, suggest all C# files
if (suggestions.length === 0) {
suggestions.push(`**${SEPARATOR}*.cs`);
}
} else if (language === cLanguage) {
- // Check for common C project structures
if (
projectStructure.some((entry) => entry.includes(`📂 src${SEPARATOR}`))
) {
@@ -587,7 +531,6 @@ function suggestIncludePatterns(
suggestions.push(`**${SEPARATOR}*.h`);
}
} else if (language === javaLanguage) {
- // Check for common Java project structures
if (
projectStructure.some((entry) => entry.includes(`📂 src${SEPARATOR}`))
) {
@@ -635,9 +578,6 @@ function suggestIncludePatterns(
return suggestions;
}
-/**
- * Suggest exclude patterns based on include patterns and language
- */
function suggestExcludePatterns(
_includePatterns: string[],
language: string,
@@ -645,15 +585,12 @@ function suggestExcludePatterns(
): string[] {
const suggestions: string[] = [];
- // add outDir to the suggestions
suggestions.push(`${outDir}${SEPARATOR}**`);
-
- // Common exclusions for all languages
+ suggestions.push(`.napi${SEPARATOR}**`);
suggestions.push(`.git${SEPARATOR}**`);
suggestions.push(`**${SEPARATOR}dist${SEPARATOR}**`);
suggestions.push(`**${SEPARATOR}build${SEPARATOR}**`);
- // Language-specific suggestions
if (language === pythonLanguage) {
suggestions.push(`**${SEPARATOR}__pycache__${SEPARATOR}**`);
suggestions.push(`**${SEPARATOR}*.pyc`);
@@ -675,7 +612,6 @@ function suggestExcludePatterns(
suggestions.push(`**${SEPARATOR}*.suo`);
suggestions.push(`**${SEPARATOR}.nuget${SEPARATOR}**`);
suggestions.push(`**${SEPARATOR}artifacts${SEPARATOR}**`);
- suggestions.push(`**${SEPARATOR}packages${SEPARATOR}**`);
} else if (language === javaLanguage) {
suggestions.push(`**${SEPARATOR}bin${SEPARATOR}**`);
suggestions.push(`**${SEPARATOR}obj${SEPARATOR}**`);
@@ -689,246 +625,9 @@ function suggestExcludePatterns(
return suggestions;
}
-/**
- * Create a new project via the API
- */
-async function createNewProject(apiService: ApiService): Promise {
- // Workspace selection with dynamic search
- console.info("\n🏢 WORKSPACE SELECTION");
- const selectedWorkspaceId = await search({
- message: "Search for the workspace to create your project in:",
- source: async (term) => {
- try {
- const workspacesResponse = await apiService.performRequest(
- "GET",
- `/workspaces?search=${
- encodeURIComponent(term || "")
- }&page=1&limit=10`,
- );
-
- if (workspacesResponse.status !== 200) {
- return [];
- }
-
- const response = await workspacesResponse.json() as {
- results: Array<{ id: number; name: string }>;
- total: number;
- };
-
- if (response.results.length === 0) {
- return [
- {
- name: `No workspaces found matching "${term}"`,
- value: -1,
- disabled: true,
- },
- ];
- }
-
- return response.results.map((w) => ({
- name: w.name,
- value: w.id,
- }));
- } catch {
- return [
- {
- name: "Error fetching workspaces",
- value: -1,
- disabled: true,
- },
- ];
- }
- },
- });
-
- console.info(`✅ Selected workspace ID: ${selectedWorkspaceId}`);
-
- const projectName = await input({
- message: "Enter a name for your new project:",
- validate: (value) => {
- if (!value.trim()) return "Project name cannot be empty";
- if (value.length > 100) {
- return "Project name must be less than 100 characters";
- }
- return true;
- },
- });
-
- const projectRepoUrl = await input({
- message: "Enter the URL of your project repository:",
- validate: (value) => {
- if (!value.trim()) return "Project repository URL cannot be empty";
- const result = z.string().url().safeParse(value);
- if (!result.success) {
- return result.error.message;
- }
- return true;
- },
- });
-
- try {
- const createProjectResponse = await apiService.performRequest(
- "POST",
- "/projects",
- {
- name: projectName,
- repoUrl: projectRepoUrl,
- workspaceId: selectedWorkspaceId,
- maxCodeCharPerSymbol: 100,
- maxCodeCharPerFile: 1000,
- maxCharPerSymbol: 100,
- maxCharPerFile: 1000,
- maxCodeLinePerSymbol: 10,
- maxCodeLinePerFile: 100,
- maxLinePerSymbol: 10,
- maxLinePerFile: 100,
- maxDependencyPerSymbol: 10,
- maxDependencyPerFile: 100,
- maxDependentPerSymbol: 10,
- maxDependentPerFile: 100,
- maxCyclomaticComplexityPerSymbol: 10,
- maxCyclomaticComplexityPerFile: 100,
- },
- );
-
- if (createProjectResponse.status !== 201) {
- let errorMessage = "Unknown error";
- try {
- const responseBody = await createProjectResponse.json();
- if (responseBody.error) {
- errorMessage = responseBody.error;
- }
- } catch {
- errorMessage = `HTTP ${createProjectResponse.status}`;
- }
- console.error(`❌ Failed to create project: ${errorMessage}`);
- Deno.exit(1);
- }
-
- const newProject = await createProjectResponse.json() as {
- id: number;
- };
- console.info(`✅ Created new project: ${projectName}`);
- return newProject.id;
- } catch (error) {
- console.error("❌ Failed to create project");
- console.error(
- ` Error: ${error instanceof Error ? error.message : String(error)}`,
- );
- Deno.exit(1);
- }
-}
-
-/**
- * Generate a configuration object based on user input
- */
export async function generateConfig(
workDir: string,
- globalConfig: z.infer,
): Promise> {
- const apiService = new ApiService(globalConfig);
-
- // Project selection/creation
- console.info("\n🏗️ PROJECT SETUP");
- const projectChoice = await select({
- message:
- "Would you like to connect to an existing project or create a new one?",
- choices: [
- { name: "Create a new project", value: "create" },
- { name: "Chose to an existing project", value: "existing" },
- ],
- });
-
- let selectedProjectId: number;
-
- if (projectChoice === "existing") {
- // Fetch and select existing project with dynamic search
- try {
- const selectedProject = await search({
- message: "Search for your project:",
- source: async (term) => {
- if (!term || term.length < 1) {
- // Show recent/popular projects when no search term
- try {
- const projectsResponse = await apiService.performRequest(
- "GET",
- `/projects?page=1&limit=10`,
- );
-
- if (projectsResponse.status !== 200) {
- return [];
- }
-
- const response = await projectsResponse.json() as {
- results: Array<{ id: number; name: string }>;
- total: number;
- };
-
- return response.results.map((p) => ({
- name: p.name,
- value: p.id,
- }));
- } catch {
- return [];
- }
- }
-
- try {
- const projectsResponse = await apiService.performRequest(
- "GET",
- `/projects?search=${encodeURIComponent(term)}&page=1&limit=10`,
- );
-
- if (projectsResponse.status !== 200) {
- return [];
- }
-
- const response = await projectsResponse.json() as {
- results: Array<{ id: number; name: string }>;
- total: number;
- };
-
- if (response.results.length === 0) {
- return [
- {
- name: `No projects found matching "${term}"`,
- value: -1,
- disabled: true,
- },
- ];
- }
-
- return response.results.map((p) => ({
- name: p.name,
- value: p.id,
- }));
- } catch {
- return [
- {
- name: "Error fetching projects",
- value: -1,
- disabled: true,
- },
- ];
- }
- },
- });
-
- selectedProjectId = selectedProject;
- console.info(`✅ Selected project ID: ${selectedProject}`);
- } catch (error) {
- console.error("❌ Failed to search projects");
- console.error(
- ` Error: ${error instanceof Error ? error.message : String(error)}`,
- );
- Deno.exit(1);
- }
- } else {
- // Create new project
- selectedProjectId = await createNewProject(apiService);
- }
-
- // Language selection
const language = await select({
message: "Select the language of your project",
choices: [
@@ -939,7 +638,6 @@ export async function generateConfig(
],
});
- // Python-specific config (if Python is selected)
let pythonConfig: z.infer["python"] | undefined =
undefined;
if (language === pythonLanguage) {
@@ -959,7 +657,6 @@ export async function generateConfig(
}
}
- // C-specific config
let cConfig: z.infer["c"] | undefined = undefined;
if (language === cLanguage) {
const hasIncludeDirs = await confirm({
@@ -984,7 +681,6 @@ export async function generateConfig(
}
}
- // Output directory - must be a valid directory name within the project
const outDir = await input({
message: "Enter the output directory for NanoAPI artifacts",
default: "napi_out",
@@ -992,15 +688,12 @@ export async function generateConfig(
if (!value.trim()) return "Output directory cannot be empty";
try {
- // Check if the path is valid by attempting to normalize it
const normalizedPath = normalize(join(workDir, value));
- // Ensure the path is within the project directory (prevent directory traversal)
if (!normalizedPath.startsWith(normalize(workDir))) {
return "Output directory must be within the project directory";
}
- // Check if the directory exists but is a file
try {
const stat = Deno.statSync(normalizedPath);
if (stat && !stat.isDirectory) {
@@ -1022,10 +715,8 @@ export async function generateConfig(
console.info("\n🔍 ANALYZING PROJECT STRUCTURE...");
- // Collect include patterns
const includePatterns = await collectIncludePatterns(workDir, language);
- // Collect exclude patterns
const excludePatterns = await collectExcludePatterns(
workDir,
includePatterns,
@@ -1033,10 +724,8 @@ export async function generateConfig(
outDir,
);
- // Show final file selection to the user
showFinalFileSelection(workDir, includePatterns, excludePatterns);
- // Labeling configuration
console.info("\n🏷️ LABELING CONFIGURATION");
console.info(
"Labeling helps categorize and organize your code dependencies using AI models.",
@@ -1072,7 +761,7 @@ export async function generateConfig(
const maxConcurrency = await input({
message: "Enter maximum concurrent requests (leave empty for unlimited):",
validate: (value) => {
- if (!value.trim()) return true; // Allow empty for unlimited
+ if (!value.trim()) return true;
const num = parseInt(value);
if (isNaN(num) || num <= 0) {
return "Please enter a positive number or leave empty for unlimited";
@@ -1091,7 +780,116 @@ export async function generateConfig(
console.info("✅ Labeling configuration added");
}
- // Build the config object
+ console.info("\n📊 AUDIT THRESHOLDS");
+ console.info(
+ "Audit thresholds flag files and symbols that exceed size or complexity limits.",
+ );
+
+ const enableAudit = await confirm({
+ message: "Would you like to configure custom audit thresholds?",
+ default: false,
+ });
+
+ let auditConfig:
+ | z.infer["audit"]
+ | undefined = undefined;
+
+ if (enableAudit) {
+ console.info(
+ "\n📄 File-level thresholds (defaults shown, press Enter to keep):",
+ );
+
+ const fileMaxCodeLine = await input({
+ message:
+ `Max code lines per file [${defaultAuditConfig.file.maxCodeLine}]:`,
+ });
+ const fileMaxCodeChar = await input({
+ message:
+ `Max code characters per file [${defaultAuditConfig.file.maxCodeChar}]:`,
+ });
+ const fileMaxDependency = await input({
+ message:
+ `Max dependencies per file [${defaultAuditConfig.file.maxDependency}]:`,
+ });
+ const fileMaxDependent = await input({
+ message:
+ `Max dependents per file [${defaultAuditConfig.file.maxDependent}]:`,
+ });
+ const fileMaxCyclomaticComplexity = await input({
+ message:
+ `Max cyclomatic complexity per file [${defaultAuditConfig.file.maxCyclomaticComplexity}]:`,
+ });
+
+ console.info(
+ "\n🔤 Symbol-level thresholds (defaults shown, press Enter to keep):",
+ );
+
+ const symbolMaxCodeLine = await input({
+ message:
+ `Max code lines per symbol [${defaultAuditConfig.symbol.maxCodeLine}]:`,
+ });
+ const symbolMaxCodeChar = await input({
+ message:
+ `Max code characters per symbol [${defaultAuditConfig.symbol.maxCodeChar}]:`,
+ });
+ const symbolMaxDependency = await input({
+ message:
+ `Max dependencies per symbol [${defaultAuditConfig.symbol.maxDependency}]:`,
+ });
+ const symbolMaxDependent = await input({
+ message:
+ `Max dependents per symbol [${defaultAuditConfig.symbol.maxDependent}]:`,
+ });
+ const symbolMaxCyclomaticComplexity = await input({
+ message:
+ `Max cyclomatic complexity per symbol [${defaultAuditConfig.symbol.maxCyclomaticComplexity}]:`,
+ });
+
+ const parseOptionalInt = (val: string): number | undefined => {
+ const trimmed = val.trim();
+ if (!trimmed) return undefined;
+ const num = parseInt(trimmed, 10);
+ return isNaN(num) ? undefined : num;
+ };
+
+ const fileOverrides = {
+ maxCodeLine: parseOptionalInt(fileMaxCodeLine),
+ maxCodeChar: parseOptionalInt(fileMaxCodeChar),
+ maxDependency: parseOptionalInt(fileMaxDependency),
+ maxDependent: parseOptionalInt(fileMaxDependent),
+ maxCyclomaticComplexity: parseOptionalInt(fileMaxCyclomaticComplexity),
+ };
+
+ const symbolOverrides = {
+ maxCodeLine: parseOptionalInt(symbolMaxCodeLine),
+ maxCodeChar: parseOptionalInt(symbolMaxCodeChar),
+ maxDependency: parseOptionalInt(symbolMaxDependency),
+ maxDependent: parseOptionalInt(symbolMaxDependent),
+ maxCyclomaticComplexity: parseOptionalInt(symbolMaxCyclomaticComplexity),
+ };
+
+ const cleanObj = (obj: Record) => {
+ const result: Record = {};
+ for (const [k, v] of Object.entries(obj)) {
+ if (v !== undefined) result[k] = v;
+ }
+ return Object.keys(result).length > 0 ? result : undefined;
+ };
+
+ const fileClean = cleanObj(fileOverrides);
+ const symbolClean = cleanObj(symbolOverrides);
+
+ if (fileClean || symbolClean) {
+ auditConfig = {};
+ if (fileClean) auditConfig.file = fileClean as typeof auditConfig.file;
+ if (symbolClean) {
+ auditConfig.symbol = symbolClean as typeof auditConfig.symbol;
+ }
+ }
+
+ console.info("✅ Audit threshold configuration added");
+ }
+
const config: z.infer = {
language: language,
project: {
@@ -1099,23 +897,23 @@ export async function generateConfig(
exclude: excludePatterns.length > 0 ? excludePatterns : undefined,
},
outDir: outDir ? outDir : "napi_out",
- projectIds: [selectedProjectId],
};
- // Add python config if it exists
if (pythonConfig) {
config.python = pythonConfig;
}
- // Add C config if it exists
if (cConfig) {
config.c = cConfig;
}
- // Add labeling config if it exists
if (labelingConfig) {
config.labeling = labelingConfig;
}
+ if (auditConfig) {
+ config.audit = auditConfig;
+ }
+
return config;
}
diff --git a/src/cli/handlers/login/index.ts b/src/cli/handlers/login/index.ts
deleted file mode 100644
index 49c791fd..00000000
--- a/src/cli/handlers/login/index.ts
+++ /dev/null
@@ -1,141 +0,0 @@
-import type { Arguments } from "yargs-types";
-import {
- defaultApiHost,
- type globalConfigSchema,
- setConfig,
-} from "../../middlewares/globalConfig.ts";
-import z from "zod";
-import { input } from "@inquirer/prompts";
-import { ApiService } from "../../../apiService/index.ts";
-
-const builder = {
- "api-host": {
- type: "string" as const,
- describe: "API server URL to save to config",
- alias: "H",
- default: defaultApiHost,
- },
- "api-token": {
- type: "string" as const,
- describe:
- "API token to use for authentication. Can be generated here: https://app.nanoapi.io/profile",
- alias: "T",
- },
-};
-
-async function handler(
- argv: Arguments & {
- globalConfig: z.infer;
- } & {
- apiHost: string;
- apiToken: string | undefined;
- },
-) {
- const globalConfig = argv.globalConfig as z.infer;
- globalConfig.apiHost = argv.apiHost;
- globalConfig.token = argv.apiToken;
-
- if (globalConfig.token) {
- const apiService = new ApiService(
- globalConfig,
- );
- const response = await apiService.performRequest("GET", "/auth/me");
- if (response.status !== 200) {
- console.error("❌ Authentication failed");
- console.error(` Status: ${response.status}`);
- console.error(" Make sure the API token is valid");
- Deno.exit(1);
- }
-
- setConfig(globalConfig);
- console.info("🔑 You are already logged in using an API token");
- Deno.exit(0);
- }
-
- console.info(
- `🎫 You are about to login to NanoAPI (${globalConfig.apiHost})`,
- );
-
- const email = await input({
- message: "Enter your email:",
- validate: (value) => {
- const result = z.string().email().safeParse(value);
- if (!result.success) {
- return "Please enter a valid email address";
- }
- return true;
- },
- });
-
- const apiService = new ApiService(
- globalConfig,
- );
-
- const requestOtpResponse = await apiService.performRequest(
- "POST",
- "/auth/requestOtp",
- {
- email,
- },
- );
-
- if (requestOtpResponse.status !== 200) {
- let errorMessage = "Unknown error";
- const responseBody = await requestOtpResponse.json();
- if (responseBody.error) {
- errorMessage = responseBody.error;
- }
- console.error(`Failed to request OTP: ${errorMessage}`);
- Deno.exit(1);
- }
-
- console.info(`OTP sent to ${email}`);
-
- const otp = await input({
- message: "Enter the OTP you received:",
- validate: (value) => {
- const result = z.string().min(6).max(6).refine(
- (val) => !isNaN(Number(val)),
- ).safeParse(value);
- if (!result.success) {
- return "OTP must be exactly 6 digits";
- }
- return true;
- },
- });
-
- const verifyOtpResponse = await apiService.performRequest(
- "POST",
- "/auth/verifyOtp",
- {
- email,
- otp,
- },
- );
-
- if (verifyOtpResponse.status !== 200) {
- let errorMessage = "Unknown error";
- const responseBody = await verifyOtpResponse.json();
- if (responseBody.error) {
- errorMessage = responseBody.error;
- }
- console.error(`Failed to verify OTP: ${errorMessage}`);
- Deno.exit(1);
- }
-
- const responseBody = await verifyOtpResponse.json() as {
- token: string;
- };
-
- globalConfig.jwt = responseBody.token;
- setConfig(globalConfig);
-
- console.info(`🚀 You are now logged in as ${email}!`);
-}
-
-export default {
- command: "login",
- describe: "Login to NanoAPI",
- builder,
- handler,
-};
diff --git a/src/cli/handlers/manifest/index.ts b/src/cli/handlers/manifest/index.ts
deleted file mode 100644
index 994b259d..00000000
--- a/src/cli/handlers/manifest/index.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import generateHandler from "./generate.ts";
-import type { Arguments } from "yargs-types";
-import type { globalConfigSchema } from "../../middlewares/globalConfig.ts";
-import type { z } from "zod";
-
-function builder(
- yargs: Arguments & {
- globalConfig: z.infer;
- },
-) {
- return yargs
- .command(generateHandler)
- .demandCommand(1, "You need to specify a valid command");
-}
-
-export default {
- command: "manifest",
- describe: "generate a manifest for your program",
- builder,
- handler: () => {},
-};
diff --git a/src/cli/handlers/view/index.ts b/src/cli/handlers/view/index.ts
new file mode 100644
index 00000000..a9e8697f
--- /dev/null
+++ b/src/cli/handlers/view/index.ts
@@ -0,0 +1,284 @@
+import type { Arguments } from "yargs-types";
+import type { z } from "zod";
+import type { globalConfigSchema } from "../../middlewares/globalConfig.ts";
+import {
+ type localConfigSchema,
+ napiConfigMiddleware,
+} from "../../middlewares/napiConfig.ts";
+import { dirname, fromFileUrl, join } from "@std/path";
+import { generateAuditManifest } from "../../../manifest/auditManifest/index.ts";
+import {
+ type AuditConfig,
+ defaultAuditConfig,
+} from "../../../manifest/auditManifest/types.ts";
+import type { DependencyManifest } from "../../../manifest/dependencyManifest/types.ts";
+
+const NAPI_DIR = ".napi";
+const MANIFESTS_DIR = "manifests";
+
+interface ManifestEnvelope {
+ id: string;
+ branch: string;
+ commitSha: string;
+ commitShaDate: string;
+ createdAt: string;
+ manifest: DependencyManifest;
+}
+
+interface ManifestListItem {
+ id: string;
+ branch: string;
+ commitSha: string;
+ commitShaDate: string;
+ createdAt: string;
+ fileCount: number;
+}
+
+function getManifestsDir(workdir: string): string {
+ return join(workdir, NAPI_DIR, MANIFESTS_DIR);
+}
+
+function listManifests(workdir: string): ManifestListItem[] {
+ const manifestsDir = getManifestsDir(workdir);
+ const items: ManifestListItem[] = [];
+
+ try {
+ for (const entry of Deno.readDirSync(manifestsDir)) {
+ if (!entry.isFile || !entry.name.endsWith(".json")) continue;
+
+ try {
+ const content = Deno.readTextFileSync(join(manifestsDir, entry.name));
+ const envelope = JSON.parse(content) as ManifestEnvelope;
+ items.push({
+ id: envelope.id,
+ branch: envelope.branch,
+ commitSha: envelope.commitSha,
+ commitShaDate: envelope.commitShaDate,
+ createdAt: envelope.createdAt,
+ fileCount: Object.keys(envelope.manifest).length,
+ });
+ } catch {
+ // Skip malformed manifest files
+ }
+ }
+ } catch {
+ // Directory doesn't exist yet
+ }
+
+ items.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+ return items;
+}
+
+function loadManifest(
+ workdir: string,
+ manifestId: string,
+): ManifestEnvelope | null {
+ const manifestPath = join(
+ getManifestsDir(workdir),
+ `${manifestId}.json`,
+ );
+ try {
+ const content = Deno.readTextFileSync(manifestPath);
+ return JSON.parse(content) as ManifestEnvelope;
+ } catch {
+ return null;
+ }
+}
+
+function getViewerDistDir(): string {
+ const thisDir = dirname(fromFileUrl(import.meta.url));
+ return join(thisDir, "..", "..", "..", "..", "viewer", "dist");
+}
+
+function getContentType(path: string): string {
+ if (path.endsWith(".html")) return "text/html; charset=utf-8";
+ if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
+ if (path.endsWith(".css")) return "text/css; charset=utf-8";
+ if (path.endsWith(".json")) return "application/json; charset=utf-8";
+ if (path.endsWith(".svg")) return "image/svg+xml";
+ if (path.endsWith(".png")) return "image/png";
+ if (path.endsWith(".ico")) return "image/x-icon";
+ if (path.endsWith(".woff2")) return "font/woff2";
+ if (path.endsWith(".woff")) return "font/woff";
+ return "application/octet-stream";
+}
+
+async function tryServeStatic(
+ viewerDir: string,
+ pathname: string,
+): Promise {
+ let filePath = join(viewerDir, pathname);
+
+ try {
+ const stat = Deno.statSync(filePath);
+ if (stat.isDirectory) {
+ filePath = join(filePath, "index.html");
+ }
+ } catch {
+ // File doesn't exist
+ }
+
+ try {
+ const content = await Deno.readFile(filePath);
+ return new Response(content, {
+ headers: { "content-type": getContentType(filePath) },
+ });
+ } catch {
+ return null;
+ }
+}
+
+function jsonResponse(data: unknown, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: {
+ "content-type": "application/json; charset=utf-8",
+ "access-control-allow-origin": "*",
+ },
+ });
+}
+
+async function openBrowser(url: string) {
+ try {
+ let cmd: string[];
+ if (Deno.build.os === "darwin") {
+ cmd = ["open", url];
+ } else if (Deno.build.os === "windows") {
+ cmd = ["cmd", "/c", "start", url];
+ } else {
+ cmd = ["xdg-open", url];
+ }
+ const command = new Deno.Command(cmd[0], {
+ args: cmd.slice(1),
+ stdout: "null",
+ stderr: "null",
+ });
+ const child = command.spawn();
+ await child.status;
+ } catch {
+ // Silently fail if browser can't be opened
+ }
+}
+
+function findAvailablePort(startPort: number): number {
+ for (let port = startPort; port < startPort + 100; port++) {
+ try {
+ const listener = Deno.listen({ port });
+ listener.close();
+ return port;
+ } catch {
+ continue;
+ }
+ }
+ throw new Error("No available port found");
+}
+
+function mergeAuditConfig(
+ userAudit?: z.infer["audit"],
+): AuditConfig {
+ return {
+ file: {
+ ...defaultAuditConfig.file,
+ ...userAudit?.file,
+ },
+ symbol: {
+ ...defaultAuditConfig.symbol,
+ ...userAudit?.symbol,
+ },
+ };
+}
+
+function builder(
+ yargs: Arguments & {
+ globalConfig: z.infer;
+ },
+) {
+ return yargs
+ .middleware(napiConfigMiddleware)
+ .option("port", {
+ type: "number",
+ description: "Port to serve the viewer on",
+ default: 3000,
+ });
+}
+
+async function handler(
+ argv: Arguments & {
+ globalConfig: z.infer;
+ napiConfig: z.infer;
+ } & {
+ port: number;
+ },
+) {
+ const workdir = argv.workdir as string;
+ const viewerDir = getViewerDistDir();
+ const auditConfig = mergeAuditConfig(argv.napiConfig?.audit);
+
+ const port = findAvailablePort(argv.port);
+
+ const handler = async (request: Request): Promise => {
+ const url = new URL(request.url);
+ const pathname = url.pathname;
+
+ if (pathname === "/api/manifests" && request.method === "GET") {
+ const manifests = listManifests(workdir);
+ return jsonResponse(manifests);
+ }
+
+ const manifestDetailMatch = pathname.match(
+ /^\/api\/manifests\/([^/]+)$/,
+ );
+ if (manifestDetailMatch && request.method === "GET") {
+ const manifestId = manifestDetailMatch[1];
+ const envelope = loadManifest(workdir, manifestId);
+ if (!envelope) {
+ return jsonResponse({ error: "Manifest not found" }, 404);
+ }
+ return jsonResponse(envelope);
+ }
+
+ const auditMatch = pathname.match(
+ /^\/api\/manifests\/([^/]+)\/audit$/,
+ );
+ if (auditMatch && request.method === "GET") {
+ const manifestId = auditMatch[1];
+ const envelope = loadManifest(workdir, manifestId);
+ if (!envelope) {
+ return jsonResponse({ error: "Manifest not found" }, 404);
+ }
+ const auditManifest = generateAuditManifest(
+ envelope.manifest,
+ auditConfig,
+ );
+ return jsonResponse(auditManifest);
+ }
+
+ // Serve static files from viewer dist
+ const staticResponse = await tryServeStatic(viewerDir, pathname);
+ if (staticResponse) return staticResponse;
+
+ // SPA fallback: serve index.html for any unmatched route
+ const indexResponse = await tryServeStatic(viewerDir, "/index.html");
+ if (indexResponse) return indexResponse;
+
+ return new Response("Not Found", { status: 404 });
+ };
+
+ console.info(`🚀 Starting napi viewer...`);
+ console.info(
+ `📂 Serving manifests from: ${join(workdir, NAPI_DIR, MANIFESTS_DIR)}`,
+ );
+ console.info(`🌐 Open: http://localhost:${port}`);
+ console.info(`\nPress Ctrl+C to stop.\n`);
+
+ await openBrowser(`http://localhost:${port}`);
+
+ Deno.serve({ port }, handler);
+}
+
+export default {
+ command: "view",
+ describe: "open the dependency visualizer in your browser",
+ builder,
+ handler,
+};
diff --git a/src/cli/index.ts b/src/cli/index.ts
index d8dfbfae..5c5a1269 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -3,10 +3,10 @@ import {
checkVersionMiddleware,
getCurrentVersion,
} from "./middlewares/checkVersion.ts";
-import loginCommand from "./handlers/login/index.ts";
import initCommand from "./handlers/init/index.ts";
import setCommand from "./handlers/set/index.ts";
-import manifestCommand from "./handlers/manifest/index.ts";
+import generateCommand from "./handlers/generate/index.ts";
+import viewCommand from "./handlers/view/index.ts";
import extractCommand from "./handlers/extract/index.ts";
import { globalConfigMiddleware } from "./middlewares/globalConfig.ts";
@@ -26,10 +26,10 @@ export function initCli() {
.options(globalOptions)
.middleware(checkVersionMiddleware)
.middleware(globalConfigMiddleware)
- .command(loginCommand)
.command(initCommand)
.command(setCommand)
- .command(manifestCommand)
+ .command(generateCommand)
+ .command(viewCommand)
.command(extractCommand)
.demandCommand(1, "You need to specify a command")
.strict()
diff --git a/src/cli/middlewares/globalConfig.ts b/src/cli/middlewares/globalConfig.ts
index f8a64c9d..d6e980b1 100644
--- a/src/cli/middlewares/globalConfig.ts
+++ b/src/cli/middlewares/globalConfig.ts
@@ -3,9 +3,6 @@ import { dirname, join } from "@std/path";
import z from "zod";
export const globalConfigSchema = z.object({
- jwt: z.string().optional(),
- token: z.string().optional(),
- apiHost: z.string(),
labeling: z.object({
apiKeys: z.object({
google: z.string().optional(),
@@ -15,11 +12,7 @@ export const globalConfigSchema = z.object({
}).optional(),
});
-export const defaultApiHost = "https://api.nanoapi.io";
-
-const defaultConfig: z.infer = {
- apiHost: defaultApiHost,
-};
+const defaultConfig: z.infer = {};
function getConfigPath() {
const appName = "napi";
diff --git a/src/cli/middlewares/isAuthenticated.ts b/src/cli/middlewares/isAuthenticated.ts
deleted file mode 100644
index 3cd0c63d..00000000
--- a/src/cli/middlewares/isAuthenticated.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import type { Arguments } from "yargs-types";
-import type { z } from "zod";
-import type { globalConfigSchema } from "./globalConfig.ts";
-import { ApiService } from "../../apiService/index.ts";
-
-export async function isAuthenticatedMiddleware(
- args: Arguments & {
- globalConfig: z.infer;
- },
-) {
- const globalConfig = args.globalConfig as z.infer;
-
- if (!globalConfig.jwt) {
- console.error("❌ Not logged in");
- console.error(" Please login first using: napi login");
- Deno.exit(1);
- }
-
- try {
- const apiService = new ApiService(
- globalConfig,
- );
- const response = await apiService.performRequest("GET", "/auth/me");
-
- if (response.status === 401) {
- console.error("❌ Authentication expired");
- console.error(" Please login again using: napi login");
- Deno.exit(1);
- }
-
- if (response.status !== 200) {
- console.error("❌ Authentication check failed");
- console.error(` Status: ${response.status}`);
- console.error(" Please try logging in again using: napi login");
- Deno.exit(1);
- }
-
- console.info("✅ Authentication verified");
- } catch (error) {
- console.error("❌ Failed to verify authentication");
- console.error(
- ` Error: ${error instanceof Error ? error.message : String(error)}`,
- );
- console.error(" Please check your connection and try logging in again");
- Deno.exit(1);
- }
-}
diff --git a/src/cli/middlewares/napiConfig.ts b/src/cli/middlewares/napiConfig.ts
index 7192139c..e6abf0f6 100644
--- a/src/cli/middlewares/napiConfig.ts
+++ b/src/cli/middlewares/napiConfig.ts
@@ -19,7 +19,6 @@ import {
const pythonVersions = Object.keys(pythonStdlibList);
export const localConfigSchema = z.object({
- projectIds: z.array(z.number().int()),
language: z.enum([pythonLanguage, csharpLanguage, cLanguage, javaLanguage]),
[pythonLanguage]: z
.object({
@@ -51,6 +50,26 @@ export const localConfigSchema = z.object({
]),
maxConcurrency: z.number().optional(),
}).optional(),
+ audit: z.object({
+ file: z.object({
+ maxCodeChar: z.number().optional(),
+ maxChar: z.number().optional(),
+ maxCodeLine: z.number().optional(),
+ maxLine: z.number().optional(),
+ maxDependency: z.number().optional(),
+ maxDependent: z.number().optional(),
+ maxCyclomaticComplexity: z.number().optional(),
+ }).optional(),
+ symbol: z.object({
+ maxCodeChar: z.number().optional(),
+ maxChar: z.number().optional(),
+ maxCodeLine: z.number().optional(),
+ maxLine: z.number().optional(),
+ maxDependency: z.number().optional(),
+ maxDependent: z.number().optional(),
+ maxCyclomaticComplexity: z.number().optional(),
+ }).optional(),
+ }).optional(),
});
const napiConfigFileName = ".napirc";
diff --git a/src/helpers/treeSitter/parsers.ts b/src/helpers/treeSitter/parsers.ts
index bc04dfb3..5d267a36 100644
--- a/src/helpers/treeSitter/parsers.ts
+++ b/src/helpers/treeSitter/parsers.ts
@@ -1,8 +1,8 @@
import Parser, { type Language } from "tree-sitter";
-import Python from "npm:tree-sitter-python";
-import CSharp from "npm:tree-sitter-c-sharp";
+import Python from "tree-sitter-python";
+import CSharp from "tree-sitter-c-sharp";
import C from "tree-sitter-c";
-import Java from "npm:tree-sitter-java";
+import Java from "tree-sitter-java";
const pythonParser = new Parser();
pythonParser.setLanguage(Python as Language);
diff --git a/src/languagePlugins/c/testFiles/index.ts b/src/languagePlugins/c/testFiles/index.ts
index 89254685..5aa4a0cf 100644
--- a/src/languagePlugins/c/testFiles/index.ts
+++ b/src/languagePlugins/c/testFiles/index.ts
@@ -66,7 +66,6 @@ export function getCFilesContentMap(): Map<
}
export const dummyLocalConfig = {
- projectIds: [1],
language: cLanguage,
project: {
include: [],
diff --git a/src/languagePlugins/java/dependencyFormatting/index.ts b/src/languagePlugins/java/dependencyFormatting/index.ts
index 5c4645de..3d601a78 100644
--- a/src/languagePlugins/java/dependencyFormatting/index.ts
+++ b/src/languagePlugins/java/dependencyFormatting/index.ts
@@ -1,4 +1,4 @@
-import type Parser from "npm:tree-sitter";
+import type Parser from "tree-sitter";
import { javaParser } from "../../../helpers/treeSitter/parsers.ts";
import { JavaImportResolver } from "../importResolver/index.ts";
import { JavaInvocationResolver } from "../invocationResolver/index.ts";
diff --git a/src/languagePlugins/java/dependencyFormatting/types.ts b/src/languagePlugins/java/dependencyFormatting/types.ts
index 990c3406..086bb1f7 100644
--- a/src/languagePlugins/java/dependencyFormatting/types.ts
+++ b/src/languagePlugins/java/dependencyFormatting/types.ts
@@ -1,5 +1,5 @@
import type { SymbolType } from "../packageResolver/types.ts";
-import type Parser from "npm:tree-sitter";
+import type Parser from "tree-sitter";
/**
* Represents a dependency in a Java file
diff --git a/src/languagePlugins/java/invocationResolver/queries.ts b/src/languagePlugins/java/invocationResolver/queries.ts
index 8d0e0be5..cdffd271 100644
--- a/src/languagePlugins/java/invocationResolver/queries.ts
+++ b/src/languagePlugins/java/invocationResolver/queries.ts
@@ -1,4 +1,4 @@
-import Parser from "npm:tree-sitter";
+import Parser from "tree-sitter";
import { javaParser } from "../../../helpers/treeSitter/parsers.ts";
/**
diff --git a/src/languagePlugins/java/packageResolver/queries.ts b/src/languagePlugins/java/packageResolver/queries.ts
index 0fa00252..f6808744 100644
--- a/src/languagePlugins/java/packageResolver/queries.ts
+++ b/src/languagePlugins/java/packageResolver/queries.ts
@@ -1,4 +1,4 @@
-import Parser from "npm:tree-sitter";
+import Parser from "tree-sitter";
import { javaParser } from "../../../helpers/treeSitter/parsers.ts";
/**
diff --git a/src/languagePlugins/python/symbolExtractor/index.test.ts b/src/languagePlugins/python/symbolExtractor/index.test.ts
index 25ac3276..1b3cc04c 100644
--- a/src/languagePlugins/python/symbolExtractor/index.test.ts
+++ b/src/languagePlugins/python/symbolExtractor/index.test.ts
@@ -41,7 +41,6 @@ describe("PythonSymbolExtractor", () => {
files: Map,
): DependencyManifest {
const dependencyManifest = generatePythonDependencyManifest(files, {
- projectIds: [1],
language: pythonLanguage,
python: {
version: "3.10",
diff --git a/src/manifest/auditManifest/index.ts b/src/manifest/auditManifest/index.ts
new file mode 100644
index 00000000..893baa68
--- /dev/null
+++ b/src/manifest/auditManifest/index.ts
@@ -0,0 +1,257 @@
+import type { DependencyManifest } from "../dependencyManifest/types.ts";
+import {
+ metricCharacterCount,
+ metricCodeCharacterCount,
+ metricCodeLineCount,
+ metricCyclomaticComplexity,
+ metricDependencyCount,
+ metricDependentCount,
+ metricLinesCount,
+} from "../dependencyManifest/types.ts";
+import type {
+ AuditConfig,
+ AuditManifest,
+ FileAuditManifest,
+ SymbolAuditManifest,
+} from "./types.ts";
+
+function getSeverityLevel(
+ value: number,
+ targetValue = 0,
+): 1 | 2 | 3 | 4 | 5 {
+ if (value > targetValue * 10) {
+ return 5;
+ } else if (value > targetValue * 5) {
+ return 4;
+ } else if (value > targetValue * 2) {
+ return 3;
+ } else if (value > targetValue * 1.5) {
+ return 2;
+ } else {
+ return 1;
+ }
+}
+
+export function generateAuditManifest(
+ dependencyManifest: DependencyManifest,
+ config: AuditConfig,
+): AuditManifest {
+ const auditManifest: AuditManifest = {};
+
+ for (const fileDependencyManifest of Object.values(dependencyManifest)) {
+ const fileAudit: FileAuditManifest = {
+ id: fileDependencyManifest.id,
+ alerts: {},
+ symbols: {},
+ };
+
+ const fm = fileDependencyManifest.metrics;
+
+ if (fm.codeCharacterCount > config.file.maxCodeChar) {
+ fileAudit.alerts[metricCodeCharacterCount] = {
+ metric: metricCodeCharacterCount,
+ severity: getSeverityLevel(
+ fm.codeCharacterCount,
+ config.file.maxCodeChar,
+ ),
+ message: {
+ short: "File too large",
+ long:
+ `File exceeds maximum character limit (${fm.codeCharacterCount}/${config.file.maxCodeChar})`,
+ },
+ };
+ }
+
+ if (fm.characterCount > config.file.maxChar) {
+ fileAudit.alerts[metricCharacterCount] = {
+ metric: metricCharacterCount,
+ severity: getSeverityLevel(fm.characterCount, config.file.maxChar),
+ message: {
+ short: "File too large",
+ long:
+ `File exceeds maximum character limit (${fm.characterCount}/${config.file.maxChar})`,
+ },
+ };
+ }
+
+ if (fm.codeLineCount > config.file.maxCodeLine) {
+ fileAudit.alerts[metricCodeLineCount] = {
+ metric: metricCodeLineCount,
+ severity: getSeverityLevel(fm.codeLineCount, config.file.maxCodeLine),
+ message: {
+ short: "Too many lines",
+ long:
+ `File exceeds maximum line count (${fm.codeLineCount}/${config.file.maxCodeLine})`,
+ },
+ };
+ }
+
+ if (fm.linesCount > config.file.maxLine) {
+ fileAudit.alerts[metricLinesCount] = {
+ metric: metricLinesCount,
+ severity: getSeverityLevel(fm.linesCount, config.file.maxLine),
+ message: {
+ short: "Too many lines",
+ long:
+ `File exceeds maximum line count (${fm.linesCount}/${config.file.maxLine})`,
+ },
+ };
+ }
+
+ if (fm.dependencyCount > config.file.maxDependency) {
+ fileAudit.alerts[metricDependencyCount] = {
+ metric: metricDependencyCount,
+ severity: getSeverityLevel(
+ fm.dependencyCount,
+ config.file.maxDependency,
+ ),
+ message: {
+ short: "Too many dependencies",
+ long:
+ `File exceeds maximum dependency count (${fm.dependencyCount}/${config.file.maxDependency})`,
+ },
+ };
+ }
+
+ if (fm.dependentCount > config.file.maxDependent) {
+ fileAudit.alerts[metricDependentCount] = {
+ metric: metricDependentCount,
+ severity: getSeverityLevel(fm.dependentCount, config.file.maxDependent),
+ message: {
+ short: "Too many dependents",
+ long:
+ `File exceeds maximum dependent count (${fm.dependentCount}/${config.file.maxDependent})`,
+ },
+ };
+ }
+
+ if (fm.cyclomaticComplexity > config.file.maxCyclomaticComplexity) {
+ fileAudit.alerts[metricCyclomaticComplexity] = {
+ metric: metricCyclomaticComplexity,
+ severity: getSeverityLevel(
+ fm.cyclomaticComplexity,
+ config.file.maxCyclomaticComplexity,
+ ),
+ message: {
+ short: "Too complex",
+ long:
+ `File exceeds maximum cyclomatic complexity (${fm.cyclomaticComplexity}/${config.file.maxCyclomaticComplexity})`,
+ },
+ };
+ }
+
+ for (const symbol of Object.values(fileDependencyManifest.symbols)) {
+ const symbolAudit: SymbolAuditManifest = {
+ id: symbol.id,
+ alerts: {},
+ };
+
+ const sm = symbol.metrics;
+
+ if (sm.codeCharacterCount > config.symbol.maxCodeChar) {
+ symbolAudit.alerts[metricCodeCharacterCount] = {
+ metric: metricCodeCharacterCount,
+ severity: getSeverityLevel(
+ sm.codeCharacterCount,
+ config.symbol.maxCodeChar,
+ ),
+ message: {
+ short: "Symbol too large",
+ long:
+ `Symbol exceeds maximum character limit (${sm.codeCharacterCount}/${config.symbol.maxCodeChar})`,
+ },
+ };
+ }
+
+ if (sm.characterCount > config.symbol.maxChar) {
+ symbolAudit.alerts[metricCharacterCount] = {
+ metric: metricCharacterCount,
+ severity: getSeverityLevel(sm.characterCount, config.symbol.maxChar),
+ message: {
+ short: "Symbol too large",
+ long:
+ `Symbol exceeds maximum character limit (${sm.characterCount}/${config.symbol.maxChar})`,
+ },
+ };
+ }
+
+ if (sm.codeLineCount > config.symbol.maxCodeLine) {
+ symbolAudit.alerts[metricCodeLineCount] = {
+ metric: metricCodeLineCount,
+ severity: getSeverityLevel(
+ sm.codeLineCount,
+ config.symbol.maxCodeLine,
+ ),
+ message: {
+ short: "Symbol too long",
+ long:
+ `Symbol exceeds maximum line count (${sm.codeLineCount}/${config.symbol.maxCodeLine})`,
+ },
+ };
+ }
+
+ if (sm.linesCount > config.symbol.maxLine) {
+ symbolAudit.alerts[metricLinesCount] = {
+ metric: metricLinesCount,
+ severity: getSeverityLevel(sm.linesCount, config.symbol.maxLine),
+ message: {
+ short: "Symbol too long",
+ long:
+ `Symbol exceeds maximum line count (${sm.linesCount}/${config.symbol.maxLine})`,
+ },
+ };
+ }
+
+ if (sm.dependencyCount > config.symbol.maxDependency) {
+ symbolAudit.alerts[metricDependencyCount] = {
+ metric: metricDependencyCount,
+ severity: getSeverityLevel(
+ sm.dependencyCount,
+ config.symbol.maxDependency,
+ ),
+ message: {
+ short: "Too many dependencies",
+ long:
+ `Symbol exceeds maximum dependency count (${sm.dependencyCount}/${config.symbol.maxDependency})`,
+ },
+ };
+ }
+
+ if (sm.dependentCount > config.symbol.maxDependent) {
+ symbolAudit.alerts[metricDependentCount] = {
+ metric: metricDependentCount,
+ severity: getSeverityLevel(
+ sm.dependentCount,
+ config.symbol.maxDependent,
+ ),
+ message: {
+ short: "Too many dependents",
+ long:
+ `Symbol exceeds maximum dependent count (${sm.dependentCount}/${config.symbol.maxDependent})`,
+ },
+ };
+ }
+
+ if (sm.cyclomaticComplexity > config.symbol.maxCyclomaticComplexity) {
+ symbolAudit.alerts[metricCyclomaticComplexity] = {
+ metric: metricCyclomaticComplexity,
+ severity: getSeverityLevel(
+ sm.cyclomaticComplexity,
+ config.symbol.maxCyclomaticComplexity,
+ ),
+ message: {
+ short: "Symbol too complex",
+ long:
+ `Symbol exceeds maximum cyclomatic complexity (${sm.cyclomaticComplexity}/${config.symbol.maxCyclomaticComplexity})`,
+ },
+ };
+ }
+
+ fileAudit.symbols[symbol.id] = symbolAudit;
+ }
+
+ auditManifest[fileDependencyManifest.id] = fileAudit;
+ }
+
+ return auditManifest;
+}
diff --git a/src/manifest/auditManifest/types.ts b/src/manifest/auditManifest/types.ts
new file mode 100644
index 00000000..b41fc5bd
--- /dev/null
+++ b/src/manifest/auditManifest/types.ts
@@ -0,0 +1,65 @@
+import type { Metric } from "../dependencyManifest/types.ts";
+
+export type AuditAlert = {
+ metric: Metric;
+ severity: number;
+ message: {
+ short: string;
+ long: string;
+ };
+};
+
+export type SymbolAuditManifest = {
+ id: string;
+ alerts: Record;
+};
+
+export type FileAuditManifest = {
+ id: string;
+ alerts: Record;
+ symbols: Record;
+};
+
+export type AuditManifest = Record;
+
+export interface AuditConfig {
+ file: {
+ maxCodeChar: number;
+ maxChar: number;
+ maxCodeLine: number;
+ maxLine: number;
+ maxDependency: number;
+ maxDependent: number;
+ maxCyclomaticComplexity: number;
+ };
+ symbol: {
+ maxCodeChar: number;
+ maxChar: number;
+ maxCodeLine: number;
+ maxLine: number;
+ maxDependency: number;
+ maxDependent: number;
+ maxCyclomaticComplexity: number;
+ };
+}
+
+export const defaultAuditConfig: AuditConfig = {
+ file: {
+ maxCodeChar: 1000,
+ maxChar: 1000,
+ maxCodeLine: 100,
+ maxLine: 100,
+ maxDependency: 100,
+ maxDependent: 100,
+ maxCyclomaticComplexity: 100,
+ },
+ symbol: {
+ maxCodeChar: 100,
+ maxChar: 100,
+ maxCodeLine: 10,
+ maxLine: 10,
+ maxDependency: 10,
+ maxDependent: 10,
+ maxCyclomaticComplexity: 10,
+ },
+};
diff --git a/viewer/.gitignore b/viewer/.gitignore
new file mode 100644
index 00000000..a547bf36
--- /dev/null
+++ b/viewer/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/viewer/README.md b/viewer/README.md
new file mode 100644
index 00000000..7dbf7ebf
--- /dev/null
+++ b/viewer/README.md
@@ -0,0 +1,73 @@
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+
+ // Remove tseslint.configs.recommended and replace with this
+ tseslint.configs.recommendedTypeChecked,
+ // Alternatively, use this for stricter rules
+ tseslint.configs.strictTypeChecked,
+ // Optionally, add this for stylistic rules
+ tseslint.configs.stylisticTypeChecked,
+
+ // Other configs...
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ // Other configs...
+ // Enable lint rules for React
+ reactX.configs['recommended-typescript'],
+ // Enable lint rules for React DOM
+ reactDom.configs.recommended,
+ ],
+ languageOptions: {
+ parserOptions: {
+ project: ['./tsconfig.node.json', './tsconfig.app.json'],
+ tsconfigRootDir: import.meta.dirname,
+ },
+ // other options...
+ },
+ },
+])
+```
diff --git a/viewer/index.html b/viewer/index.html
new file mode 100644
index 00000000..f8f67ac1
--- /dev/null
+++ b/viewer/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ NanoAPI Viewer
+
+
+
+
+
+
diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx
new file mode 100644
index 00000000..ca5b2db3
--- /dev/null
+++ b/viewer/src/App.tsx
@@ -0,0 +1,20 @@
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+import { ThemeProvider } from "./contexts/ThemeProvider";
+import { TooltipProvider } from "./components/shadcn/Tooltip";
+import ManifestList from "./pages/ManifestList";
+import ManifestView from "./pages/ManifestView";
+
+export default function App() {
+ return (
+
+
+
+
+ } />
+ } />
+
+
+
+
+ );
+}
diff --git a/viewer/src/api.ts b/viewer/src/api.ts
new file mode 100644
index 00000000..54f4f095
--- /dev/null
+++ b/viewer/src/api.ts
@@ -0,0 +1,39 @@
+import type { DependencyManifest } from "./types/dependencyManifest";
+import type { AuditManifest } from "./types/auditManifest";
+
+export interface ManifestListItem {
+ id: string;
+ branch: string;
+ commitSha: string;
+ commitShaDate: string;
+ createdAt: string;
+ fileCount: number;
+}
+
+export interface ManifestEnvelope {
+ id: string;
+ branch: string;
+ commitSha: string;
+ commitShaDate: string;
+ createdAt: string;
+ manifest: DependencyManifest;
+}
+
+const BASE = "/api";
+
+export async function fetchManifests(): Promise {
+ const res = await fetch(`${BASE}/manifests`);
+ return res.json();
+}
+
+export async function fetchManifest(id: string): Promise {
+ const res = await fetch(`${BASE}/manifests/${id}`);
+ if (!res.ok) throw new Error("Manifest not found");
+ return res.json();
+}
+
+export async function fetchAudit(id: string): Promise {
+ const res = await fetch(`${BASE}/manifests/${id}/audit`);
+ if (!res.ok) throw new Error("Audit not found");
+ return res.json();
+}
diff --git a/viewer/src/components/DependencyVisualizer/DependencyVisualizer.tsx b/viewer/src/components/DependencyVisualizer/DependencyVisualizer.tsx
new file mode 100644
index 00000000..99ff1a31
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/DependencyVisualizer.tsx
@@ -0,0 +1,135 @@
+import { useState } from "react";
+import { useSearchParams } from "react-router-dom";
+import type { DependencyManifest } from "../../types/dependencyManifest.ts";
+import type { AuditManifest } from "../../types/auditManifest.ts";
+import { SidebarProvider, SidebarTrigger } from "../shadcn/Sidebar.tsx";
+import {
+ FileExplorerSidebar,
+ type ExplorerNodeData,
+} from "./components/FileExplorerSidebar.tsx";
+import BreadcrumbNav from "./components/BreadcrumbNav.tsx";
+import ProjectVisualizer from "./visualizers/ProjectVisualizer.tsx";
+import FileVisualizer from "./visualizers/FileVisualizer.tsx";
+import SymbolVisualizer from "./visualizers/SymbolVisualizer.tsx";
+
+export default function DependencyVisualizer(props: {
+ manifestId: string;
+ dependencyManifest: DependencyManifest;
+ auditManifest: AuditManifest;
+}) {
+ const [searchParams] = useSearchParams();
+ const [highlightedCytoscapeRef, setHighlightedCytoscapeRef] = useState<
+ { filePath: string; symbolId: string | undefined } | undefined
+ >(undefined);
+
+ function handleHighlight(node: ExplorerNodeData) {
+ if (!node.fileId) return;
+ const newRef = {
+ filePath: node.fileId,
+ symbolId: node.symbolId,
+ };
+ if (
+ highlightedCytoscapeRef?.filePath === newRef.filePath &&
+ highlightedCytoscapeRef?.symbolId === newRef.symbolId
+ ) {
+ setHighlightedCytoscapeRef(undefined);
+ } else {
+ setHighlightedCytoscapeRef(newRef);
+ }
+ }
+
+ function toDetails(node: ExplorerNodeData) {
+ if (node.symbolId && node.fileId) {
+ const p = new URLSearchParams(searchParams);
+ p.set("fileId", node.fileId);
+ p.set("instanceId", node.symbolId);
+ return `?${p.toString()}`;
+ } else if (node.fileId) {
+ const p = new URLSearchParams(searchParams);
+ p.set("fileId", node.fileId);
+ p.delete("instanceId");
+ return `?${p.toString()}`;
+ } else {
+ const p = new URLSearchParams(searchParams);
+ p.delete("fileId");
+ p.delete("instanceId");
+ return `?${p.toString()}`;
+ }
+ }
+
+ const fileId = searchParams.get("fileId");
+ const instanceId = searchParams.get("instanceId");
+
+ return (
+
+
+
+
+
+
+ {
+ const p = new URLSearchParams(searchParams);
+ p.delete("fileId");
+ p.delete("instanceId");
+ return `?${p.toString()}`;
+ }}
+ fileId={fileId}
+ toFileIdLink={(fId) => {
+ const p = new URLSearchParams(searchParams);
+ p.set("fileId", fId);
+ p.delete("instanceId");
+ return `?${p.toString()}`;
+ }}
+ instanceId={instanceId}
+ toInstanceIdLink={(fId, iId) => {
+ const p = new URLSearchParams(searchParams);
+ p.set("fileId", fId);
+ p.set("instanceId", iId);
+ return `?${p.toString()}`;
+ }}
+ />
+
+
+
+ {fileId && instanceId ? (
+
+ ) : fileId ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/BreadcrumbNav.tsx b/viewer/src/components/DependencyVisualizer/components/BreadcrumbNav.tsx
new file mode 100644
index 00000000..0f7eca27
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/BreadcrumbNav.tsx
@@ -0,0 +1,57 @@
+import { Link } from "react-router-dom";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbSeparator,
+} from "../../shadcn/Breadcrumb.tsx";
+
+export default function BreadcrumbNav(props: {
+ toProjectLink: () => string;
+ fileId: string | null;
+ toFileIdLink: (fileId: string) => string;
+ instanceId: string | null;
+ toInstanceIdLink: (fileId: string, instanceId: string) => string;
+}) {
+ return (
+
+
+
+
+ Project
+
+
+ {props.fileId && (
+ <>
+
+
+
+
+ {props.fileId}
+
+
+
+ {props.instanceId && (
+ <>
+
+
+
+
+ {props.instanceId}
+
+
+
+ >
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/DisplayNameWithTooltip.tsx b/viewer/src/components/DependencyVisualizer/components/DisplayNameWithTooltip.tsx
new file mode 100644
index 00000000..c043780a
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/DisplayNameWithTooltip.tsx
@@ -0,0 +1,31 @@
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "../../shadcn/Tooltip.tsx";
+
+export default function DisplayNameWithTooltip(props: {
+ name: string;
+ maxChar?: number;
+ truncateBefore?: boolean;
+}) {
+ const maxChar = props.maxChar || 30;
+
+ if (props.name.length > maxChar) {
+ const displayedName = props.truncateBefore
+ ? "..." + props.name.slice(0, maxChar)
+ : props.name.slice(0, maxChar) + "...";
+
+ return (
+
+
+ {displayedName}
+
+
+ {props.name}
+
+
+ );
+ }
+ return {props.name} ;
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/FileExplorerSidebar.tsx b/viewer/src/components/DependencyVisualizer/components/FileExplorerSidebar.tsx
new file mode 100644
index 00000000..0323f689
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/FileExplorerSidebar.tsx
@@ -0,0 +1,426 @@
+import { useEffect, useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+import type { DependencyManifest } from "../../../types/dependencyManifest.ts";
+import type { AuditManifest } from "../../../types/auditManifest.ts";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarGroup,
+ SidebarHeader,
+ SidebarRail,
+} from "../../shadcn/Sidebar.tsx";
+import { Button } from "../../shadcn/Button.tsx";
+import { Input } from "../../shadcn/Input.tsx";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "../../shadcn/Tooltip.tsx";
+import {
+ ChevronDown,
+ ChevronRight,
+ Code,
+ File,
+ FolderClosed,
+ FolderOpen,
+ ScanEye,
+ Search,
+ SearchCode,
+ X,
+} from "lucide-react";
+import { ScrollArea, ScrollBar } from "../../shadcn/Scrollarea.tsx";
+import DisplayNameWithTooltip from "./DisplayNameWithTooltip.tsx";
+
+export interface ExplorerNodeData {
+ id: string;
+ displayName: string;
+ fileId?: string;
+ symbolId?: string;
+ children: Map;
+}
+
+function buildExplorerTree(
+ dependencyManifest: DependencyManifest,
+ filteredSymbols: { fileId: string; symbolId: string }[],
+): ExplorerNodeData | undefined {
+ const getExplorerNodeId = (filePath: string, instanceId?: string) => {
+ if (instanceId) {
+ return `${filePath}#${instanceId}`;
+ }
+ return filePath;
+ };
+
+ const root: ExplorerNodeData = {
+ id: "root",
+ displayName: "Project",
+ children: new Map(),
+ };
+
+ const filteredSymbolsSet = new Set(
+ filteredSymbols.map((s) => `${s.fileId}#${s.symbolId}`),
+ );
+
+ const filesWithFilteredSymbols = new Set(
+ filteredSymbols.map((s) => s.fileId),
+ );
+
+ const shouldShowAll = filteredSymbols.length === 0;
+
+ let hasMatchingNodes = false;
+
+ for (const fileDependencyManifest of Object.values(dependencyManifest)) {
+ const filePath = fileDependencyManifest.filePath;
+
+ const fileShouldBeIncluded = shouldShowAll ||
+ filesWithFilteredSymbols.has(filePath);
+
+ if (!fileShouldBeIncluded) {
+ continue;
+ }
+
+ hasMatchingNodes = true;
+
+ const parts = filePath.split("/");
+ let currentNode: ExplorerNodeData = root;
+ for (const part of parts) {
+ const id = getExplorerNodeId(part);
+ if (!currentNode.children.has(id)) {
+ currentNode.children.set(id, {
+ id: id,
+ displayName: part,
+ children: new Map(),
+ });
+ }
+ currentNode = currentNode.children.get(id)!;
+ }
+ currentNode.fileId = getExplorerNodeId(filePath);
+
+ for (const instanceId of Object.keys(fileDependencyManifest.symbols)) {
+ const symbolKey = `${filePath}#${instanceId}`;
+ if (shouldShowAll || filteredSymbolsSet.has(symbolKey)) {
+ const id = getExplorerNodeId(filePath, instanceId);
+ currentNode.children.set(id, {
+ id: id,
+ displayName: instanceId,
+ fileId: filePath,
+ symbolId: instanceId,
+ children: new Map(),
+ });
+ }
+ }
+ }
+
+ if (!shouldShowAll && !hasMatchingNodes) {
+ return undefined;
+ }
+
+ const flattenTree = (node: ExplorerNodeData): ExplorerNodeData => {
+ if (node.children.size > 0) {
+ const flattenedChildren = new Map();
+
+ const folders: Array<[string, ExplorerNodeData]> = [];
+ const files: Array<[string, ExplorerNodeData]> = [];
+
+ for (const [id, child] of node.children) {
+ const flattenedChild = flattenTree(child);
+ if (flattenedChild.fileId) {
+ files.push([id, flattenedChild]);
+ } else {
+ folders.push([id, flattenedChild]);
+ }
+ }
+
+ for (const [id, folder] of folders) {
+ flattenedChildren.set(id, folder);
+ }
+ for (const [id, file] of files) {
+ flattenedChildren.set(id, file);
+ }
+
+ node.children = flattenedChildren;
+ }
+
+ while (node.children.size === 1) {
+ const childEntry = Array.from(node.children.entries())[0];
+ const child = childEntry[1];
+
+ if (child.fileId) {
+ break;
+ }
+
+ node.displayName = `${node.displayName}/${child.displayName}`;
+ node.id = child.id;
+ node.children = child.children;
+ }
+
+ return node;
+ };
+
+ return flattenTree(root);
+}
+
+function computeFilteredSymbols(
+ dependencyManifest: DependencyManifest,
+ searchTerm: string,
+): { fileId: string; symbolId: string }[] {
+ const term = searchTerm.toLowerCase();
+ const results: { fileId: string; symbolId: string }[] = [];
+
+ for (const file of Object.values(dependencyManifest)) {
+ const filePathMatch = file.filePath.toLowerCase().includes(term);
+
+ for (const symbolId of Object.keys(file.symbols)) {
+ if (filePathMatch || symbolId.toLowerCase().includes(term)) {
+ results.push({ fileId: file.filePath, symbolId });
+ }
+ }
+ }
+
+ return results;
+}
+
+export function FileExplorerSidebar(props: {
+ dependencyManifest: DependencyManifest;
+ auditManifest: AuditManifest;
+ onHighlightInCytoscape: (node: ExplorerNodeData) => void;
+ toDetails: (node: ExplorerNodeData) => string;
+}) {
+ const [searchTerm, setSearchTerm] = useState("");
+ const [activeSearch, setActiveSearch] = useState("");
+
+ const filteredSymbols = useMemo(() => {
+ if (!activeSearch) return [];
+ return computeFilteredSymbols(props.dependencyManifest, activeSearch);
+ }, [props.dependencyManifest, activeSearch]);
+
+ const [explorerTree, setExplorerTree] = useState();
+
+ useEffect(() => {
+ const tree = buildExplorerTree(props.dependencyManifest, filteredSymbols);
+ setExplorerTree(tree);
+ }, [props.dependencyManifest, filteredSymbols]);
+
+ function onSubmitSearch(e: React.FormEvent) {
+ e.preventDefault();
+ if (searchTerm.trim()) {
+ setActiveSearch(searchTerm.trim());
+ }
+ }
+
+ function onClearSearch() {
+ setSearchTerm("");
+ setActiveSearch("");
+ }
+
+ return (
+
+
+
+
+ NanoAPI
+
+
+
+
+
+
+
+
+
+ {!explorerTree
+ ? (
+
+ No matching files found
+
+ )
+ : (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function ExplorerNode(props: {
+ node: ExplorerNodeData;
+ level: number;
+ onHighlightInCytoscape: (node: ExplorerNodeData) => void;
+ toDetails: (node: ExplorerNodeData) => string;
+}) {
+ const [showChildren, setShowChildren] = useState(false);
+
+ const type: "folder" | "file" | "symbol" = props.node.symbolId
+ ? "symbol"
+ : props.node.fileId
+ ? "file"
+ : "folder";
+
+ return (
+
+ {(() => {
+ switch (type) {
+ case "folder":
+ return (
+
setShowChildren(!showChildren)}
+ className="w-full justify-start"
+ >
+ {showChildren ? : }
+
+
+ );
+ case "file":
+ return (
+
+
+ setShowChildren(!showChildren)}
+ className="w-full justify-start"
+ >
+ {showChildren
+ ?
+ : }
+
+
+
+
+
+
+
+
+ props.onHighlightInCytoscape(props.node)}
+ >
+
+
+
+
+ Highlight in graph
+
+
+
+
+
+
+
+
+
+
+
+ View graph for this file
+
+
+
+
+ );
+ case "symbol":
+ return (
+
+
+
+
+
+
+
+
+
+ props.onHighlightInCytoscape(props.node)}
+ >
+
+
+
+
+ Highlight in graph
+
+
+
+
+
+
+
+
+
+
+
+ View graph for this symbol
+
+
+
+
+ );
+ }
+ })()}
+ {showChildren &&
+ Array.from(props.node.children.values()).map((child) => (
+
+ ))}
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/SymbolExtractionDialog.tsx b/viewer/src/components/DependencyVisualizer/components/SymbolExtractionDialog.tsx
new file mode 100644
index 00000000..d1cf3dcf
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/SymbolExtractionDialog.tsx
@@ -0,0 +1,104 @@
+import { useState } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "../../shadcn/Dialog.tsx";
+import { Button } from "../../shadcn/Button.tsx";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "../../shadcn/Card.tsx";
+import { Alert, AlertDescription, AlertTitle } from "../../shadcn/Alert.tsx";
+import { Separator } from "../../shadcn/Separator.tsx";
+import { Code, Copy, Info, Terminal } from "lucide-react";
+import { ScrollArea } from "../../shadcn/Scrollarea.tsx";
+
+export default function SymbolExtractionDialog(props: {
+ manifestId: string;
+ children: React.ReactNode;
+ filePath: string;
+ symbolIds: string[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [copied, setCopied] = useState(false);
+
+ const generateCommand = () => {
+ const symbolOptions = props.symbolIds.map((symbolId) => {
+ return `--symbol="${props.filePath}|${symbolId}"`;
+ });
+ return `napi extract --manifestId=${props.manifestId} ${symbolOptions.join(" ")}`;
+ };
+
+ const copyToClipboard = async () => {
+ try {
+ await navigator.clipboard.writeText(generateCommand());
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy to clipboard:", err);
+ }
+ };
+
+ return (
+
+
+ {props.children}
+
+
+
+
+
+
+ Extract Symbol(s) via CLI
+
+
+ Use the napi CLI to extract symbols from your program.
+
+
+
+
+
+
+ Prerequisites
+
+
+
+ Ensure you have a napi manifest generated locally:
+
+
+ napi generate
+
+
+
+
+
+
+
+
+
+ Command
+
+
+
+ {copied ? "Copied!" : "Copy"}
+
+
+
+
+
+ {generateCommand()}
+
+
+
+
+
+
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/contextMenu/FileContextMenu.tsx b/viewer/src/components/DependencyVisualizer/components/contextMenu/FileContextMenu.tsx
new file mode 100644
index 00000000..ee58290f
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/contextMenu/FileContextMenu.tsx
@@ -0,0 +1,84 @@
+import { Link, useSearchParams } from "react-router-dom";
+import type { DependencyManifest } from "../../../../types/dependencyManifest.ts";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../../../shadcn/Dropdownmenu.tsx";
+import { PanelRight, SearchCode } from "lucide-react";
+import DisplayNameWithTooltip from "../DisplayNameWithTooltip.tsx";
+
+export default function FileContextMenu(props: {
+ context:
+ | {
+ position: { x: number; y: number };
+ fileDependencyManifest: DependencyManifest[string];
+ }
+ | undefined;
+ onClose: () => void;
+ onOpenDetails: (filePath: string) => void;
+}) {
+ const [searchParams] = useSearchParams();
+
+ function getToFileLink(filePath: string) {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("fileId", filePath);
+ newSearchParams.delete("instanceId");
+ return `?${newSearchParams.toString()}`;
+ }
+
+ return (
+
+
props.onClose()}
+ >
+
+
+
+
+
+
+ {
+ props.context?.fileDependencyManifest &&
+ props.onOpenDetails(
+ props.context.fileDependencyManifest.filePath,
+ );
+ props.onClose();
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/contextMenu/SymbolContextMenu.tsx b/viewer/src/components/DependencyVisualizer/components/contextMenu/SymbolContextMenu.tsx
new file mode 100644
index 00000000..29a925a4
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/contextMenu/SymbolContextMenu.tsx
@@ -0,0 +1,88 @@
+import { Link, useSearchParams } from "react-router-dom";
+import type { DependencyManifest } from "../../../../types/dependencyManifest.ts";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../../../shadcn/Dropdownmenu.tsx";
+import { PanelRight, SearchCode } from "lucide-react";
+import DisplayNameWithTooltip from "../DisplayNameWithTooltip.tsx";
+
+export default function SymbolContextMenu(props: {
+ context:
+ | {
+ position: { x: number; y: number };
+ fileDependencyManifest: DependencyManifest[string];
+ symbolDependencyManifest: DependencyManifest[string]["symbols"][string];
+ }
+ | undefined;
+ onClose: () => void;
+ onOpenDetails: (filePath: string, symbolId: string) => void;
+}) {
+ const [searchParams] = useSearchParams();
+
+ function getToSymbolLink(filePath: string, symbolId: string) {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("fileId", filePath);
+ newSearchParams.set("instanceId", symbolId);
+ return `?${newSearchParams.toString()}`;
+ }
+
+ return (
+
+
props.onClose()}
+ >
+
+
+
+
+
+
+ {
+ props.context?.fileDependencyManifest &&
+ props.onOpenDetails(
+ props.context.fileDependencyManifest.filePath,
+ props.context.symbolDependencyManifest.id,
+ );
+ props.onClose();
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/controls/ControlExtensions/FiltersExtension.tsx b/viewer/src/components/DependencyVisualizer/components/controls/ControlExtensions/FiltersExtension.tsx
new file mode 100644
index 00000000..c26f1099
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/controls/ControlExtensions/FiltersExtension.tsx
@@ -0,0 +1,95 @@
+import { type MouseEvent, useEffect, useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "../../../../shadcn/Tooltip.tsx";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "../../../../shadcn/Dropdownmenu.tsx";
+import { Button } from "../../../../shadcn/Button.tsx";
+import { Funnel } from "lucide-react";
+
+export default function FiltersExtension(props: {
+ busy: boolean;
+ currentFileName?: string;
+ onFilterChange: (
+ showExternal: boolean,
+ showVariables: boolean,
+ showFunctions: boolean,
+ showClasses: boolean,
+ showStructs: boolean,
+ showEnums: boolean,
+ showInterfaces: boolean,
+ showRecords: boolean,
+ showDelegates: boolean,
+ ) => void;
+}) {
+ const [showExternal, setShowExternal] = useState(true);
+ const [showVariables, setShowVariables] = useState(true);
+ const [showFunctions, setShowFunctions] = useState(true);
+ const [showClasses, setShowClasses] = useState(true);
+ const [showStructs, setShowStructs] = useState(true);
+ const [showEnums, setShowEnums] = useState(true);
+ const [showInterfaces, setShowInterfaces] = useState(true);
+ const [showRecords, setShowRecords] = useState(true);
+ const [showDelegates, setShowDelegates] = useState(true);
+
+ useEffect(() => {
+ props.onFilterChange(
+ showExternal, showVariables, showFunctions, showClasses,
+ showStructs, showEnums, showInterfaces, showRecords, showDelegates,
+ );
+ }, [showExternal, showVariables, showFunctions, showClasses, showStructs, showEnums, showInterfaces, showRecords, showDelegates]);
+
+ function handleFilterClick(
+ e: MouseEvent,
+ set: React.Dispatch>,
+ ) {
+ e.preventDefault();
+ set((prev) => !prev);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ Filters
+
+ {[
+ { label: "Show external", checked: showExternal, set: setShowExternal },
+ { label: "Show variables", checked: showVariables, set: setShowVariables },
+ { label: "Show functions", checked: showFunctions, set: setShowFunctions },
+ { label: "Show classes", checked: showClasses, set: setShowClasses },
+ { label: "Show structs", checked: showStructs, set: setShowStructs },
+ { label: "Show enums", checked: showEnums, set: setShowEnums },
+ { label: "Show interfaces", checked: showInterfaces, set: setShowInterfaces },
+ { label: "Show records", checked: showRecords, set: setShowRecords },
+ { label: "Show delegates", checked: showDelegates, set: setShowDelegates },
+ ].map(({ label, checked, set }) => (
+ handleFilterClick(e, set)}
+ >
+ {label}
+
+ ))}
+
+
+ Hide or show specific elements in the graph.
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/controls/ControlExtensions/GraphDepthExtension.tsx b/viewer/src/components/DependencyVisualizer/components/controls/ControlExtensions/GraphDepthExtension.tsx
new file mode 100644
index 00000000..3a5e52d8
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/controls/ControlExtensions/GraphDepthExtension.tsx
@@ -0,0 +1,76 @@
+import { useState } from "react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "../../../../shadcn/Tooltip.tsx";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "../../../../shadcn/Dropdownmenu.tsx";
+import { Button } from "../../../../shadcn/Button.tsx";
+import { Settings2 } from "lucide-react";
+import { Slider } from "../../../../shadcn/Slider.tsx";
+import { Input } from "../../../../shadcn/Input.tsx";
+import { Label } from "../../../../shadcn/Label.tsx";
+
+export default function GraphDepthExtension(props: {
+ busy: boolean;
+ dependencyState: {
+ depth: number;
+ setDepth: (depth: number) => void;
+ };
+ dependentState: {
+ depth: number;
+ setDepth: (depth: number) => void;
+ };
+}) {
+ const { dependencyState, dependentState } = props;
+ const [tempDependencyDepth, setTempDependencyDepth] = useState(dependencyState.depth);
+ const [tempDependentDepth, setTempDependentDepth] = useState(dependentState.depth);
+
+ function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ if (tempDependencyDepth !== dependencyState.depth) {
+ dependencyState.setDepth(tempDependencyDepth);
+ }
+ if (tempDependentDepth !== dependentState.depth) {
+ dependentState.setDepth(tempDependentDepth);
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Set the depth of the dependencies shown on the graph.
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/controls/ControlExtensions/MetricsExtension.tsx b/viewer/src/components/DependencyVisualizer/components/controls/ControlExtensions/MetricsExtension.tsx
new file mode 100644
index 00000000..45613c1d
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/controls/ControlExtensions/MetricsExtension.tsx
@@ -0,0 +1,79 @@
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "../../../../shadcn/Tooltip.tsx";
+import { Button } from "../../../../shadcn/Button.tsx";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "../../../../shadcn/Dropdownmenu.tsx";
+import type { Metric } from "../../../../../types/dependencyManifest.ts";
+import {
+ metricLinesCount,
+ metricCodeLineCount,
+ metricCharacterCount,
+ metricCodeCharacterCount,
+ metricDependencyCount,
+ metricDependentCount,
+ metricCyclomaticComplexity,
+} from "../../../../../types/dependencyManifest.ts";
+
+export default function MetricsExtension(props: {
+ busy: boolean;
+ metricState: {
+ metric: Metric | undefined;
+ setMetric: (metric: Metric | undefined) => void;
+ };
+}) {
+ const metric = props.metricState.metric;
+
+ function getMetricLabel(metric: Metric | undefined) {
+ switch (metric) {
+ case metricLinesCount: return "Lines";
+ case metricCodeLineCount: return "Code Lines";
+ case metricCharacterCount: return "Chars";
+ case metricCodeCharacterCount: return "Code Chars";
+ case metricDependencyCount: return "Dependencies";
+ case metricDependentCount: return "Dependents";
+ case metricCyclomaticComplexity: return "Complexity";
+ default: return "None";
+ }
+ }
+
+ return (
+
+
+
+
+
+ {getMetricLabel(metric)}
+
+
+
+
+ {([
+ { metric: undefined, label: "No Metric" },
+ { metric: metricLinesCount, label: "Lines" },
+ { metric: metricCodeLineCount, label: "Code Lines" },
+ { metric: metricCharacterCount, label: "Total Characters" },
+ { metric: metricCodeCharacterCount, label: "Code Characters" },
+ { metric: metricDependencyCount, label: "Dependencies" },
+ { metric: metricDependentCount, label: "Dependents" },
+ { metric: metricCyclomaticComplexity, label: "Complexity" },
+ ] as { metric: Metric | undefined; label: string }[]).map((val) => (
+ props.metricState?.setMetric?.(val.metric)}
+ >
+ {val.label}
+
+ ))}
+
+
+ Select a metric to display on the graph
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/controls/Controls.tsx b/viewer/src/components/DependencyVisualizer/components/controls/Controls.tsx
new file mode 100644
index 00000000..6f5df472
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/controls/Controls.tsx
@@ -0,0 +1,69 @@
+import type { ReactNode } from "react";
+import { Button } from "../../../shadcn/Button.tsx";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "../../../shadcn/Tooltip.tsx";
+import type { Core } from "cytoscape";
+import { Focus, Network, ZoomIn, ZoomOut } from "lucide-react";
+
+export default function Controls(props: {
+ busy: boolean;
+ cy: Core | undefined;
+ onLayout: () => void;
+ children?: ReactNode;
+}) {
+ function handleFit() {
+ if (!props.cy) return;
+ const elements = props.cy.elements();
+ const padding = 10;
+ props.cy.center(elements).fit(elements, padding);
+ }
+
+ function handleZoom(zoom: number) {
+ if (!props.cy) return;
+ const level = props.cy.zoom() * zoom;
+ const x = props.cy.width() / 2;
+ const y = props.cy.height() / 2;
+ props.cy.zoom({ level, renderedPosition: { x, y } });
+ }
+
+ return (
+
+
+
+
+
+
+
+ Fit to screen
+
+
+
+ props.onLayout()}>
+
+
+
+ Reset layout
+
+
+
+ handleZoom(0.9)}>
+
+
+
+ Zoom out
+
+
+
+ handleZoom(1.1)}>
+
+
+
+ Zoom in
+
+ {props.children}
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/detailsPanes/AlertBadge.tsx b/viewer/src/components/DependencyVisualizer/components/detailsPanes/AlertBadge.tsx
new file mode 100644
index 00000000..fcab2042
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/detailsPanes/AlertBadge.tsx
@@ -0,0 +1,16 @@
+import { Check, TriangleAlert } from "lucide-react";
+
+export default function AlertBadge(props: { count: number }) {
+ return (
+
+ {props.count > 0
+ ? (
+ <>
+
+ {props.count}
+ >
+ )
+ : }
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/detailsPanes/FileDetailsPane.tsx b/viewer/src/components/DependencyVisualizer/components/detailsPanes/FileDetailsPane.tsx
new file mode 100644
index 00000000..d4517051
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/detailsPanes/FileDetailsPane.tsx
@@ -0,0 +1,167 @@
+import type { DependencyManifest } from "../../../../types/dependencyManifest.ts";
+import type { AuditManifest } from "../../../../types/auditManifest.ts";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+} from "../../../shadcn/Sheet.tsx";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "../../../shadcn/Card.tsx";
+import { Code, File, SearchCode } from "lucide-react";
+import { ScrollArea } from "../../../shadcn/Scrollarea.tsx";
+import { Button } from "../../../shadcn/Button.tsx";
+import { Link, useSearchParams } from "react-router-dom";
+import DisplayNameWithTooltip from "../DisplayNameWithTooltip.tsx";
+import Metrics from "./Metrics.tsx";
+import AlertBadge from "./AlertBadge.tsx";
+
+export default function FileDetailsPane(props: {
+ context:
+ | {
+ manifestId: string;
+ fileDependencyManifest: DependencyManifest[string];
+ fileAuditManifest: AuditManifest[string];
+ }
+ | undefined;
+ onClose: () => void;
+}) {
+ const [searchParams] = useSearchParams();
+
+ function getToFileLink(filePath: string) {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("fileId", filePath);
+ newSearchParams.delete("instanceId");
+ return `?${newSearchParams.toString()}`;
+ }
+
+ return (
+
+
props.onClose()}
+ >
+
+
+
+
+
+
+
+
+
+
+
+ View graph for this file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Symbols (
+ {Object.keys(
+ props.context?.fileDependencyManifest?.symbols || {},
+ ).length || 0}
+ )
+
+
+
+
+
+
+ {Object.entries(
+ Object.values(
+ props.context?.fileDependencyManifest?.symbols || {},
+ ).reduce(
+ (acc, symbol) => {
+ acc[symbol.type] = (acc[symbol.type] || 0) + 1;
+ return acc;
+ },
+ {} as Record
,
+ ),
+ ).map(([type, count]) => (
+
+ ))}
+
+
+
+ {Object.values(
+ props.context?.fileDependencyManifest?.symbols || {},
+ ).map((symbol) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/detailsPanes/Metrics.tsx b/viewer/src/components/DependencyVisualizer/components/detailsPanes/Metrics.tsx
new file mode 100644
index 00000000..cab84ca7
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/detailsPanes/Metrics.tsx
@@ -0,0 +1,59 @@
+import type { DependencyManifest } from "../../../../types/dependencyManifest.ts";
+import type { AuditManifest } from "../../../../types/auditManifest.ts";
+import { Alert, AlertDescription } from "../../../shadcn/Alert.tsx";
+
+export default function Metrics(props: {
+ dependencyManifest:
+ | DependencyManifest[string]
+ | DependencyManifest[string]["symbols"][string]
+ | undefined;
+ auditManifest:
+ | AuditManifest[string]
+ | AuditManifest[string]["symbols"][string]
+ | undefined;
+}) {
+ function metricToHumanString(metric: string) {
+ switch (metric) {
+ case "linesCount":
+ return "Lines";
+ case "codeLineCount":
+ return "Code Lines";
+ case "characterCount":
+ return "Characters";
+ case "codeCharacterCount":
+ return "Code Characters";
+ case "dependencyCount":
+ return "Dependencies";
+ case "dependentCount":
+ return "Dependents";
+ case "cyclomaticComplexity":
+ return "Cyclomatic Complexity";
+ default:
+ return metric;
+ }
+ }
+
+ return (
+
+ {Object.entries(props.dependencyManifest?.metrics || {}).map((
+ [key, value],
+ ) => (
+
+
+
+ {metricToHumanString(key)}
+
+
{value}
+
+ {(props.auditManifest?.alerts || {})?.[key] && (
+
+
+ {props.auditManifest?.alerts?.[key]?.message?.long}
+
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/components/detailsPanes/SymbolDetailsPane.tsx b/viewer/src/components/DependencyVisualizer/components/detailsPanes/SymbolDetailsPane.tsx
new file mode 100644
index 00000000..c3685407
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/components/detailsPanes/SymbolDetailsPane.tsx
@@ -0,0 +1,165 @@
+import type { DependencyManifest } from "../../../../types/dependencyManifest.ts";
+import type { AuditManifest } from "../../../../types/auditManifest.ts";
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "../../../shadcn/Sheet.tsx";
+import {
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "../../../shadcn/Card.tsx";
+import { Code, File, SearchCode } from "lucide-react";
+import { ScrollArea } from "../../../shadcn/Scrollarea.tsx";
+import { Button } from "../../../shadcn/Button.tsx";
+import { Link, useSearchParams } from "react-router-dom";
+import DisplayNameWithTooltip from "../DisplayNameWithTooltip.tsx";
+import Metrics from "./Metrics.tsx";
+import AlertBadge from "./AlertBadge.tsx";
+
+export default function SymbolDetailsPane(props: {
+ context:
+ | {
+ manifestId: string;
+ fileDependencyManifest: DependencyManifest[string];
+ symbolDependencyManifest: DependencyManifest[string]["symbols"][string];
+ fileAuditManifest: AuditManifest[string];
+ symbolAuditManifest: AuditManifest[string]["symbols"][string];
+ }
+ | undefined;
+ onClose: () => void;
+}) {
+ const [searchParams] = useSearchParams();
+
+ function getToSymbolLink(filePath: string, symbolId: string) {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("fileId", filePath);
+ newSearchParams.set("instanceId", symbolId);
+ return `?${newSearchParams.toString()}`;
+ }
+
+ function getToFileLink(filePath: string) {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("fileId", filePath);
+ newSearchParams.delete("instanceId");
+ return `?${newSearchParams.toString()}`;
+ }
+
+ return (
+
+
props.onClose()}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ View graph for this symbol
+
+
+
+
+
+ View graph for this file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/visualizers/FileVisualizer.tsx b/viewer/src/components/DependencyVisualizer/visualizers/FileVisualizer.tsx
new file mode 100644
index 00000000..dfa2bc43
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/visualizers/FileVisualizer.tsx
@@ -0,0 +1,212 @@
+import { useEffect, useRef, useState } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import Controls from "../components/controls/Controls.tsx";
+import MetricsExtension from "../components/controls/ControlExtensions/MetricsExtension.tsx";
+import FiltersExtension from "../components/controls/ControlExtensions/FiltersExtension.tsx";
+import SymbolContextMenu from "../components/contextMenu/SymbolContextMenu.tsx";
+import SymbolDetailsPane from "../components/detailsPanes/SymbolDetailsPane.tsx";
+import { FileDependencyVisualizer } from "../../../cytoscape/fileDependencyVisualizer/index.ts";
+import type { DependencyManifest, Metric } from "../../../types/dependencyManifest.ts";
+import type { AuditManifest } from "../../../types/auditManifest.ts";
+import { useTheme } from "../../../contexts/ThemeProvider.tsx";
+
+interface FileVisualizerProps {
+ manifestId: string;
+ dependencyManifest: DependencyManifest;
+ auditManifest: AuditManifest;
+ highlightedCytoscapeRef:
+ | { filePath: string; symbolId: string | undefined }
+ | undefined;
+ fileId: string;
+}
+
+export default function FileVisualizer(props: FileVisualizerProps) {
+ const navigate = useNavigate();
+ const { theme } = useTheme();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const containerRef = useRef(null);
+ const [busy, setBusy] = useState(true);
+ const [fileVisualizer, setFileVisualizer] = useState<
+ FileDependencyVisualizer | undefined
+ >(undefined);
+
+ const metricFromUrl = (searchParams.get("metric") || undefined) as
+ | Metric
+ | undefined;
+
+ const [metric, setMetric] = useState(metricFromUrl);
+
+ function handleMetricChange(newMetric: Metric | undefined) {
+ if (newMetric) {
+ searchParams.set("metric", newMetric);
+ setSearchParams(searchParams);
+ } else {
+ searchParams.delete("metric");
+ setSearchParams(searchParams);
+ }
+ setMetric(newMetric);
+ }
+
+ const [contextMenu, setContextMenu] = useState<
+ | {
+ position: { x: number; y: number };
+ fileDependencyManifest: DependencyManifest[string];
+ symbolDependencyManifest: DependencyManifest[string]["symbols"][string];
+ }
+ | undefined
+ >(undefined);
+
+ const [detailsPane, setDetailsPane] = useState<
+ | {
+ manifestId: string;
+ fileDependencyManifest: DependencyManifest[string];
+ symbolDependencyManifest: DependencyManifest[string]["symbols"][string];
+ fileAuditManifest: AuditManifest[string];
+ symbolAuditManifest: AuditManifest[string]["symbols"][string];
+ }
+ | undefined
+ >(undefined);
+
+ useEffect(() => {
+ setBusy(true);
+ const visualizer = new FileDependencyVisualizer(
+ containerRef.current as HTMLElement,
+ props.fileId,
+ props.dependencyManifest,
+ props.auditManifest,
+ {
+ theme,
+ defaultMetric: metric,
+ onAfterNodeRightClick: (value: {
+ position: { x: number; y: number };
+ filePath: string;
+ symbolId: string;
+ }) => {
+ const fileDependencyManifest =
+ props.dependencyManifest[value.filePath];
+ const symbolDependencyManifest =
+ fileDependencyManifest.symbols[value.symbolId];
+ setContextMenu({
+ position: value.position,
+ fileDependencyManifest,
+ symbolDependencyManifest,
+ });
+ },
+ onAfterNodeDblClick: (filePath: string, symbolId: string) => {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("fileId", filePath);
+ newSearchParams.set("instanceId", symbolId);
+ navigate(`?${newSearchParams.toString()}`);
+ },
+ },
+ );
+
+ setFileVisualizer(visualizer);
+ setBusy(false);
+
+ return () => {
+ visualizer?.cy.destroy();
+ setFileVisualizer(undefined);
+ };
+ }, [props.dependencyManifest, props.auditManifest, props.fileId]);
+
+ useEffect(() => {
+ if (fileVisualizer) {
+ fileVisualizer.setTargetMetric(metric);
+ }
+ }, [metric]);
+
+ useEffect(() => {
+ if (fileVisualizer) {
+ if (props.highlightedCytoscapeRef) {
+ fileVisualizer.highlightNode(props.highlightedCytoscapeRef);
+ } else {
+ fileVisualizer.unhighlightNodes();
+ }
+ }
+ }, [props.highlightedCytoscapeRef]);
+
+ useEffect(() => {
+ if (fileVisualizer) {
+ fileVisualizer.updateTheme(theme);
+ }
+ }, [theme]);
+
+ function handleFilterChange(
+ showExternal: boolean,
+ showVariables: boolean,
+ showFunctions: boolean,
+ showClasses: boolean,
+ showStructs: boolean,
+ showEnums: boolean,
+ showInterfaces: boolean,
+ showRecords: boolean,
+ showDelegates: boolean,
+ ) {
+ if (fileVisualizer) {
+ fileVisualizer.filterNodes(
+ showExternal,
+ showVariables,
+ showFunctions,
+ showClasses,
+ showStructs,
+ showEnums,
+ showInterfaces,
+ showRecords,
+ showDelegates,
+ );
+ }
+ }
+
+ return (
+
+
+
+
+ fileVisualizer?.layoutGraph(fileVisualizer.cy)}
+ >
+
+
+
+
+
+
setContextMenu(undefined)}
+ onOpenDetails={(filePath, symbolId) => {
+ const fileDependencyManifest = props.dependencyManifest[filePath];
+ const symbolDependencyManifest =
+ fileDependencyManifest.symbols[symbolId];
+ const fileAuditManifest = props.auditManifest[filePath];
+ const symbolAuditManifest = fileAuditManifest.symbols[symbolId];
+ setDetailsPane({
+ manifestId: props.manifestId,
+ fileDependencyManifest,
+ symbolDependencyManifest,
+ fileAuditManifest,
+ symbolAuditManifest,
+ });
+ }}
+ />
+
+ setDetailsPane(undefined)}
+ />
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/visualizers/ProjectVisualizer.tsx b/viewer/src/components/DependencyVisualizer/visualizers/ProjectVisualizer.tsx
new file mode 100644
index 00000000..eb4b160c
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/visualizers/ProjectVisualizer.tsx
@@ -0,0 +1,162 @@
+import { useEffect, useRef, useState } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import Controls from "../components/controls/Controls.tsx";
+import MetricsExtension from "../components/controls/ControlExtensions/MetricsExtension.tsx";
+import FileContextMenu from "../components/contextMenu/FileContextMenu.tsx";
+import FileDetailsPane from "../components/detailsPanes/FileDetailsPane.tsx";
+import { ProjectDependencyVisualizer } from "../../../cytoscape/projectDependencyVisualizer/index.ts";
+import type { DependencyManifest, Metric } from "../../../types/dependencyManifest.ts";
+import type { AuditManifest } from "../../../types/auditManifest.ts";
+import { useTheme } from "../../../contexts/ThemeProvider.tsx";
+
+interface ProjectVisualizerProps {
+ manifestId: string;
+ dependencyManifest: DependencyManifest;
+ auditManifest: AuditManifest;
+ highlightedCytoscapeRef:
+ | { filePath: string; symbolId: string | undefined }
+ | undefined;
+}
+
+export default function ProjectVisualizer(props: ProjectVisualizerProps) {
+ const navigate = useNavigate();
+ const { theme } = useTheme();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const containerRef = useRef(null);
+ const [busy, setBusy] = useState(true);
+ const [projectVisualizer, setProjectVisualizer] = useState<
+ ProjectDependencyVisualizer | undefined
+ >(undefined);
+
+ const metricFromUrl = (searchParams.get("metric") || undefined) as
+ | Metric
+ | undefined;
+
+ const [metric, setMetric] = useState(metricFromUrl);
+
+ function handleMetricChange(newMetric: Metric | undefined) {
+ if (newMetric) {
+ searchParams.set("metric", newMetric);
+ setSearchParams(searchParams);
+ } else {
+ searchParams.delete("metric");
+ setSearchParams(searchParams);
+ }
+ setMetric(newMetric);
+ }
+
+ const [contextMenu, setContextMenu] = useState<
+ | {
+ position: { x: number; y: number };
+ fileDependencyManifest: DependencyManifest[string];
+ }
+ | undefined
+ >(undefined);
+
+ const [detailsPane, setDetailsPane] = useState<
+ | {
+ manifestId: string;
+ fileDependencyManifest: DependencyManifest[string];
+ fileAuditManifest: AuditManifest[string];
+ }
+ | undefined
+ >(undefined);
+
+ useEffect(() => {
+ setBusy(true);
+ const visualizer = new ProjectDependencyVisualizer(
+ containerRef.current as HTMLElement,
+ props.dependencyManifest,
+ props.auditManifest,
+ {
+ theme,
+ defaultMetric: metric,
+ onAfterNodeRightClick: (value: {
+ position: { x: number; y: number };
+ filePath: string;
+ }) => {
+ setContextMenu({
+ position: value.position,
+ fileDependencyManifest: props.dependencyManifest[value.filePath],
+ });
+ },
+ onAfterNodeDblClick: (filePath: string) => {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("fileId", filePath);
+ newSearchParams.delete("instanceId");
+ navigate(`?${newSearchParams.toString()}`);
+ },
+ },
+ );
+
+ setProjectVisualizer(visualizer);
+ setBusy(false);
+
+ return () => {
+ visualizer?.cy.destroy();
+ setProjectVisualizer(undefined);
+ };
+ }, [props.dependencyManifest, props.auditManifest]);
+
+ useEffect(() => {
+ if (projectVisualizer) {
+ projectVisualizer.setTargetMetric(metric);
+ }
+ }, [metric]);
+
+ useEffect(() => {
+ if (projectVisualizer) {
+ if (props.highlightedCytoscapeRef) {
+ projectVisualizer.highlightNode(props.highlightedCytoscapeRef);
+ } else {
+ projectVisualizer.unhighlightNodes();
+ }
+ }
+ }, [props.highlightedCytoscapeRef]);
+
+ useEffect(() => {
+ if (projectVisualizer) {
+ projectVisualizer.updateTheme(theme);
+ }
+ }, [theme]);
+
+ return (
+
+
+
+
+ projectVisualizer?.layoutGraph(projectVisualizer.cy)}
+ >
+
+
+
+
+
setDetailsPane(undefined)}
+ />
+
+ setContextMenu(undefined)}
+ onOpenDetails={(filePath) => {
+ setDetailsPane({
+ manifestId: props.manifestId,
+ fileDependencyManifest: props.dependencyManifest[filePath],
+ fileAuditManifest: props.auditManifest[filePath],
+ });
+ }}
+ />
+
+ );
+}
diff --git a/viewer/src/components/DependencyVisualizer/visualizers/SymbolVisualizer.tsx b/viewer/src/components/DependencyVisualizer/visualizers/SymbolVisualizer.tsx
new file mode 100644
index 00000000..8533572e
--- /dev/null
+++ b/viewer/src/components/DependencyVisualizer/visualizers/SymbolVisualizer.tsx
@@ -0,0 +1,200 @@
+import { useEffect, useRef, useState } from "react";
+import { useNavigate, useSearchParams } from "react-router-dom";
+import Controls from "../components/controls/Controls.tsx";
+import GraphDepthExtension from "../components/controls/ControlExtensions/GraphDepthExtension.tsx";
+import SymbolContextMenu from "../components/contextMenu/SymbolContextMenu.tsx";
+import SymbolDetailsPane from "../components/detailsPanes/SymbolDetailsPane.tsx";
+import { SymbolDependencyVisualizer } from "../../../cytoscape/symbolDependencyVisualizer/index.ts";
+import type { DependencyManifest } from "../../../types/dependencyManifest.ts";
+import type { AuditManifest } from "../../../types/auditManifest.ts";
+import { useTheme } from "../../../contexts/ThemeProvider.tsx";
+
+interface SymbolVisualizerProps {
+ manifestId: string;
+ dependencyManifest: DependencyManifest;
+ auditManifest: AuditManifest;
+ highlightedCytoscapeRef:
+ | { filePath: string; symbolId: string | undefined }
+ | undefined;
+ fileId: string;
+ instanceId: string;
+}
+
+export default function SymbolVisualizer(props: SymbolVisualizerProps) {
+ const navigate = useNavigate();
+ const { theme } = useTheme();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const containerRef = useRef(null);
+ const [busy, setBusy] = useState(true);
+ const [symbolVisualizer, setSymbolVisualizer] = useState<
+ SymbolDependencyVisualizer | undefined
+ >(undefined);
+
+ const dependencyDepthFromUrl =
+ (searchParams.get("dependencyDepth") || undefined) as number | undefined;
+ const dependentDepthFromUrl =
+ (searchParams.get("dependentDepth") || undefined) as number | undefined;
+
+ const [dependencyDepth, setDependencyDepth] = useState(
+ dependencyDepthFromUrl || 3,
+ );
+ const [dependentDepth, setDependentDepth] = useState(
+ dependentDepthFromUrl || 0,
+ );
+
+ function handleDependencyDepthChange(depth: number) {
+ searchParams.set("dependencyDepth", depth.toString());
+ setSearchParams(searchParams);
+ setDependencyDepth(depth);
+ }
+
+ function handleDependentDepthChange(depth: number) {
+ searchParams.set("dependentDepth", depth.toString());
+ setSearchParams(searchParams);
+ setDependentDepth(depth);
+ }
+
+ const [contextMenu, setContextMenu] = useState<
+ | {
+ position: { x: number; y: number };
+ fileDependencyManifest: DependencyManifest[string];
+ symbolDependencyManifest: DependencyManifest[string]["symbols"][string];
+ }
+ | undefined
+ >(undefined);
+
+ const [detailsPane, setDetailsPane] = useState<
+ | {
+ manifestId: string;
+ fileDependencyManifest: DependencyManifest[string];
+ symbolDependencyManifest: DependencyManifest[string]["symbols"][string];
+ fileAuditManifest: AuditManifest[string];
+ symbolAuditManifest: AuditManifest[string]["symbols"][string];
+ }
+ | undefined
+ >(undefined);
+
+ useEffect(() => {
+ setBusy(true);
+
+ if (!props.fileId || !props.instanceId) {
+ return;
+ }
+
+ const visualizer = new SymbolDependencyVisualizer(
+ containerRef.current as HTMLElement,
+ props.fileId,
+ props.instanceId,
+ dependencyDepth,
+ dependentDepth,
+ props.dependencyManifest,
+ props.auditManifest,
+ {
+ theme,
+ onAfterNodeRightClick: (value: {
+ position: { x: number; y: number };
+ filePath: string;
+ symbolId: string;
+ }) => {
+ const fileDependencyManifest =
+ props.dependencyManifest[value.filePath];
+ const symbolDependencyManifest =
+ fileDependencyManifest.symbols[value.symbolId];
+ setContextMenu({
+ position: value.position,
+ fileDependencyManifest,
+ symbolDependencyManifest,
+ });
+ },
+ onAfterNodeDblClick: (filePath: string, symbolId: string) => {
+ const newSearchParams = new URLSearchParams(searchParams);
+ newSearchParams.set("fileId", filePath);
+ newSearchParams.set("instanceId", symbolId);
+ navigate(`?${newSearchParams.toString()}`);
+ },
+ },
+ );
+
+ setSymbolVisualizer(visualizer);
+ setBusy(false);
+
+ return () => {
+ visualizer?.cy.destroy();
+ setSymbolVisualizer(undefined);
+ };
+ }, [
+ props.dependencyManifest,
+ props.auditManifest,
+ props.fileId,
+ props.instanceId,
+ dependencyDepth,
+ dependentDepth,
+ ]);
+
+ useEffect(() => {
+ if (symbolVisualizer) {
+ if (props.highlightedCytoscapeRef) {
+ symbolVisualizer.highlightNode(props.highlightedCytoscapeRef);
+ } else {
+ symbolVisualizer.unhighlightNodes();
+ }
+ }
+ }, [props.highlightedCytoscapeRef]);
+
+ useEffect(() => {
+ if (symbolVisualizer) {
+ symbolVisualizer.updateTheme(theme);
+ }
+ }, [theme]);
+
+ return (
+
+
+
+
+ symbolVisualizer?.layoutGraph(symbolVisualizer.cy)}
+ >
+
+
+
+
+
setContextMenu(undefined)}
+ onOpenDetails={(filePath, symbolId) => {
+ const fileDependencyManifest = props.dependencyManifest[filePath];
+ const symbolDependencyManifest =
+ fileDependencyManifest.symbols[symbolId];
+ const fileAuditManifest = props.auditManifest[filePath];
+ const symbolAuditManifest = fileAuditManifest.symbols[symbolId];
+ setDetailsPane({
+ manifestId: props.manifestId,
+ fileDependencyManifest,
+ symbolDependencyManifest,
+ fileAuditManifest,
+ symbolAuditManifest,
+ });
+ }}
+ />
+
+ setDetailsPane(undefined)}
+ />
+
+ );
+}
diff --git a/viewer/src/components/shadcn/Alert.tsx b/viewer/src/components/shadcn/Alert.tsx
new file mode 100644
index 00000000..9ef9269b
--- /dev/null
+++ b/viewer/src/components/shadcn/Alert.tsx
@@ -0,0 +1,66 @@
+import type * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "../../lib/utils.ts";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
+ {
+ variants: {
+ variant: {
+ default: "bg-card text-card-foreground",
+ destructive:
+ "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+function Alert({
+ className,
+ variant,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Alert, AlertDescription, AlertTitle };
diff --git a/viewer/src/components/shadcn/Breadcrumb.tsx b/viewer/src/components/shadcn/Breadcrumb.tsx
new file mode 100644
index 00000000..3de86be9
--- /dev/null
+++ b/viewer/src/components/shadcn/Breadcrumb.tsx
@@ -0,0 +1,109 @@
+import type * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { ChevronRight, MoreHorizontal } from "lucide-react";
+
+import { cn } from "../../lib/utils.ts";
+
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return ;
+}
+
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+
+ );
+}
+
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+
+ );
+}
+
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function BreadcrumbSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+ svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? }
+
+ );
+}
+
+function BreadcrumbEllipsis({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+
+ More
+
+ );
+}
+
+export {
+ Breadcrumb,
+ BreadcrumbEllipsis,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+};
diff --git a/viewer/src/components/shadcn/Button.tsx b/viewer/src/components/shadcn/Button.tsx
new file mode 100644
index 00000000..692c5c04
--- /dev/null
+++ b/viewer/src/components/shadcn/Button.tsx
@@ -0,0 +1,61 @@
+import type * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "../../lib/utils.ts";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "size-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}:
+ & React.ComponentProps<"button">
+ & VariantProps
+ & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/viewer/src/components/shadcn/Card.tsx b/viewer/src/components/shadcn/Card.tsx
new file mode 100644
index 00000000..fe5171b8
--- /dev/null
+++ b/viewer/src/components/shadcn/Card.tsx
@@ -0,0 +1,92 @@
+import type * as React from "react";
+
+import { cn } from "../../lib/utils.ts";
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+};
diff --git a/viewer/src/components/shadcn/Dialog.tsx b/viewer/src/components/shadcn/Dialog.tsx
new file mode 100644
index 00000000..9da847c0
--- /dev/null
+++ b/viewer/src/components/shadcn/Dialog.tsx
@@ -0,0 +1,141 @@
+import type * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { XIcon } from "lucide-react";
+
+import { cn } from "../../lib/utils.ts";
+
+function Dialog({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean;
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ );
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+};
diff --git a/viewer/src/components/shadcn/Dropdownmenu.tsx b/viewer/src/components/shadcn/Dropdownmenu.tsx
new file mode 100644
index 00000000..135a9a6e
--- /dev/null
+++ b/viewer/src/components/shadcn/Dropdownmenu.tsx
@@ -0,0 +1,255 @@
+import type * as React from "react";
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
+
+import { cn } from "../../lib/utils.ts";
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+ variant?: "default" | "destructive";
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ );
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ );
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ );
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean;
+}) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuPortal,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+};
diff --git a/viewer/src/components/shadcn/Input.tsx b/viewer/src/components/shadcn/Input.tsx
new file mode 100644
index 00000000..867627a1
--- /dev/null
+++ b/viewer/src/components/shadcn/Input.tsx
@@ -0,0 +1,21 @@
+import type * as React from "react";
+
+import { cn } from "../../lib/utils.ts";
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/viewer/src/components/shadcn/Label.tsx b/viewer/src/components/shadcn/Label.tsx
new file mode 100644
index 00000000..679ec4cd
--- /dev/null
+++ b/viewer/src/components/shadcn/Label.tsx
@@ -0,0 +1,22 @@
+import type * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+
+import { cn } from "../../lib/utils.ts";
+
+function Label({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Label };
diff --git a/viewer/src/components/shadcn/Scrollarea.tsx b/viewer/src/components/shadcn/Scrollarea.tsx
new file mode 100644
index 00000000..1eeb9d49
--- /dev/null
+++ b/viewer/src/components/shadcn/Scrollarea.tsx
@@ -0,0 +1,56 @@
+import type * as React from "react";
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
+
+import { cn } from "../../lib/utils.ts";
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ );
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+export { ScrollArea, ScrollBar };
diff --git a/viewer/src/components/shadcn/Separator.tsx b/viewer/src/components/shadcn/Separator.tsx
new file mode 100644
index 00000000..ec76887c
--- /dev/null
+++ b/viewer/src/components/shadcn/Separator.tsx
@@ -0,0 +1,26 @@
+import type * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+
+import { cn } from "../../lib/utils.ts";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/viewer/src/components/shadcn/Sheet.tsx b/viewer/src/components/shadcn/Sheet.tsx
new file mode 100644
index 00000000..cf4f33b4
--- /dev/null
+++ b/viewer/src/components/shadcn/Sheet.tsx
@@ -0,0 +1,137 @@
+import type * as React from "react";
+import * as SheetPrimitive from "@radix-ui/react-dialog";
+import { XIcon } from "lucide-react";
+
+import { cn } from "../../lib/utils.ts";
+
+function Sheet({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function SheetTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetClose({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetPortal({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function SheetOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SheetContent({
+ className,
+ children,
+ side = "right",
+ ...props
+}: React.ComponentProps & {
+ side?: "top" | "right" | "bottom" | "left";
+}) {
+ return (
+
+
+
+ {children}
+
+
+ Close
+
+
+
+ );
+}
+
+function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SheetTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SheetDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+};
diff --git a/viewer/src/components/shadcn/Sidebar.tsx b/viewer/src/components/shadcn/Sidebar.tsx
new file mode 100644
index 00000000..27327ba6
--- /dev/null
+++ b/viewer/src/components/shadcn/Sidebar.tsx
@@ -0,0 +1,721 @@
+"use client";
+
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import { PanelLeftIcon } from "lucide-react";
+
+import { useIsMobile } from "./hooks/use-mobile.tsx";
+import { cn } from "../../lib/utils.ts";
+import { Button } from "./Button.tsx";
+import { Input } from "./Input.tsx";
+import { Separator } from "./Separator.tsx";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "./Sheet.tsx";
+import { Skeleton } from "./Skeleton.tsx";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "./Tooltip.tsx";
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state";
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+const SIDEBAR_WIDTH = "16rem";
+const SIDEBAR_WIDTH_MOBILE = "18rem";
+const SIDEBAR_WIDTH_ICON = "3rem";
+const SIDEBAR_KEYBOARD_SHORTCUT = "b";
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed";
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ openMobile: boolean;
+ setOpenMobile: (open: boolean) => void;
+ isMobile: boolean;
+ toggleSidebar: () => void;
+};
+
+const SidebarContext = React.createContext(null);
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext);
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.");
+ }
+
+ return context;
+}
+
+function SidebarProvider({
+ defaultOpen = true,
+ open: openProp,
+ onOpenChange: setOpenProp,
+ className,
+ style,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ defaultOpen?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+}) {
+ const isMobile = useIsMobile();
+ const [openMobile, setOpenMobile] = React.useState(false);
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen);
+ const open = openProp ?? _open;
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value;
+ if (setOpenProp) {
+ setOpenProp(openState);
+ } else {
+ _setOpen(openState);
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie =
+ `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ },
+ [setOpenProp, open],
+ );
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
+ }, [isMobile, setOpen, setOpenMobile]);
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
+ (event.metaKey || event.ctrlKey)
+ ) {
+ event.preventDefault();
+ toggleSidebar();
+ }
+ };
+
+ globalThis.addEventListener("keydown", handleKeyDown);
+ return () => globalThis.removeEventListener("keydown", handleKeyDown);
+ }, [toggleSidebar]);
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed";
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function Sidebar({
+ side = "left",
+ variant = "sidebar",
+ collapsible = "offcanvas",
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ side?: "left" | "right";
+ variant?: "sidebar" | "floating" | "inset";
+ collapsible?: "offcanvas" | "icon" | "none";
+}) {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ );
+}
+
+function SidebarTrigger({
+ className,
+ onClick,
+ ...props
+}: React.ComponentProps) {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+ {
+ onClick?.(event);
+ toggleSidebar();
+ }}
+ {...props}
+ >
+
+ Toggle Sidebar
+
+ );
+}
+
+function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+}
+
+function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
+ return (
+
+ );
+}
+
+function SidebarInput({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarGroupLabel({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "div";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarGroupAction({
+ className,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"button"> & { asChild?: boolean }) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarGroupContent({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+function SidebarMenuButton({
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ isActive?: boolean;
+ tooltip?: string | React.ComponentProps;
+} & VariantProps) {
+ const Comp = asChild ? Slot : "button";
+ const { isMobile, state } = useSidebar();
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ };
+ }
+
+ return (
+
+ {button}
+
+
+ );
+}
+
+function SidebarMenuAction({
+ className,
+ asChild = false,
+ showOnHover = false,
+ ...props
+}: React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ showOnHover?: boolean;
+}) {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 md:after:hidden",
+ "peer-data-[size=sm]/menu-button:top-1",
+ "peer-data-[size=default]/menu-button:top-1.5",
+ "peer-data-[size=lg]/menu-button:top-2.5",
+ "group-data-[collapsible=icon]:hidden",
+ showOnHover &&
+ "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function SidebarMenuBadge({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSkeleton({
+ className,
+ showIcon = false,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showIcon?: boolean;
+}) {
+ // Random width between 50 to 90%.
+ const width = React.useMemo(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`;
+ }, []);
+
+ return (
+
+ {showIcon && (
+
+ )}
+
+
+ );
+}
+
+function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSubItem({
+ className,
+ ...props
+}: React.ComponentProps<"li">) {
+ return (
+
+ );
+}
+
+function SidebarMenuSubButton({
+ asChild = false,
+ size = "md",
+ isActive = false,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+ size?: "sm" | "md";
+ isActive?: boolean;
+}) {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+ svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
+ size === "sm" && "text-xs",
+ size === "md" && "text-sm",
+ "group-data-[collapsible=icon]:hidden",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+};
diff --git a/viewer/src/components/shadcn/Skeleton.tsx b/viewer/src/components/shadcn/Skeleton.tsx
new file mode 100644
index 00000000..89b225cc
--- /dev/null
+++ b/viewer/src/components/shadcn/Skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "../../lib/utils.ts";
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+export { Skeleton };
diff --git a/viewer/src/components/shadcn/Slider.tsx b/viewer/src/components/shadcn/Slider.tsx
new file mode 100644
index 00000000..eb8cbdf4
--- /dev/null
+++ b/viewer/src/components/shadcn/Slider.tsx
@@ -0,0 +1,64 @@
+import * as React from "react";
+import * as SliderPrimitive from "@radix-ui/react-slider";
+
+import { cn } from "../../lib/utils.ts";
+
+function Slider({
+ className,
+ defaultValue,
+ value,
+ min = 0,
+ max = 100,
+ ...props
+}: React.ComponentProps) {
+ const _values = React.useMemo(
+ () =>
+ Array.isArray(value)
+ ? value
+ : Array.isArray(defaultValue)
+ ? defaultValue
+ : [min, max],
+ [value, defaultValue, min, max],
+ );
+
+ return (
+
+
+
+
+ {Array.from(
+ { length: _values.length },
+ (_, index) => (
+
+ ),
+ )}
+
+ );
+}
+
+export { Slider };
diff --git a/viewer/src/components/shadcn/Tooltip.tsx b/viewer/src/components/shadcn/Tooltip.tsx
new file mode 100644
index 00000000..6d9b85ec
--- /dev/null
+++ b/viewer/src/components/shadcn/Tooltip.tsx
@@ -0,0 +1,59 @@
+import type * as React from "react";
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+
+import { cn } from "../../lib/utils.ts";
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ );
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ );
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return ;
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ );
+}
+
+export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger };
diff --git a/viewer/src/components/shadcn/hooks/use-mobile.tsx b/viewer/src/components/shadcn/hooks/use-mobile.tsx
new file mode 100644
index 00000000..0606ed40
--- /dev/null
+++ b/viewer/src/components/shadcn/hooks/use-mobile.tsx
@@ -0,0 +1,23 @@
+import * as React from "react";
+
+const MOBILE_BREAKPOINT = 768;
+
+export function useIsMobile() {
+ const [isMobile, setIsMobile] = React.useState(
+ undefined,
+ );
+
+ React.useEffect(() => {
+ const mql = globalThis.matchMedia(
+ `(max-width: ${MOBILE_BREAKPOINT - 1}px)`,
+ );
+ const onChange = () => {
+ setIsMobile(globalThis.innerWidth < MOBILE_BREAKPOINT);
+ };
+ mql.addEventListener("change", onChange);
+ setIsMobile(globalThis.innerWidth < MOBILE_BREAKPOINT);
+ return () => mql.removeEventListener("change", onChange);
+ }, []);
+
+ return !!isMobile;
+}
diff --git a/viewer/src/contexts/ThemeProvider.tsx b/viewer/src/contexts/ThemeProvider.tsx
new file mode 100644
index 00000000..9c06d8fb
--- /dev/null
+++ b/viewer/src/contexts/ThemeProvider.tsx
@@ -0,0 +1,64 @@
+import { createContext, useContext, useEffect, useState } from "react";
+
+type Theme = "dark" | "light";
+
+type ThemeProviderProps = {
+ children: React.ReactNode;
+ defaultTheme?: Theme;
+ storageKey?: string;
+};
+
+type ThemeProviderState = {
+ theme: Theme;
+ setTheme: (theme: Theme) => void;
+};
+
+function getSystemTheme() {
+ const systemTheme = globalThis.matchMedia("(prefers-color-scheme: dark)");
+ return systemTheme.matches ? "dark" : "light";
+}
+
+const initialState: ThemeProviderState = {
+ theme: getSystemTheme(),
+ setTheme: () => null,
+};
+
+const ThemeProviderContext = createContext(initialState);
+
+export function ThemeProvider({
+ children,
+ defaultTheme = getSystemTheme(),
+ storageKey = "napi-ui-theme",
+}: ThemeProviderProps) {
+ const [theme, setTheme] = useState(
+ () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
+ );
+
+ useEffect(() => {
+ const root = globalThis.document.documentElement;
+ root.classList.remove("light", "dark");
+ root.classList.add(theme);
+ }, [theme]);
+
+ const value = {
+ theme,
+ setTheme: (theme: Theme) => {
+ localStorage.setItem(storageKey, theme);
+ setTheme(theme);
+ },
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export const useTheme = () => {
+ const context = useContext(ThemeProviderContext);
+ if (context === undefined) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return context;
+};
diff --git a/viewer/src/cytoscape/elements/file.ts b/viewer/src/cytoscape/elements/file.ts
new file mode 100644
index 00000000..bf81f2af
--- /dev/null
+++ b/viewer/src/cytoscape/elements/file.ts
@@ -0,0 +1,293 @@
+import type * as auditManifestTypes from "../../types/auditManifest";
+import * as dependencyManifestTypes from "../../types/dependencyManifest";
+import {
+ getCollapsedSymbolNodeLabel,
+ getExpandedSymbolNodeLabel,
+ getNodeWidthAndHeightFromLabel,
+} from "../label/index.ts";
+import { getMetricsSeverityForNode } from "../metrics/index.ts";
+import type { SymbolNapiNodeData } from "./types.ts";
+import type { EdgeDefinition, NodeDefinition } from "cytoscape";
+
+export function computeNodeId(fileId: string, symbolId: string) {
+ return `${fileId}:${symbolId}`;
+}
+
+function createNodeData(params: {
+ id: string;
+ fileName: string;
+ symbolName: string;
+ symbolType: string;
+ isExternal: boolean;
+ metricsSeverity: {
+ [dependencyManifestTypes.metricLinesCount]: number;
+ [dependencyManifestTypes.metricCodeLineCount]: number;
+ [dependencyManifestTypes.metricCodeCharacterCount]: number;
+ [dependencyManifestTypes.metricCharacterCount]: number;
+ [dependencyManifestTypes.metricDependencyCount]: number;
+ [dependencyManifestTypes.metricDependentCount]: number;
+ [dependencyManifestTypes.metricCyclomaticComplexity]: number;
+ };
+ expandedLabel: string;
+ collapsedLabel: string;
+}): SymbolNapiNodeData {
+ // Calculate dimensions for expanded and collapsed views
+ const { width: expandedWidth, height: expandedHeight } =
+ getNodeWidthAndHeightFromLabel(
+ params.expandedLabel,
+ );
+
+ const { width: collapsedWidth, height: collapsedHeight } =
+ getNodeWidthAndHeightFromLabel(
+ params.collapsedLabel,
+ );
+
+ // Create the node data structure
+ return {
+ id: params.id,
+ position: { x: 0, y: 0 },
+ fileName: params.fileName,
+ isExternal: params.isExternal,
+ symbolName: params.symbolName,
+ symbolType: params.symbolType,
+ metricsSeverity: params.metricsSeverity,
+ expanded: {
+ label: params.expandedLabel,
+ width: expandedWidth,
+ height: expandedHeight,
+ },
+ collapsed: {
+ label: params.collapsedLabel,
+ width: collapsedWidth,
+ height: collapsedHeight,
+ },
+ };
+}
+
+interface CustomNodeDefinition extends NodeDefinition {
+ data: SymbolNapiNodeData & object;
+}
+
+function processDependencies(
+ symbol: dependencyManifestTypes.DependencyManifest[string]["symbols"][string],
+ symbolNodeId: string,
+ dependencyManifest: dependencyManifestTypes.DependencyManifest,
+ auditManifest: auditManifestTypes.AuditManifest,
+ nodes: CustomNodeDefinition[],
+ edges: EdgeDefinition[],
+) {
+ Object.values(symbol.dependencies).forEach((dep) => {
+ let depDependencyManifest:
+ | dependencyManifestTypes.DependencyManifest[string]
+ | undefined;
+ let depAuditManifest: auditManifestTypes.AuditManifest[string] | undefined;
+
+ if (!dep.isExternal) {
+ depDependencyManifest = dependencyManifest[dep.id];
+ depAuditManifest = auditManifest[dep.id];
+ }
+
+ // For each symbol this depends on, create an edge
+ Object.keys(dep.symbols).forEach((depSymbolName) => {
+ const depSymbolNodeId = computeNodeId(dep.id, depSymbolName);
+
+ // Check if node already exists
+ const existingNode = nodes.find(
+ (node) => node.data.id === depSymbolNodeId,
+ );
+
+ if (!existingNode) {
+ let depSymbolType: dependencyManifestTypes.SymbolType | "unknown" =
+ "unknown";
+ if (depDependencyManifest) {
+ depSymbolType = depDependencyManifest.symbols[depSymbolName].type;
+ }
+
+ // Create label for dependency nodes
+ const expandedLabel = getExpandedSymbolNodeLabel({
+ currentFileId: dep.id,
+ fileName: dep.id,
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ symbolAuditManifest: depAuditManifest?.symbols[depSymbolName],
+ });
+
+ const collapsedLabel = getCollapsedSymbolNodeLabel({
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ });
+
+ const metricsSeverity = getMetricsSeverityForNode(
+ depAuditManifest?.symbols[depSymbolName],
+ );
+
+ const nodeData = createNodeData({
+ id: depSymbolNodeId,
+ fileName: dep.id,
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ isExternal: dep.isExternal,
+ metricsSeverity,
+ expandedLabel,
+ collapsedLabel,
+ });
+
+ nodes.push({ data: nodeData });
+ }
+
+ // Add the edge
+ const edgeId = `${depSymbolNodeId}->${symbolNodeId}`;
+ edges.push({
+ data: {
+ id: edgeId,
+ source: depSymbolNodeId,
+ target: symbolNodeId,
+ },
+ });
+ });
+ });
+}
+
+function processDependents(
+ symbol: dependencyManifestTypes.DependencyManifest[string]["symbols"][string],
+ symbolNodeId: string,
+ dependencyManifest: dependencyManifestTypes.DependencyManifest,
+ auditManifest: auditManifestTypes.AuditManifest,
+ nodes: CustomNodeDefinition[],
+ edges: EdgeDefinition[],
+) {
+ Object.values(symbol.dependents).forEach((dep) => {
+ const depDependencyManifest = dependencyManifest[dep.id];
+ const depAuditManifest = auditManifest[dep.id];
+
+ Object.keys(dep.symbols).forEach((depSymbolName) => {
+ const depSymbolNodeId = computeNodeId(dep.id, depSymbolName);
+
+ // Check if node already exists
+ const existingNode = nodes.find(
+ (node) => node.data.id === depSymbolNodeId,
+ );
+
+ if (!existingNode) {
+ const depSymbolType = depDependencyManifest.symbols[depSymbolName].type;
+
+ // Create label for dependent nodes
+ const expandedLabel = getExpandedSymbolNodeLabel({
+ currentFileId: dep.id,
+ fileName: dep.id,
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ symbolAuditManifest: depAuditManifest?.symbols[depSymbolName],
+ });
+
+ const metricsSeverity = getMetricsSeverityForNode(
+ depAuditManifest?.symbols[depSymbolName],
+ );
+
+ const isExternal = !dependencyManifest[dep.id];
+
+ const nodeData = createNodeData({
+ id: depSymbolNodeId,
+ fileName: dep.id,
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ isExternal,
+ metricsSeverity,
+ expandedLabel,
+ collapsedLabel: depSymbolName,
+ });
+
+ nodes.push({ data: nodeData });
+ }
+
+ // Add the edge
+ const edgeId = `${symbolNodeId}->${depSymbolNodeId}`;
+ edges.push({
+ data: {
+ id: edgeId,
+ source: symbolNodeId,
+ target: depSymbolNodeId,
+ },
+ });
+ });
+ });
+}
+
+export function getSymbolElementsInFile(
+ fileId: string,
+ dependencyManifest: dependencyManifestTypes.DependencyManifest,
+ auditManifest: auditManifestTypes.AuditManifest,
+) {
+ const fileManifest = dependencyManifest[fileId];
+ if (!fileManifest) {
+ console.error(`File manifest not found for ${fileId}`);
+ return [];
+ }
+
+ const fileAuditManifest = auditManifest[fileId];
+ const nodes: CustomNodeDefinition[] = [];
+ const edges: EdgeDefinition[] = [];
+
+ // First pass: Create nodes for each symbol in the file
+ Object.values(fileManifest.symbols).forEach((symbol) => {
+ const symbolAuditManifest = fileAuditManifest.symbols[symbol.id];
+ const symbolNodeId = computeNodeId(fileId, symbol.id);
+
+ // Create labels for the symbol
+ const expandedLabel = getExpandedSymbolNodeLabel({
+ currentFileId: fileId,
+ fileName: fileId,
+ symbolName: symbol.id,
+ symbolType: symbol.type,
+ symbolAuditManifest,
+ });
+ const collapsedLabel = getCollapsedSymbolNodeLabel({
+ symbolName: symbol.id,
+ symbolType: symbol.type,
+ });
+
+ const metricsSeverity = getMetricsSeverityForNode(symbolAuditManifest);
+
+ const isExternal = !dependencyManifest[fileId];
+
+ const nodeData = createNodeData({
+ id: symbolNodeId,
+ fileName: fileId,
+ symbolName: symbol.id,
+ symbolType: symbol.type,
+ isExternal,
+ metricsSeverity,
+ expandedLabel,
+ collapsedLabel,
+ });
+
+ nodes.push({ data: nodeData });
+ });
+
+ // Second pass: Create nodes and edges for dependencies and dependents
+ Object.values(fileManifest.symbols).forEach((symbol) => {
+ const symbolNodeId = computeNodeId(fileId, symbol.id);
+
+ // Process dependencies
+ processDependencies(
+ symbol,
+ symbolNodeId,
+ dependencyManifest,
+ auditManifest,
+ nodes,
+ edges,
+ );
+
+ // Process dependents
+ processDependents(
+ symbol,
+ symbolNodeId,
+ dependencyManifest,
+ auditManifest,
+ nodes,
+ edges,
+ );
+ });
+
+ return [...nodes, ...edges];
+}
diff --git a/viewer/src/cytoscape/elements/project.ts b/viewer/src/cytoscape/elements/project.ts
new file mode 100644
index 00000000..bcdadf82
--- /dev/null
+++ b/viewer/src/cytoscape/elements/project.ts
@@ -0,0 +1,95 @@
+import type {
+ EdgeDefinition,
+ ElementDefinition,
+ NodeDefinition,
+} from "cytoscape";
+import type * as dependencyManifestTypes from "../../types/dependencyManifest";
+import type * as auditManifestTypes from "../../types/auditManifest";
+import {
+ getCollapsedFileNodeLabel,
+ getExpandedFileNodeLabel,
+ getNodeWidthAndHeightFromLabel,
+} from "../label/index.ts";
+import { getMetricsSeverityForNode } from "../metrics/index.ts";
+import type { FileNapiNodeData } from "./types.ts";
+
+export function getFileElementsInProject(
+ dependencyManifest: dependencyManifestTypes.DependencyManifest,
+ auditManifest: auditManifestTypes.AuditManifest,
+): ElementDefinition[] {
+ interface CustomNodeDefinition extends NodeDefinition {
+ data: FileNapiNodeData & object;
+ }
+
+ const nodes: CustomNodeDefinition[] = [];
+ const edges: EdgeDefinition[] = [];
+
+ Object.values(dependencyManifest).forEach((fileDependencyManifest) => {
+ const fileAuditManifest = auditManifest[fileDependencyManifest.id];
+
+ const expandedLabel = getExpandedFileNodeLabel({
+ fileName: fileDependencyManifest.id,
+ fileAuditManifest,
+ });
+ const { width: expandedWitdh, height: expandedHeight } =
+ getNodeWidthAndHeightFromLabel(
+ expandedLabel,
+ );
+
+ const collapsedLabel = getCollapsedFileNodeLabel({
+ fileName: fileDependencyManifest.id,
+ fileAuditManifest,
+ });
+ const { width: collapsedWidth, height: collapsedHeight } =
+ getNodeWidthAndHeightFromLabel(
+ collapsedLabel,
+ );
+
+ const metricsColors = getMetricsSeverityForNode(fileAuditManifest);
+
+ const nodeElement: CustomNodeDefinition = {
+ data: {
+ id: fileDependencyManifest.id,
+ // initial node position - will be updated by layout
+ position: { x: 0, y: 0 },
+ fileName: fileDependencyManifest.id,
+ isExternal: false,
+ metricsSeverity: metricsColors,
+ expanded: {
+ label: expandedLabel,
+ width: expandedWitdh,
+ height: expandedHeight,
+ },
+ collapsed: {
+ label: collapsedLabel,
+ width: collapsedWidth,
+ height: collapsedHeight,
+ },
+ },
+ };
+
+ nodes.push(nodeElement);
+
+ for (
+ const fileDependency of Object.values(fileDependencyManifest.dependencies)
+ ) {
+ if (fileDependency.isExternal) {
+ continue;
+ }
+
+ if (fileDependency.id === fileDependencyManifest.id) {
+ // ignore self-references
+ continue;
+ }
+
+ edges.push({
+ data: {
+ source: fileDependencyManifest.id,
+ target: fileDependency.id,
+ },
+ });
+ }
+ });
+
+ return [...nodes, ...edges];
+}
diff --git a/viewer/src/cytoscape/elements/symbol.ts b/viewer/src/cytoscape/elements/symbol.ts
new file mode 100644
index 00000000..9ffe3637
--- /dev/null
+++ b/viewer/src/cytoscape/elements/symbol.ts
@@ -0,0 +1,338 @@
+import type * as auditManifestTypes from "../../types/auditManifest";
+import * as dependencyManifestTypes from "../../types/dependencyManifest";
+import {
+ getCollapsedSymbolNodeLabel,
+ getExpandedSymbolNodeLabel,
+ getNodeWidthAndHeightFromLabel,
+} from "../label/index.ts";
+import { getMetricsSeverityForNode } from "../metrics/index.ts";
+import type { SymbolNapiNodeData } from "./types.ts";
+import type { EdgeDefinition, NodeDefinition } from "cytoscape";
+import { computeNodeId } from "./file.ts";
+
+function createNodeData(params: {
+ id: string;
+ fileName: string;
+ symbolName: string;
+ symbolType: string;
+ isExternal: boolean;
+ metricsSeverity: {
+ [dependencyManifestTypes.metricLinesCount]: number;
+ [dependencyManifestTypes.metricCodeLineCount]: number;
+ [dependencyManifestTypes.metricCodeCharacterCount]: number;
+ [dependencyManifestTypes.metricCharacterCount]: number;
+ [dependencyManifestTypes.metricDependencyCount]: number;
+ [dependencyManifestTypes.metricDependentCount]: number;
+ [dependencyManifestTypes.metricCyclomaticComplexity]: number;
+ };
+ expandedLabel: string;
+ collapsedLabel: string;
+}): SymbolNapiNodeData {
+ // Calculate dimensions for expanded and collapsed views
+ const { width: expandedWidth, height: expandedHeight } =
+ getNodeWidthAndHeightFromLabel(
+ params.expandedLabel,
+ );
+
+ const { width: collapsedWidth, height: collapsedHeight } =
+ getNodeWidthAndHeightFromLabel(
+ params.collapsedLabel,
+ );
+
+ // Create the node data structure
+ return {
+ id: params.id,
+ position: { x: 0, y: 0 },
+ fileName: params.fileName,
+ isExternal: params.isExternal,
+ symbolName: params.symbolName,
+ symbolType: params.symbolType,
+ metricsSeverity: params.metricsSeverity,
+ expanded: {
+ label: params.expandedLabel,
+ width: expandedWidth,
+ height: expandedHeight,
+ },
+ collapsed: {
+ label: params.collapsedLabel,
+ width: collapsedWidth,
+ height: collapsedHeight,
+ },
+ };
+}
+
+interface CustomNodeDefinition extends NodeDefinition {
+ data: SymbolNapiNodeData & object;
+}
+
+function traverseSymbolGraph(
+ symbol: dependencyManifestTypes.DependencyManifest[string]["symbols"][string],
+ symbolNodeId: string,
+ dependencyManifest: dependencyManifestTypes.DependencyManifest,
+ auditManifest: auditManifestTypes.AuditManifest,
+ nodeMap: Map,
+ edgeMap: Map,
+ currentDepth: number,
+ maxDepsDepth: number,
+ maxDependentsDepth: number,
+ visited: Set,
+) {
+ // Process dependencies if we haven't reached max depth
+ if (currentDepth < maxDepsDepth) {
+ Object.values(symbol.dependencies).forEach((dep) => {
+ let depDependencyManifest:
+ | dependencyManifestTypes.DependencyManifest[string]
+ | undefined;
+ let depAuditManifest:
+ | auditManifestTypes.AuditManifest[string]
+ | undefined;
+
+ if (!dep.isExternal) {
+ depDependencyManifest = dependencyManifest[dep.id];
+ depAuditManifest = auditManifest[dep.id];
+ }
+
+ Object.keys(dep.symbols).forEach((depSymbolName) => {
+ const depSymbolNodeId = computeNodeId(dep.id, depSymbolName);
+
+ // Skip if already visited
+ if (visited.has(depSymbolNodeId)) {
+ // Add edge even if node was already visited
+ const edgeId = `${depSymbolNodeId}->${symbolNodeId}`;
+ edgeMap.set(edgeId, {
+ data: {
+ id: edgeId,
+ source: depSymbolNodeId,
+ target: symbolNodeId,
+ },
+ });
+ return;
+ }
+ visited.add(depSymbolNodeId);
+
+ let depSymbolType: dependencyManifestTypes.SymbolType | "unknown" =
+ "unknown";
+ if (depDependencyManifest) {
+ depSymbolType = depDependencyManifest.symbols[depSymbolName].type;
+ }
+
+ const expandedLabel = getExpandedSymbolNodeLabel({
+ currentFileId: dep.id,
+ fileName: dep.id,
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ symbolAuditManifest: depAuditManifest?.symbols[depSymbolName],
+ });
+
+ const collapsedLabel = getCollapsedSymbolNodeLabel({
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ });
+
+ const metricsSeverity = getMetricsSeverityForNode(
+ depAuditManifest?.symbols[depSymbolName],
+ );
+
+ const nodeData = createNodeData({
+ id: depSymbolNodeId,
+ fileName: dep.id,
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ isExternal: dep.isExternal,
+ metricsSeverity,
+ expandedLabel,
+ collapsedLabel,
+ });
+
+ nodeMap.set(depSymbolNodeId, { data: nodeData });
+
+ // Add the edge
+ const edgeId = `${depSymbolNodeId}->${symbolNodeId}`;
+ edgeMap.set(edgeId, {
+ data: {
+ id: edgeId,
+ source: depSymbolNodeId,
+ target: symbolNodeId,
+ },
+ });
+
+ // Recursively process dependencies if not external
+ if (!dep.isExternal && depDependencyManifest) {
+ traverseSymbolGraph(
+ depDependencyManifest.symbols[depSymbolName],
+ depSymbolNodeId,
+ dependencyManifest,
+ auditManifest,
+ nodeMap,
+ edgeMap,
+ currentDepth + 1,
+ maxDepsDepth,
+ maxDependentsDepth,
+ visited,
+ );
+ }
+ });
+ });
+ }
+
+ // Process dependents if we haven't reached max depth
+ if (currentDepth < maxDependentsDepth) {
+ Object.values(symbol.dependents).forEach((dep) => {
+ const depDependencyManifest = dependencyManifest[dep.id];
+ const depAuditManifest = auditManifest[dep.id];
+
+ Object.keys(dep.symbols).forEach((depSymbolName) => {
+ const depSymbolNodeId = computeNodeId(dep.id, depSymbolName);
+
+ // Skip if already visited
+ if (visited.has(depSymbolNodeId)) {
+ // Add edge even if node was already visited
+ const edgeId = `${symbolNodeId}->${depSymbolNodeId}`;
+ edgeMap.set(edgeId, {
+ data: {
+ id: edgeId,
+ source: symbolNodeId,
+ target: depSymbolNodeId,
+ },
+ });
+ return;
+ }
+ visited.add(depSymbolNodeId);
+
+ const depSymbolType = depDependencyManifest.symbols[depSymbolName].type;
+
+ const expandedLabel = getExpandedSymbolNodeLabel({
+ currentFileId: dep.id,
+ fileName: dep.id,
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ symbolAuditManifest: depAuditManifest?.symbols[depSymbolName],
+ });
+
+ const metricsSeverity = getMetricsSeverityForNode(
+ depAuditManifest?.symbols[depSymbolName],
+ );
+
+ const isExternal = !dependencyManifest[dep.id];
+
+ const nodeData = createNodeData({
+ id: depSymbolNodeId,
+ fileName: dep.id,
+ symbolName: depSymbolName,
+ symbolType: depSymbolType,
+ isExternal,
+ metricsSeverity,
+ expandedLabel,
+ collapsedLabel: depSymbolName,
+ });
+
+ nodeMap.set(depSymbolNodeId, { data: nodeData });
+
+ // Add the edge
+ const edgeId = `${symbolNodeId}->${depSymbolNodeId}`;
+ edgeMap.set(edgeId, {
+ data: {
+ id: edgeId,
+ source: symbolNodeId,
+ target: depSymbolNodeId,
+ },
+ });
+
+ // Recursively process dependents
+ if (depDependencyManifest) {
+ traverseSymbolGraph(
+ depDependencyManifest.symbols[depSymbolName],
+ depSymbolNodeId,
+ dependencyManifest,
+ auditManifest,
+ nodeMap,
+ edgeMap,
+ currentDepth + 1,
+ maxDepsDepth,
+ maxDependentsDepth,
+ visited,
+ );
+ }
+ });
+ });
+ }
+}
+
+export function getSymbolElementsForSymbol(
+ fileName: string,
+ symbolId: string,
+ dependencyDepth: number,
+ dependentDepth: number,
+ dependencyManifest: dependencyManifestTypes.DependencyManifest,
+ auditManifest: auditManifestTypes.AuditManifest,
+) {
+ const fileManifest = dependencyManifest[fileName];
+ if (!fileManifest) {
+ console.error(`File manifest not found for ${fileName}`);
+ return [];
+ }
+ const symbolManifest = fileManifest.symbols[symbolId];
+ if (!symbolManifest) {
+ console.error(`Symbol manifest not found for ${symbolId}`);
+ return [];
+ }
+
+ const fileAuditManifest = auditManifest[fileName];
+ const symbolAuditManifest = fileAuditManifest.symbols[symbolId];
+
+ const nodeMap: Map = new Map();
+ const edgeMap: Map = new Map();
+ const visited = new Set();
+
+ const symbolNodeId = computeNodeId(fileName, symbolManifest.id);
+ visited.add(symbolNodeId);
+
+ // Create labels for the symbol
+ const expandedLabel = getExpandedSymbolNodeLabel({
+ currentFileId: fileName,
+ fileName,
+ symbolName: symbolManifest.id,
+ symbolType: symbolManifest.type,
+ symbolAuditManifest,
+ });
+ const collapsedLabel = getCollapsedSymbolNodeLabel({
+ symbolName: symbolManifest.id,
+ symbolType: symbolManifest.type,
+ });
+
+ const metricsSeverity = getMetricsSeverityForNode(symbolAuditManifest);
+
+ const isExternal = !dependencyManifest[fileName];
+
+ const nodeData = createNodeData({
+ id: symbolNodeId,
+ fileName,
+ symbolName: symbolManifest.id,
+ symbolType: symbolManifest.type,
+ isExternal,
+ metricsSeverity,
+ expandedLabel,
+ collapsedLabel,
+ });
+
+ nodeMap.set(symbolNodeId, { data: nodeData });
+
+ // Use the recursive function to process dependencies and dependents
+ traverseSymbolGraph(
+ symbolManifest,
+ symbolNodeId,
+ dependencyManifest,
+ auditManifest,
+ nodeMap,
+ edgeMap,
+ 0,
+ dependencyDepth,
+ dependentDepth,
+ visited,
+ );
+
+ const nodes = Array.from(nodeMap.values());
+ const edges = Array.from(edgeMap.values());
+
+ return [...nodes, ...edges];
+}
diff --git a/viewer/src/cytoscape/elements/types.ts b/viewer/src/cytoscape/elements/types.ts
new file mode 100644
index 00000000..e8a47aba
--- /dev/null
+++ b/viewer/src/cytoscape/elements/types.ts
@@ -0,0 +1,37 @@
+import * as dependencyManifestTypes from "../../types/dependencyManifest";
+
+export interface NapiNodeData {
+ id: string;
+ position: { x: number; y: number };
+ metricsSeverity: {
+ [dependencyManifestTypes.metricLinesCount]: number;
+ [dependencyManifestTypes.metricCodeLineCount]: number;
+ [dependencyManifestTypes.metricCodeCharacterCount]: number;
+ [dependencyManifestTypes.metricCharacterCount]: number;
+ [dependencyManifestTypes.metricDependencyCount]: number;
+ [dependencyManifestTypes.metricDependentCount]: number;
+ [dependencyManifestTypes.metricCyclomaticComplexity]: number;
+ };
+ expanded: {
+ label: string;
+ width: number;
+ height: number;
+ };
+ collapsed: {
+ label: string;
+ width: number;
+ height: number;
+ };
+}
+
+export interface FileNapiNodeData extends NapiNodeData {
+ fileName: string;
+ isExternal: boolean;
+}
+
+export interface SymbolNapiNodeData extends NapiNodeData {
+ fileName: string;
+ isExternal: boolean;
+ symbolName: string;
+ symbolType: string;
+}
diff --git a/viewer/src/cytoscape/fileDependencyVisualizer/index.ts b/viewer/src/cytoscape/fileDependencyVisualizer/index.ts
new file mode 100644
index 00000000..fa60f9b4
--- /dev/null
+++ b/viewer/src/cytoscape/fileDependencyVisualizer/index.ts
@@ -0,0 +1,353 @@
+import type * as auditManifestTypes from "../../types/auditManifest";
+import * as dependencyManifestTypes from "../../types/dependencyManifest";
+import type {
+ Collection,
+ Core,
+ EdgeSingular,
+ EventObjectNode,
+ NodeSingular,
+} from "cytoscape";
+import fcose from "cytoscape-fcose";
+import type { SymbolNapiNodeData } from "../elements/types.ts";
+import cytoscape from "cytoscape";
+import { mainLayout } from "../layout/index.ts";
+import { getCytoscapeStylesheet } from "../styles/index.ts";
+import { computeNodeId, getSymbolElementsInFile } from "../elements/file.ts";
+
+/**
+ * FileDependencyVisualizer creates an interactive graph of symbol dependencies within a file.
+ *
+ * This visualization provides a detailed view of internal file structure where:
+ * - Nodes represent symbols (functions, classes, variables) within the file
+ * - Edges represent dependencies between symbols
+ * - Node shapes indicate symbol types (hexagon for classes, ellipse for functions, etc.)
+ * - Colors indicate metrics severity for each symbol
+ *
+ * Key features:
+ * - Focus on a single file's internal structure and external dependencies
+ * - Different node shapes for different symbol types (classes, functions, variables)
+ * - Theme-aware visualization with optimized colors for light/dark modes
+ * - Selectable metric visualization for symbol-level analysis
+ * - Interactive node selection with focus on direct dependencies
+ * - Visual distinction between internal and external symbols
+ * - Comprehensive display of audit information for each symbol
+ *
+ * The visualization is designed to help developers understand code organization
+ * at the symbol level, identify complex relationships, and analyze internal
+ * dependencies within files.
+ */
+export class FileDependencyVisualizer {
+ public cy: Core;
+ private theme: "light" | "dark";
+ private layout = mainLayout;
+ private fileId: string;
+ /** Current metric used for node coloring */
+ private targetMetric: dependencyManifestTypes.Metric | undefined;
+ /** Currently selected node in the graph */
+ private selectedNodeId: string | undefined;
+ /** Callback functions triggered by graph interactions */
+ private externalCallbacks: {
+ onAfterNodeClick: () => void;
+ onAfterNodeDblClick: (filePath: string, symbolId: string) => void;
+ onAfterNodeRightClick: (data: {
+ position: { x: number; y: number };
+ filePath: string;
+ symbolId: string;
+ }) => void;
+ };
+
+ constructor(
+ container: HTMLElement,
+ fileId: string,
+ dependencyManifest: dependencyManifestTypes.DependencyManifest,
+ auditManifest: auditManifestTypes.AuditManifest,
+ options?: {
+ theme?: "light" | "dark";
+ defaultMetric?: dependencyManifestTypes.Metric | undefined;
+ onAfterNodeClick?: () => void;
+ onAfterNodeRightClick?: (data: {
+ position: { x: number; y: number };
+ filePath: string;
+ symbolId: string;
+ }) => void;
+ onAfterNodeDblClick?: (filePath: string, symbolId: string) => void;
+ },
+ ) {
+ this.fileId = fileId;
+
+ const defaultOptions = {
+ onAfterNodeClick: () => {},
+ onAfterNodeDblClick: () => {},
+ onAfterNodeRightClick: () => {},
+ theme: "light" as const,
+ defaultMetric: undefined,
+ };
+
+ const mergedOptions = { ...defaultOptions, ...options };
+
+ this.targetMetric = mergedOptions.defaultMetric;
+
+ this.externalCallbacks = {
+ onAfterNodeClick: mergedOptions.onAfterNodeClick,
+ onAfterNodeDblClick: mergedOptions.onAfterNodeDblClick,
+ onAfterNodeRightClick: mergedOptions.onAfterNodeRightClick,
+ };
+
+ this.theme = mergedOptions.theme;
+
+ cytoscape.use(fcose);
+ this.cy = cytoscape();
+ this.cy.mount(container);
+
+ this.cy.batch(() => {
+ const elements = getSymbolElementsInFile(
+ fileId,
+ dependencyManifest,
+ auditManifest,
+ );
+ this.cy.add(elements);
+
+ const allNodes = this.cy.nodes();
+
+ const currentFileNode = allNodes.filter(
+ `node[fileName="${this.fileId}"]`,
+ );
+
+ allNodes.addClass("symbol");
+ currentFileNode.addClass("collapsed");
+
+ const stylesheet = getCytoscapeStylesheet(
+ this.targetMetric,
+ this.theme,
+ );
+ this.cy.style(stylesheet);
+
+ this.layoutGraph(this.cy);
+
+ this.createEventListeners();
+ });
+ }
+
+ /**
+ * Updates the theme of the visualization between light and dark mode
+ *
+ * @param theme - The theme to switch to ("light" or "dark")
+ */
+ public updateTheme(theme: "light" | "dark") {
+ this.theme = theme;
+ const stylesheet = getCytoscapeStylesheet(
+ this.targetMetric,
+ this.theme,
+ );
+ this.cy.style(stylesheet);
+ }
+
+ /**
+ * Highlights a specific node in the graph
+ *
+ * @param nodeId - The ID of the node to highlight
+ */
+ public highlightNode(ref: { filePath: string; symbolId?: string }) {
+ if (ref.symbolId) {
+ const nodeId = computeNodeId(ref.filePath, ref.symbolId);
+
+ const highlightedNode = this.cy.nodes(`node[id="${nodeId}"]`);
+ const allElements = this.cy.elements();
+ const otherElements = allElements.difference(highlightedNode);
+
+ otherElements.removeClass("highlighted");
+ highlightedNode.addClass("highlighted");
+ }
+ }
+
+ /**
+ * Unhighlights all nodes in the graph
+ */
+ public unhighlightNodes() {
+ const allElements = this.cy.elements();
+
+ allElements.removeClass("highlighted");
+ }
+
+ /**
+ * Changes the metric used for coloring nodes and updates the visualization
+ *
+ * @param metric - The new metric to use for node coloring
+ */
+ public setTargetMetric(metric: dependencyManifestTypes.Metric | undefined) {
+ this.targetMetric = metric;
+
+ const stylesheet = getCytoscapeStylesheet(
+ this.targetMetric,
+ this.theme,
+ );
+ this.cy.style(stylesheet);
+ }
+
+ public filterNodes(
+ showExternal: boolean,
+ showVariables: boolean,
+ showFunctions: boolean,
+ showClasses: boolean,
+ showStructs: boolean,
+ showEnums: boolean,
+ showInterfaces: boolean,
+ showRecords: boolean,
+ showDelegates: boolean,
+ ) {
+ const nodesToHide = this.cy.nodes().filter((node: NodeSingular) => {
+ const data = node.data() as SymbolNapiNodeData;
+ if (data.fileName === this.fileId) {
+ // never hide symbols from the current file
+ return false;
+ }
+
+ if (!showExternal && data.isExternal) {
+ return true;
+ }
+
+ const symbolTypeFilters = {
+ [dependencyManifestTypes.symbolTypeVariable]: showVariables,
+ [dependencyManifestTypes.symbolTypeFunction]: showFunctions,
+ [dependencyManifestTypes.symbolTypeClass]: showClasses,
+ [dependencyManifestTypes.symbolTypeStruct]: showStructs,
+ [dependencyManifestTypes.symbolTypeEnum]: showEnums,
+ [dependencyManifestTypes.symbolTypeInterface]: showInterfaces,
+ [dependencyManifestTypes.symbolTypeRecord]: showRecords,
+ [dependencyManifestTypes.symbolTypeDelegate]: showDelegates,
+ };
+ for (const [symbolType, show] of Object.entries(symbolTypeFilters)) {
+ if (!show && data.symbolType === symbolType) {
+ return true;
+ }
+ }
+
+ return false;
+ });
+
+ const elementsToHide = nodesToHide.connectedEdges().union(nodesToHide);
+ const otherElements = this.cy.elements().difference(elementsToHide);
+
+ elementsToHide.addClass("hidden");
+ otherElements.removeClass("hidden");
+ }
+
+ /**
+ * Sets up event listeners for node interactions:
+ * - Click: Selects a node and highlights its connections
+ * - Double-click: Triggers the external double-click callback
+ * - Right-click: Opens context menu via the external callback
+ */
+ private createEventListeners() {
+ this.cy.on("onetap", "node", (evt: EventObjectNode) => {
+ const nodeId = evt.target.id();
+ const nodeData = evt.target.data() as SymbolNapiNodeData;
+ const isAlreadySelected = this.selectedNodeId === nodeId;
+ this.selectedNodeId = nodeId;
+
+ const isCurrentFile = nodeData.fileName === this.fileId;
+ if (!isCurrentFile) {
+ // Only allow selection of nodes within the current file
+ return;
+ }
+
+ const allElements = this.cy.elements();
+
+ const selectedNode = this.cy.nodes(`node[id="${this.selectedNodeId}"]`);
+
+ const connectedNodes = selectedNode
+ .closedNeighborhood()
+ .nodes()
+ .difference(selectedNode);
+
+ const dependentEdges = selectedNode
+ .connectedEdges()
+ .filter(
+ (edge: EdgeSingular) => edge.source().id() === this.selectedNodeId,
+ );
+
+ const dependencyEdges = selectedNode
+ .connectedEdges()
+ .filter(
+ (edge: EdgeSingular) => edge.target().id() === this.selectedNodeId,
+ );
+
+ const focusedElements = selectedNode.closedNeighborhood();
+
+ const backgroundElements = allElements.difference(focusedElements);
+
+ this.cy.batch(() => {
+ // remove all, clean state
+ allElements.removeClass([
+ "collapsed",
+ "expanded",
+ "selected",
+ "background",
+ "dependency",
+ "dependent",
+ ]);
+
+ if (!isAlreadySelected) {
+ backgroundElements.addClass("background");
+
+ connectedNodes.addClass("collapsed");
+
+ selectedNode.addClass("expanded");
+ selectedNode.addClass("selected");
+
+ dependencyEdges.addClass("dependency");
+ dependentEdges.addClass("dependent");
+
+ focusedElements.layout(this.layout).run();
+ } else {
+ this.selectedNodeId = undefined;
+
+ const fileNodes = this.cy.nodes().filter(
+ `node[fileName="${this.fileId}"]`,
+ );
+ fileNodes.addClass("collapsed");
+ }
+ });
+
+ this.externalCallbacks.onAfterNodeClick();
+ });
+
+ this.cy.on("dbltap", "node", (evt: EventObjectNode) => {
+ const node = evt.target;
+ const data = node.data() as SymbolNapiNodeData;
+
+ // If the node is external, ignore it
+ if (data.isExternal) return;
+
+ this.externalCallbacks.onAfterNodeDblClick(
+ data.fileName,
+ data.symbolName,
+ );
+ });
+
+ this.cy.on("cxttap", "node", (evt: EventObjectNode) => {
+ const node = evt.target;
+ const data = node.data() as SymbolNapiNodeData;
+
+ // If the node is external, ignore it
+ if (data.isExternal) return;
+
+ const { x, y } = node.renderedPosition();
+ this.externalCallbacks.onAfterNodeRightClick({
+ position: { x, y },
+ filePath: data.fileName,
+ symbolId: data.symbolName,
+ });
+ });
+ }
+
+ /**
+ * Applies the graph layout algorithm to position nodes optimally
+ *
+ * @param collection - The collection of elements to layout (defaults to all nodes)
+ */
+ public layoutGraph(collection: Collection | Core) {
+ const collectionToLayout = collection || this.cy.nodes();
+ collectionToLayout.layout(this.layout).run();
+ }
+}
diff --git a/viewer/src/cytoscape/label/index.ts b/viewer/src/cytoscape/label/index.ts
new file mode 100644
index 00000000..9ba61abe
--- /dev/null
+++ b/viewer/src/cytoscape/label/index.ts
@@ -0,0 +1,165 @@
+import type * as auditManifestTypes from "../../types/auditManifest";
+
+/**
+ * Calculates the optimal width and height for a node based on its label text.
+ *
+ * Determines dimensions by measuring the label's text length and line count,
+ * applying appropriate font size, line height, and padding.
+ * Enforces minimum dimensions to ensure nodes are visually distinguishable.
+ *
+ * @param label - The label text to be displayed in the node
+ * @param options - Configuration options for calculating dimensions
+ * @returns Object containing calculated width and height
+ */
+export function getNodeWidthAndHeightFromLabel(
+ label: string,
+ options = {
+ fontSize: 10,
+ lineHeight: 1.5,
+ padding: 10,
+ minHeight: 60,
+ minWidth: 60,
+ },
+) {
+ const lines = label.split("\n");
+
+ const height = Math.max(
+ lines.length * options.fontSize * options.lineHeight + 2 * options.padding,
+ options.minHeight,
+ );
+
+ const width = Math.max(
+ ...lines.map(
+ (line) => line.length * options.fontSize + 2 * options.padding,
+ ),
+ options.minWidth,
+ );
+
+ return { width, height };
+}
+
+const successChar = "🎉";
+const errorChar = "⚠️";
+
+/**
+ * Generates the collapsed label for a file node with summarized audit information.
+ *
+ * Creates a compact representation showing:
+ * - Truncated file name (if longer than maximum length)
+ * - Alert count with warning icon (if issues exist)
+ *
+ * Used for the default, non-selected state of nodes in the graph visualization.
+ *
+ * @param data - Object containing file name and audit information
+ * @returns Formatted label string for collapsed node view
+ */
+export function getCollapsedFileNodeLabel(data: {
+ fileName: string;
+ fileAuditManifest: auditManifestTypes.AuditManifest[string];
+}) {
+ const fileNameMaxLength = 25;
+ const fileName = data.fileName.length > fileNameMaxLength
+ ? `...${data.fileName.slice(-fileNameMaxLength)}`
+ : data.fileName;
+
+ let label = fileName;
+
+ const alerts = Object.values(data.fileAuditManifest.alerts);
+
+ if (alerts.length > 0) {
+ label += `\n${errorChar}(${alerts.length})`;
+ }
+
+ return label;
+}
+
+/**
+ * Generates the expanded label for a file node with detailed audit information.
+ *
+ * Creates a comprehensive display showing:
+ * - Full file name without truncation
+ * - List of all alerts with warning icons and short messages
+ * - Success message if no issues are found
+ *
+ * Used when a node is selected to provide more detailed information.
+ *
+ * @param data - Object containing file name and audit information
+ * @returns Formatted label string for expanded node view
+ */
+export function getExpandedFileNodeLabel(data: {
+ fileName: string;
+ fileAuditManifest: auditManifestTypes.AuditManifest[string];
+}) {
+ let label = data.fileName;
+
+ const alerts = Object.values(data.fileAuditManifest.alerts);
+
+ if (alerts.length > 0) {
+ alerts.forEach((alert) => {
+ label += `\n${errorChar} ${alert.message.short}`;
+ });
+ } else {
+ label += `\n${successChar} No issues found`;
+ }
+
+ return label;
+}
+
+/**
+ * Generates the collapsed label for a symbol node with minimal information.
+ *
+ * Shows only the symbol name for a compact representation in the non-selected state.
+ *
+ * @param data - Object containing symbol name
+ * @returns Symbol name as the collapsed label
+ */
+export function getCollapsedSymbolNodeLabel(data: {
+ symbolName: string;
+ symbolType: string;
+}) {
+ return `${data.symbolName} (${data.symbolType})`;
+}
+
+/**
+ * Generates the expanded label for a symbol node with detailed information.
+ *
+ * Creates a comprehensive display showing:
+ * - Symbol name and type
+ * - Source file information
+ * - List of alerts with warning icons (if any)
+ * - Success message if no issues are found
+ *
+ * Used when a symbol node is selected to provide more detailed information.
+ *
+ * @param data - Object containing symbol information and audit data
+ * @returns Formatted label string for expanded symbol node view
+ */
+export function getExpandedSymbolNodeLabel(data: {
+ currentFileId: string;
+ fileName: string;
+ symbolName: string;
+ symbolType: string;
+ symbolAuditManifest:
+ | auditManifestTypes.AuditManifest[string]["symbols"][string]
+ | undefined;
+}) {
+ // Create the basic label with symbol name and type
+ let label = `${data.symbolName} (${data.symbolType})`;
+ // Add file information
+ label += `\nSource: ${data.fileName}`;
+
+ // Add alerts information if available and not an external symbol
+ if (data.symbolAuditManifest) {
+ const alertList = Object.values(data.symbolAuditManifest.alerts);
+
+ if (alertList.length > 0) {
+ alertList.forEach((alert) => {
+ label += `\n${errorChar} ${alert.message.short}`;
+ });
+ } else {
+ label += `\n${successChar} No issues`;
+ }
+ }
+
+ return label;
+}
diff --git a/viewer/src/cytoscape/layout/index.ts b/viewer/src/cytoscape/layout/index.ts
new file mode 100644
index 00000000..88e4b318
--- /dev/null
+++ b/viewer/src/cytoscape/layout/index.ts
@@ -0,0 +1,22 @@
+import type { FcoseLayoutOptions } from "cytoscape-fcose";
+
+/**
+ * Main layout configuration for dependency visualization graphs.
+ *
+ * Uses the F-COSE (Force-directed Compound Spring Embedder) algorithm optimized for:
+ * - High quality graph rendering with "proof" quality setting
+ * - Strong node repulsion to prevent overlapping (1,000,000 force units)
+ * - Ideal edge length of 200px for readability
+ * - Gentle gravity (0.1) to pull components toward center
+ * - Component packing to utilize space efficiently
+ * - Node dimensions that include labels to prevent text overlap
+ */
+export const mainLayout = {
+ name: "fcose",
+ quality: "proof",
+ nodeRepulsion: 1000000,
+ idealEdgeLength: 200,
+ gravity: 0.1,
+ packComponents: true,
+ nodeDimensionsIncludeLabels: true,
+} as FcoseLayoutOptions;
diff --git a/viewer/src/cytoscape/metrics/index.ts b/viewer/src/cytoscape/metrics/index.ts
new file mode 100644
index 00000000..d1c6d15e
--- /dev/null
+++ b/viewer/src/cytoscape/metrics/index.ts
@@ -0,0 +1,38 @@
+import type * as auditManifestTypes from "../../types/auditManifest";
+import * as dependencyManifestTypes from "../../types/dependencyManifest";
+
+/**
+ * Extracts metric severity levels from an audit manifest for visualization.
+ *
+ * Processes the audit manifest to build a record of severity values (0-5) for each
+ * supported metric type. Default severity is 0 (no issues) for metrics not present
+ * in the audit manifest.
+ *
+ * @param auditManifest - Audit information for a file or symbol
+ * @returns Object mapping each metric type to its severity level (0-5)
+ */
+export function getMetricsSeverityForNode(
+ auditManifest:
+ | auditManifestTypes.AuditManifest[string]
+ | auditManifestTypes.AuditManifest[string]["symbols"][string]
+ | undefined,
+) {
+ const metricsSeverity: Record = {
+ [dependencyManifestTypes.metricLinesCount]: 0,
+ [dependencyManifestTypes.metricCodeLineCount]: 0,
+ [dependencyManifestTypes.metricCodeCharacterCount]: 0,
+ [dependencyManifestTypes.metricCharacterCount]: 0,
+ [dependencyManifestTypes.metricDependencyCount]: 0,
+ [dependencyManifestTypes.metricDependentCount]: 0,
+ [dependencyManifestTypes.metricCyclomaticComplexity]: 0,
+ };
+
+ if (auditManifest) {
+ Object.entries(auditManifest.alerts).forEach(([metric, value]) => {
+ metricsSeverity[metric as dependencyManifestTypes.Metric] =
+ value.severity;
+ });
+ }
+
+ return metricsSeverity;
+}
diff --git a/viewer/src/cytoscape/projectDependencyVisualizer/index.ts b/viewer/src/cytoscape/projectDependencyVisualizer/index.ts
new file mode 100644
index 00000000..ac0aa0ce
--- /dev/null
+++ b/viewer/src/cytoscape/projectDependencyVisualizer/index.ts
@@ -0,0 +1,269 @@
+import cytoscape, {
+ type Collection,
+ type EdgeSingular,
+ type EventObjectNode,
+} from "cytoscape";
+import type { Core } from "cytoscape";
+import fcose from "cytoscape-fcose";
+import type * as dependencyManifestTypes from "../../types/dependencyManifest";
+import type * as auditManifestTypes from "../../types/auditManifest";
+import type { NapiNodeData } from "../elements/types.ts";
+import { mainLayout } from "../layout/index.ts";
+import { getCytoscapeStylesheet } from "../styles/index.ts";
+import { getFileElementsInProject } from "../elements/project.ts";
+
+/**
+ * ProjectDependencyVisualizer creates an interactive graph of project-level dependencies.
+ *
+ * This visualization provides a comprehensive view of the project's architecture where:
+ * - Nodes represent individual project files, sized according to complexity metrics
+ * - Edges represent import/export relationships between files
+ * - Colors indicate metrics severity (code size, complexity, dependency count)
+ * - Interactive features allow exploration of dependency relationships
+ *
+ * Key features:
+ * - Theme-aware visualization with optimized colors for light/dark modes
+ * - Selectable metric visualization (LOC, character count, cyclomatic complexity)
+ * - Interactive node selection with focus on direct dependencies
+ * - Automatic layout using F-COSE algorithm for optimal readability
+ * - Support for different node states (selected, connected, highlighted)
+ * - Visual representation of audit alerts and warnings
+ *
+ * The visualization is designed to help developers understand project structure,
+ * identify problematic dependencies, and analyze code complexity at the file level.
+ */
+export class ProjectDependencyVisualizer {
+ public cy: Core;
+ private theme: "light" | "dark";
+ /** Layout configuration for organizing the dependency graph */
+ private layout = mainLayout;
+ /** Current metric used for node coloring */
+ private targetMetric: dependencyManifestTypes.Metric | undefined;
+ /** Currently selected node in the graph */
+ private selectedNodeId: string | undefined;
+ /** Callback functions triggered by graph interactions */
+ private externalCallbacks: {
+ onAfterNodeClick: () => void;
+ onAfterNodeDblClick: (filePath: string) => void;
+ onAfterNodeRightClick: (data: {
+ position: { x: number; y: number };
+ filePath: string;
+ }) => void;
+ };
+
+ /**
+ * Creates a new CodeDependencyVisualizer instance.
+ *
+ * @param container - The HTML element to mount the Cytoscape graph onto
+ * @param dependencyManifest - Object containing dependency information for project files
+ * @param auditManifest - Object containing audit information (errors/warnings) for project files
+ * @param options - Optional configuration parameters
+ */
+ constructor(
+ container: HTMLElement,
+ dependencyManifest: dependencyManifestTypes.DependencyManifest,
+ auditManifest: auditManifestTypes.AuditManifest,
+ options?: {
+ theme?: "light" | "dark";
+ defaultMetric?: dependencyManifestTypes.Metric | undefined;
+ onAfterNodeClick?: () => void;
+ onAfterNodeRightClick?: (data: {
+ position: { x: number; y: number };
+ filePath: string;
+ }) => void;
+ onAfterNodeDblClick?: (filePath: string) => void;
+ },
+ ) {
+ const defaultOptions = {
+ onAfterNodeClick: () => {},
+ onAfterNodeDblClick: () => {},
+ onAfterNodeRightClick: () => {},
+ theme: "light" as const,
+ defaultMetric: undefined,
+ };
+
+ const mergedOptions = { ...defaultOptions, ...options };
+
+ this.targetMetric = mergedOptions.defaultMetric;
+
+ this.externalCallbacks = {
+ onAfterNodeClick: mergedOptions.onAfterNodeClick,
+ onAfterNodeDblClick: mergedOptions.onAfterNodeDblClick,
+ onAfterNodeRightClick: mergedOptions.onAfterNodeRightClick,
+ };
+
+ cytoscape.use(fcose);
+ this.cy = cytoscape();
+ this.cy.mount(container);
+ this.theme = mergedOptions.theme;
+
+ const elements = getFileElementsInProject(
+ dependencyManifest,
+ auditManifest,
+ );
+ this.cy.add(elements);
+
+ const stylesheet = getCytoscapeStylesheet(
+ this.targetMetric,
+ this.theme,
+ );
+ this.cy.style(stylesheet);
+
+ this.layoutGraph(this.cy);
+
+ this.createEventListeners();
+ }
+
+ /**
+ * Updates the theme of the visualization between light and dark mode
+ *
+ * @param theme - The theme to switch to ("light" or "dark")
+ */
+ public updateTheme(theme: "light" | "dark") {
+ this.theme = theme;
+ const stylesheet = getCytoscapeStylesheet(
+ this.targetMetric,
+ this.theme,
+ );
+ this.cy.style(stylesheet);
+ }
+
+ /**
+ * Highlights a specific node in the graph
+ *
+ * @param nodeId - The ID of the node to highlight
+ */
+ public highlightNode(ref: { filePath: string; symbolId?: string }) {
+ const nodeId = ref.filePath;
+
+ const highlightedNode = this.cy.nodes(`node[id="${nodeId}"]`);
+ const allElements = this.cy.elements();
+ const otherElements = allElements.difference(highlightedNode);
+
+ otherElements.removeClass("highlighted");
+ highlightedNode.addClass("highlighted");
+ }
+
+ /**
+ * Unhighlights all nodes in the graph
+ */
+ public unhighlightNodes() {
+ const allElements = this.cy.elements();
+
+ allElements.removeClass("highlighted");
+ }
+
+ /**
+ * Sets up event listeners for node interactions:
+ * - Click: Selects a node and highlights its connections
+ * - Double-click: Triggers the external double-click callback
+ * - Right-click: Opens context menu via the external callback
+ */
+ private createEventListeners() {
+ this.cy.on("onetap", "node", (evt: EventObjectNode) => {
+ const nodeId = evt.target.id();
+ const isAlreadySelected = this.selectedNodeId === nodeId;
+ this.selectedNodeId = nodeId;
+
+ const allElements = this.cy.elements();
+
+ const selectedNode = this.cy.nodes(`node[id="${this.selectedNodeId}"]`);
+
+ const connectedNodes = selectedNode
+ .closedNeighborhood()
+ .nodes()
+ .difference(selectedNode);
+
+ const dependentEdges = selectedNode
+ .connectedEdges()
+ .filter(
+ (edge: EdgeSingular) => edge.source().id() === this.selectedNodeId,
+ );
+
+ const dependencyEdges = selectedNode
+ .connectedEdges()
+ .filter(
+ (edge: EdgeSingular) => edge.target().id() === this.selectedNodeId,
+ );
+
+ const focusedElements = selectedNode.closedNeighborhood();
+
+ const backgroundElements = allElements.difference(focusedElements);
+
+ this.cy.batch(() => {
+ // remove all, clean state
+ allElements.removeClass([
+ "file",
+ "collapsed",
+ "expanded",
+ "selected",
+ "dependency",
+ "dependent",
+ "background",
+ ]);
+
+ if (!isAlreadySelected) {
+ backgroundElements.addClass("background");
+
+ connectedNodes.addClass("collapsed");
+ connectedNodes.addClass("file");
+
+ selectedNode.addClass("expanded");
+ selectedNode.addClass("selected");
+ selectedNode.addClass("file");
+
+ dependencyEdges.addClass("dependency");
+ dependentEdges.addClass("dependent");
+
+ // layout the closed neighborhood
+ focusedElements.layout(this.layout).run();
+ } else {
+ this.selectedNodeId = undefined;
+ }
+ });
+
+ this.externalCallbacks.onAfterNodeClick();
+ });
+
+ this.cy.on("dbltap", "node", (evt: EventObjectNode) => {
+ const node = evt.target;
+ const data = node.data() as NapiNodeData;
+ this.externalCallbacks.onAfterNodeDblClick(data.id);
+ });
+
+ this.cy.on("cxttap", "node", (evt: EventObjectNode) => {
+ const node = evt.target;
+ const { x, y } = node.renderedPosition();
+
+ this.externalCallbacks.onAfterNodeRightClick({
+ position: { x, y },
+ filePath: node.id(),
+ });
+ });
+ }
+
+ /**
+ * Applies the graph layout algorithm to position nodes optimally
+ *
+ * @param collection - The collection of elements to layout (defaults to all nodes)
+ */
+ public layoutGraph(collection: Collection | Core) {
+ const collectionToLayout = collection || this.cy.nodes();
+ collectionToLayout.layout(this.layout).run();
+ }
+
+ /**
+ * Changes the metric used for coloring nodes and updates the visualization
+ *
+ * @param metric - The new metric to use for node coloring (e.g., LOC, characters, dependencies)
+ */
+ public setTargetMetric(metric: dependencyManifestTypes.Metric | undefined) {
+ this.targetMetric = metric;
+
+ const stylesheet = getCytoscapeStylesheet(
+ this.targetMetric,
+ this.theme,
+ );
+ this.cy.style(stylesheet);
+ }
+}
diff --git a/viewer/src/cytoscape/styles/index.ts b/viewer/src/cytoscape/styles/index.ts
new file mode 100644
index 00000000..7cdfeff4
--- /dev/null
+++ b/viewer/src/cytoscape/styles/index.ts
@@ -0,0 +1,257 @@
+import type { NodeSingular, StylesheetJson } from "cytoscape";
+import * as dependencyManifestTypes from "../../types/dependencyManifest";
+import type { NapiNodeData, SymbolNapiNodeData } from "../elements/types.ts";
+
+interface CytoscapeStyles {
+ node: {
+ colors: {
+ text: {
+ default: string;
+ selected: string;
+ external: string;
+ };
+ background: {
+ default: string;
+ highlighted: string;
+ selected: string;
+ external: string;
+ };
+ border: {
+ default: string;
+ severity: {
+ 0: string;
+ 1: string;
+ 2: string;
+ 3: string;
+ 4: string;
+ 5: string;
+ };
+ };
+ };
+ width: {
+ default: number;
+ highlighted: number;
+ };
+ };
+ edge: {
+ colors: {
+ default: string;
+ dependency: string;
+ dependent: string;
+ };
+ width: {
+ default: number;
+ highlighted: number;
+ };
+ };
+}
+
+function getSeverityColor(styles: CytoscapeStyles, level: number) {
+ const severityLevels = styles.node.colors.border.severity;
+ const targetColor = level in severityLevels
+ ? severityLevels[level as keyof typeof severityLevels]
+ : undefined;
+
+ return targetColor || styles.node.colors.border.default;
+}
+
+function getCytoscapeStyles(theme: "light" | "dark" = "light") {
+ return {
+ node: {
+ colors: {
+ text: {
+ default: theme === "light" ? "#3B0764" : "#FFFFFF",
+ selected: theme === "light" ? "#FFFFFF" : "#3B0764",
+ external: theme === "light" ? "#3B0764" : "#FFFFFF",
+ },
+ background: {
+ default: theme === "light" ? "#F3E8FF" : "#6D28D9",
+ selected: theme === "light" ? "#A259D9" : "#CBA6F7",
+ external: theme === "light" ? "#F1F5F9" : "#334155",
+ highlighted: theme === "light" ? "#eab308" : "#facc15",
+ },
+ border: {
+ default: theme === "light" ? "#A259D9" : "#CBA6F7",
+ severity: {
+ 0: theme === "light" ? "#A259D9" : "#CBA6F7",
+ 1: theme === "light" ? "#65a30d" : "#a3e635",
+ 2: theme === "light" ? "#ca8a04" : "#facc15",
+ 3: theme === "light" ? "#d97706" : "#fbbf24",
+ 4: theme === "light" ? "#ea580c" : "#fb923c",
+ 5: theme === "light" ? "#dc2626" : "#f87171",
+ },
+ },
+ },
+ width: {
+ default: 5,
+ highlighted: 10,
+ },
+ },
+ edge: {
+ colors: {
+ default: theme === "light" ? "#1a1a1a" : "#ffffff",
+ dependency: theme === "light" ? "#0284c7" : "#38bdf8",
+ dependent: theme === "light" ? "#9333ea" : "#a78bfa",
+ },
+ width: {
+ default: 1,
+ highlighted: 3,
+ },
+ },
+ } as CytoscapeStyles;
+}
+
+export function getCytoscapeStylesheet(
+ targetMetric: dependencyManifestTypes.Metric | undefined,
+ theme: "light" | "dark" = "light",
+) {
+ const styles = getCytoscapeStyles(theme);
+
+ const stylesheet = [
+ // Node specific styles
+ {
+ selector: "node",
+ style: {
+ "text-wrap": "wrap",
+ color: styles.node.colors.text.default,
+ "border-width": styles.node.width.default,
+ "border-color": (node: NodeSingular) => {
+ const data = node.data() as NapiNodeData;
+ if (targetMetric) {
+ return getSeverityColor(styles, data.metricsSeverity[targetMetric]);
+ }
+ return styles.node.colors.border.default;
+ },
+ "background-color": styles.node.colors.background.default,
+ shape: "ellipse",
+ "text-valign": "center",
+ "text-halign": "center",
+ "width": 20,
+ "height": 20,
+ opacity: 0.9,
+ },
+ },
+ {
+ selector: "node.file",
+ style: {
+ shape: "roundrectangle",
+ },
+ },
+ {
+ selector: "node.symbol",
+ style: {
+ "background-color": (node: NodeSingular) => {
+ const data = node.data() as SymbolNapiNodeData;
+ return data.isExternal
+ ? styles.node.colors.background.external
+ : styles.node.colors.background.default;
+ },
+ "color": (node: NodeSingular) => {
+ const data = node.data() as SymbolNapiNodeData;
+ return data.isExternal
+ ? styles.node.colors.text.external
+ : styles.node.colors.text.default;
+ },
+ shape: (node: NodeSingular) => {
+ const data = node.data() as SymbolNapiNodeData;
+ if (data.isExternal) return "octagon";
+ switch (data.symbolType) {
+ case dependencyManifestTypes.symbolTypeClass:
+ case dependencyManifestTypes.symbolTypeInterface:
+ case dependencyManifestTypes.symbolTypeStruct:
+ case dependencyManifestTypes.symbolTypeEnum:
+ case dependencyManifestTypes.symbolTypeRecord:
+ return "hexagon";
+ case dependencyManifestTypes.symbolTypeFunction:
+ case dependencyManifestTypes.symbolTypeDelegate:
+ return "roundrectangle";
+ case dependencyManifestTypes.symbolTypeVariable:
+ return "ellipse";
+ default:
+ return "ellipse";
+ }
+ },
+ "border-style": (node: NodeSingular) => {
+ const data = node.data() as SymbolNapiNodeData;
+ return data.isExternal ? "dashed" : "solid";
+ },
+ },
+ },
+ {
+ selector: "node.collapsed",
+ style: {
+ label: "data(collapsed.label)",
+ width: "data(collapsed.width)",
+ height: "data(collapsed.height)",
+ "z-index": 1000,
+ },
+ },
+ {
+ selector: "node.expanded",
+ style: {
+ label: "data(expanded.label)",
+ width: "data(expanded.width)",
+ height: "data(expanded.height)",
+ "z-index": 2000,
+ },
+ },
+ {
+ selector: "node.highlighted",
+ style: {
+ "border-width": styles.node.width.highlighted,
+ "background-color": styles.node.colors.background.highlighted,
+ },
+ },
+ {
+ selector: "node.selected",
+ style: {
+ "background-color": styles.node.colors.background.selected,
+ "color": styles.node.colors.text.selected,
+ },
+ },
+
+ // Edge specific styles
+ {
+ selector: "edge",
+ style: {
+ width: styles.edge.width.default,
+ "line-color": styles.edge.colors.default,
+ "target-arrow-color": styles.edge.colors.default,
+ "target-arrow-shape": "triangle",
+ "curve-style": "bezier",
+ },
+ },
+ {
+ selector: "edge.dependency",
+ style: {
+ width: styles.edge.width.highlighted,
+ "line-color": styles.edge.colors.dependency,
+ "target-arrow-color": styles.edge.colors.dependency,
+ },
+ },
+ {
+ selector: "edge.dependent",
+ style: {
+ width: styles.edge.width.highlighted,
+ "line-color": styles.edge.colors.dependent,
+ "target-arrow-color": styles.edge.colors.dependent,
+ },
+ },
+
+ // All elements styles
+ {
+ selector: ".background",
+ style: {
+ "opacity": 0.1,
+ },
+ },
+ {
+ selector: ".hidden",
+ style: {
+ "opacity": 0,
+ },
+ },
+ ] as StylesheetJson;
+
+ return stylesheet;
+}
diff --git a/viewer/src/cytoscape/symbolDependencyVisualizer/index.ts b/viewer/src/cytoscape/symbolDependencyVisualizer/index.ts
new file mode 100644
index 00000000..59cd3645
--- /dev/null
+++ b/viewer/src/cytoscape/symbolDependencyVisualizer/index.ts
@@ -0,0 +1,300 @@
+import type * as auditManifestTypes from "../../types/auditManifest";
+import * as dependencyManifestTypes from "../../types/dependencyManifest";
+import type {
+ Collection,
+ Core,
+ EventObjectNode,
+ NodeSingular,
+} from "cytoscape";
+import fcose from "cytoscape-fcose";
+import type { SymbolNapiNodeData } from "../elements/types.ts";
+import cytoscape from "cytoscape";
+import { mainLayout } from "../layout/index.ts";
+import { getCytoscapeStylesheet } from "../styles/index.ts";
+import { computeNodeId } from "../elements/file.ts";
+import { getSymbolElementsForSymbol } from "../elements/symbol.ts";
+
+/**
+ * FileDependencyVisualizer creates an interactive graph of symbol dependencies within a file.
+ *
+ * This visualization provides a detailed view of internal file structure where:
+ * - Nodes represent symbols (functions, classes, variables) within the file
+ * - Edges represent dependencies between symbols
+ * - Node shapes indicate symbol types (hexagon for classes, ellipse for functions, etc.)
+ * - Colors indicate metrics severity for each symbol
+ *
+ * Key features:
+ * - Focus on a single file's internal structure and external dependencies
+ * - Different node shapes for different symbol types (classes, functions, variables)
+ * - Theme-aware visualization with optimized colors for light/dark modes
+ * - Selectable metric visualization for symbol-level analysis
+ * - Interactive node selection with focus on direct dependencies
+ * - Visual distinction between internal and external symbols
+ * - Comprehensive display of audit information for each symbol
+ *
+ * The visualization is designed to help developers understand code organization
+ * at the symbol level, identify complex relationships, and analyze internal
+ * dependencies within files.
+ */
+export class SymbolDependencyVisualizer {
+ public cy: Core;
+ private theme: "light" | "dark";
+ private layout = mainLayout;
+ private filePath: string;
+ private symbolId: string;
+ /** Currently selected node in the graph */
+ private _selectedNodeId: string | undefined;
+ /** Callback functions triggered by graph interactions */
+ private externalCallbacks: {
+ onAfterNodeClick: () => void;
+ onAfterNodeDblClick: (filePath: string, symbolId: string) => void;
+ onAfterNodeRightClick: (data: {
+ position: { x: number; y: number };
+ filePath: string;
+ symbolId: string;
+ }) => void;
+ };
+
+ constructor(
+ container: HTMLElement,
+ filePath: string,
+ symbolId: string,
+ dependencyDepth: number,
+ dependentDepth: number,
+ dependencyManifest: dependencyManifestTypes.DependencyManifest,
+ auditManifest: auditManifestTypes.AuditManifest,
+ options?: {
+ theme?: "light" | "dark";
+ defaultMetric?: dependencyManifestTypes.Metric | undefined;
+ onAfterNodeClick?: () => void;
+ onAfterNodeRightClick?: (data: {
+ position: { x: number; y: number };
+ filePath: string;
+ symbolId: string;
+ }) => void;
+ onAfterNodeDblClick?: (filePath: string, symbolId: string) => void;
+ },
+ ) {
+ this.filePath = filePath;
+ this.symbolId = symbolId;
+
+ const defaultOptions = {
+ onAfterNodeClick: () => {},
+ onAfterNodeDblClick: () => {},
+ onAfterNodeRightClick: () => {},
+ theme: "light" as const,
+ defaultMetric: undefined,
+ };
+
+ const mergedOptions = { ...defaultOptions, ...options };
+
+ this.externalCallbacks = {
+ onAfterNodeClick: mergedOptions.onAfterNodeClick,
+ onAfterNodeDblClick: mergedOptions.onAfterNodeDblClick,
+ onAfterNodeRightClick: mergedOptions.onAfterNodeRightClick,
+ };
+
+ this.theme = mergedOptions.theme;
+
+ cytoscape.use(fcose);
+ this.cy = cytoscape();
+ this.cy.mount(container);
+
+ this.cy.batch(() => {
+ const elements = getSymbolElementsForSymbol(
+ this.filePath,
+ this.symbolId,
+ dependencyDepth,
+ dependentDepth,
+ dependencyManifest,
+ auditManifest,
+ );
+ this.cy.add(elements);
+
+ const allNodes = this.cy.nodes();
+ const selectedNode = this.cy.nodes().filter(
+ (node) => {
+ const data = node.data() as SymbolNapiNodeData;
+ return data.fileName === this.filePath &&
+ data.symbolName === this.symbolId;
+ },
+ );
+ selectedNode.addClass("selected");
+ selectedNode.addClass("expanded");
+
+ allNodes.addClass("symbol");
+
+ const stylesheet = getCytoscapeStylesheet(
+ undefined,
+ this.theme,
+ );
+ this.cy.style(stylesheet);
+
+ this.layoutGraph(this.cy);
+
+ this.createEventListeners();
+ });
+ }
+
+ /**
+ * Updates the theme of the visualization between light and dark mode
+ *
+ * @param theme - The theme to switch to ("light" or "dark")
+ */
+ public updateTheme(theme: "light" | "dark") {
+ this.theme = theme;
+ const stylesheet = getCytoscapeStylesheet(
+ undefined,
+ this.theme,
+ );
+ this.cy.style(stylesheet);
+ }
+
+ /**
+ * Highlights a specific node in the graph
+ *
+ * @param nodeId - The ID of the node to highlight
+ */
+ public highlightNode(ref: { filePath: string; symbolId?: string }) {
+ if (ref.symbolId) {
+ const nodeId = computeNodeId(ref.filePath, ref.symbolId);
+
+ const highlightedNode = this.cy.nodes(`node[id="${nodeId}"]`);
+ const allElements = this.cy.elements();
+ const otherElements = allElements.difference(highlightedNode);
+
+ otherElements.removeClass("highlighted");
+ highlightedNode.addClass("highlighted");
+ }
+ }
+
+ /**
+ * Unhighlights all nodes in the graph
+ */
+ public unhighlightNodes() {
+ const allElements = this.cy.elements();
+
+ allElements.removeClass("highlighted");
+ }
+
+ public filterNodes(
+ showExternal: boolean,
+ showVariables: boolean,
+ showFunctions: boolean,
+ showClasses: boolean,
+ showStructs: boolean,
+ showEnums: boolean,
+ ) {
+ const nodesToHide = this.cy.nodes().filter((node: NodeSingular) => {
+ const data = node.data() as SymbolNapiNodeData;
+ if (
+ data.fileName === this.filePath && data.symbolName === this.symbolId
+ ) {
+ // never hide the current symbol
+ return false;
+ }
+
+ if (!showExternal && data.isExternal) {
+ return true;
+ }
+ if (
+ !showVariables &&
+ data.symbolType === dependencyManifestTypes.symbolTypeVariable
+ ) {
+ return true;
+ }
+ if (
+ !showFunctions &&
+ data.symbolType === dependencyManifestTypes.symbolTypeFunction
+ ) {
+ return true;
+ }
+ if (
+ !showClasses &&
+ data.symbolType === dependencyManifestTypes.symbolTypeClass
+ ) {
+ return true;
+ }
+ if (
+ !showStructs &&
+ data.symbolType === dependencyManifestTypes.symbolTypeStruct
+ ) {
+ return true;
+ }
+ if (
+ !showEnums && data.symbolType === dependencyManifestTypes.symbolTypeEnum
+ ) {
+ return true;
+ }
+ return false;
+ });
+
+ const elementsToHide = nodesToHide.connectedEdges().union(nodesToHide);
+ const otherElements = this.cy.elements().difference(elementsToHide);
+
+ elementsToHide.addClass("hidden");
+ otherElements.removeClass("hidden");
+ }
+
+ /**
+ * Sets up event listeners for node interactions:
+ * - Click: Selects a node and highlights its connections
+ * - Double-click: Triggers the external double-click callback
+ * - Right-click: Opens context menu via the external callback
+ */
+ private createEventListeners() {
+ this.cy.on("onetap", "node", (evt: EventObjectNode) => {
+ const nodeId = evt.target.id();
+ this._selectedNodeId = nodeId;
+
+ const selectedNode = this.cy.nodes(`node[id="${nodeId}"]`);
+ const isAlreadyExpanded = selectedNode[0].hasClass("expanded");
+
+ if (isAlreadyExpanded) {
+ selectedNode.removeClass("expanded");
+ } else {
+ selectedNode.addClass("expanded");
+ }
+
+ this.externalCallbacks.onAfterNodeClick();
+ });
+
+ this.cy.on("dbltap", "node", (evt: EventObjectNode) => {
+ const node = evt.target;
+ const data = node.data() as SymbolNapiNodeData;
+
+ // If the node is external, ignore it
+ if (data.isExternal) return;
+
+ this.externalCallbacks.onAfterNodeDblClick(
+ data.fileName,
+ data.id,
+ );
+ });
+
+ this.cy.on("cxttap", "node", (evt: EventObjectNode) => {
+ const node = evt.target;
+ const data = node.data() as SymbolNapiNodeData;
+
+ // If the node is external, ignore it
+ if (data.isExternal) return;
+
+ const { x, y } = node.renderedPosition();
+ this.externalCallbacks.onAfterNodeRightClick({
+ position: { x, y },
+ filePath: data.fileName,
+ symbolId: data.symbolName,
+ });
+ });
+ }
+
+ /**
+ * Applies the graph layout algorithm to position nodes optimally
+ *
+ * @param collection - The collection of elements to layout (defaults to all nodes)
+ */
+ public layoutGraph(collection: Collection | Core) {
+ const collectionToLayout = collection || this.cy.nodes();
+ collectionToLayout.layout(this.layout).run();
+ }
+}
diff --git a/viewer/src/index.css b/viewer/src/index.css
new file mode 100644
index 00000000..a7d9572d
--- /dev/null
+++ b/viewer/src/index.css
@@ -0,0 +1,147 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+:root {
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --destructive-foreground: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --radius: 0.625rem;
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.145 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.145 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.985 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.396 0.141 25.723);
+ --destructive-foreground: oklch(0.637 0.237 25.331);
+ --border: oklch(0.269 0 0);
+ --input: oklch(0.269 0 0);
+ --ring: oklch(0.439 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(0.269 0 0);
+ --sidebar-ring: oklch(0.439 0 0);
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+@layer base {
+ :root {
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+ }
+
+ .dark {
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.439 0 0);
+ }
+}
diff --git a/viewer/src/lib/utils.ts b/viewer/src/lib/utils.ts
new file mode 100644
index 00000000..365058ce
--- /dev/null
+++ b/viewer/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/viewer/src/main.tsx b/viewer/src/main.tsx
new file mode 100644
index 00000000..65dc4ec6
--- /dev/null
+++ b/viewer/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+import "./index.css";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/viewer/src/pages/ManifestList.tsx b/viewer/src/pages/ManifestList.tsx
new file mode 100644
index 00000000..ff53239d
--- /dev/null
+++ b/viewer/src/pages/ManifestList.tsx
@@ -0,0 +1,94 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { fetchManifests, type ManifestListItem } from "../api";
+import { useTheme } from "../contexts/ThemeProvider";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../components/shadcn/Card";
+import { Button } from "../components/shadcn/Button";
+import { Moon, Sun } from "lucide-react";
+
+export default function ManifestList() {
+ const [manifests, setManifests] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const { theme, setTheme } = useTheme();
+
+ useEffect(() => {
+ fetchManifests()
+ .then(setManifests)
+ .finally(() => setLoading(false));
+ }, []);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ NanoAPI - Manifests
+
+ setTheme(theme === "dark" ? "light" : "dark")}
+ >
+ {theme === "dark" ? : }
+
+
+
+ {manifests.length === 0 ? (
+
+
+ No manifests found.
+
+ Run{" "}
+
+ napi generate
+ {" "}
+ to create your first manifest.
+
+
+
+ ) : (
+
+ {manifests.map((m) => (
+
+
+
+
+
+ {m.branch}
+
+
+ {m.commitSha.substring(0, 7)}
+
+
+
+ View
+
+
+
+ {m.fileCount} files · Commit{" "}
+ {new Date(m.commitShaDate).toLocaleDateString()} ·
+ Generated {new Date(m.createdAt).toLocaleString()}
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/viewer/src/pages/ManifestView.tsx b/viewer/src/pages/ManifestView.tsx
new file mode 100644
index 00000000..7ef8557e
--- /dev/null
+++ b/viewer/src/pages/ManifestView.tsx
@@ -0,0 +1,57 @@
+import { useEffect, useState } from "react";
+import { useParams, Link } from "react-router-dom";
+import { fetchManifest, fetchAudit, type ManifestEnvelope } from "../api";
+import type { DependencyManifest } from "../types/dependencyManifest";
+import type { AuditManifest } from "../types/auditManifest";
+import DependencyVisualizer from "../components/DependencyVisualizer/DependencyVisualizer";
+
+export default function ManifestView() {
+ const { id } = useParams<{ id: string }>();
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [envelope, setEnvelope] = useState(null);
+ const [auditManifest, setAuditManifest] = useState(
+ null,
+ );
+
+ useEffect(() => {
+ if (!id) return;
+ setLoading(true);
+ Promise.all([fetchManifest(id), fetchAudit(id)])
+ .then(([env, audit]) => {
+ setEnvelope(env);
+ setAuditManifest(audit);
+ })
+ .catch((e) => setError(e.message))
+ .finally(() => setLoading(false));
+ }, [id]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error || !envelope || !auditManifest) {
+ return (
+
+
Error: {error || "Unknown error"}
+
+ Back to manifests
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/viewer/src/types/auditManifest.ts b/viewer/src/types/auditManifest.ts
new file mode 100644
index 00000000..b426cdf7
--- /dev/null
+++ b/viewer/src/types/auditManifest.ts
@@ -0,0 +1,23 @@
+import type { Metric } from "./dependencyManifest";
+
+export type AuditAlert = {
+ metric: Metric;
+ severity: number;
+ message: {
+ short: string;
+ long: string;
+ };
+};
+
+export type SymbolAuditManifest = {
+ id: string;
+ alerts: Record;
+};
+
+export type FileAuditManifest = {
+ id: string;
+ alerts: Record;
+ symbols: Record;
+};
+
+export type AuditManifest = Record;
diff --git a/viewer/src/types/dependencyManifest.ts b/viewer/src/types/dependencyManifest.ts
new file mode 100644
index 00000000..a1539b40
--- /dev/null
+++ b/viewer/src/types/dependencyManifest.ts
@@ -0,0 +1,106 @@
+export const classSymbolType = "class";
+export const structSymbolType = "struct";
+export const enumSymbolType = "enum";
+export const unionSymbolType = "union";
+export const typedefSymbolType = "typedef";
+export const interfaceSymbolType = "interface";
+export const recordSymbolType = "record";
+export const delegateSymbolType = "delegate";
+export const functionSymbolType = "function";
+export const variableSymbolType = "variable";
+
+export type SymbolType =
+ | typeof classSymbolType
+ | typeof functionSymbolType
+ | typeof variableSymbolType
+ | typeof structSymbolType
+ | typeof enumSymbolType
+ | typeof unionSymbolType
+ | typeof typedefSymbolType
+ | typeof interfaceSymbolType
+ | typeof recordSymbolType
+ | typeof delegateSymbolType;
+
+// Aliases used by cytoscape code
+export const symbolTypeClass = classSymbolType;
+export const symbolTypeStruct = structSymbolType;
+export const symbolTypeEnum = enumSymbolType;
+export const symbolTypeUnion = unionSymbolType;
+export const symbolTypeTypedef = typedefSymbolType;
+export const symbolTypeInterface = interfaceSymbolType;
+export const symbolTypeRecord = recordSymbolType;
+export const symbolTypeDelegate = delegateSymbolType;
+export const symbolTypeFunction = functionSymbolType;
+export const symbolTypeVariable = variableSymbolType;
+
+export const metricLinesCount = "linesCount";
+export const metricCodeLineCount = "codeLineCount";
+export const metricCharacterCount = "characterCount";
+export const metricCodeCharacterCount = "codeCharacterCount";
+export const metricDependencyCount = "dependencyCount";
+export const metricDependentCount = "dependentCount";
+export const metricCyclomaticComplexity = "cyclomaticComplexity";
+
+export type Metric =
+ | typeof metricLinesCount
+ | typeof metricCodeLineCount
+ | typeof metricCharacterCount
+ | typeof metricCodeCharacterCount
+ | typeof metricDependencyCount
+ | typeof metricDependentCount
+ | typeof metricCyclomaticComplexity;
+
+export interface DependencyInfo {
+ id: string;
+ isExternal: boolean;
+ symbols: Record;
+}
+
+export interface DependentInfo {
+ id: string;
+ symbols: Record;
+}
+
+export interface SymbolDependencyManifest {
+ id: string;
+ type: SymbolType;
+ positions: {
+ start: { index: number; row: number; column: number };
+ end: { index: number; row: number; column: number };
+ }[];
+ metrics: {
+ [metricLinesCount]: number;
+ [metricCodeLineCount]: number;
+ [metricCharacterCount]: number;
+ [metricCodeCharacterCount]: number;
+ [metricDependencyCount]: number;
+ [metricDependentCount]: number;
+ [metricCyclomaticComplexity]: number;
+ };
+ description: string;
+ dependencies: Record;
+ dependents: Record;
+}
+
+export interface FileDependencyManifest {
+ id: string;
+ filePath: string;
+ language: string;
+ metrics: {
+ [metricLinesCount]: number;
+ [metricCodeLineCount]: number;
+ [metricCharacterCount]: number;
+ [metricCodeCharacterCount]: number;
+ [metricDependencyCount]: number;
+ [metricDependentCount]: number;
+ [metricCyclomaticComplexity]: number;
+ };
+ dependencies: Record;
+ dependents: Record;
+ symbols: Record;
+}
+
+export type DependencyManifest = Record;
+
+// V1 alias used by some cytoscape code
+export type DependencyManifestV1 = DependencyManifest;
diff --git a/viewer/vite.config.ts b/viewer/vite.config.ts
new file mode 100644
index 00000000..d91671f4
--- /dev/null
+++ b/viewer/vite.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import deno from "@deno/vite-plugin";
+import tailwindcss from "@tailwindcss/vite";
+
+export default defineConfig({
+ plugins: [
+ react(),
+ deno(),
+ tailwindcss(),
+ ],
+ optimizeDeps: {
+ include: ["react/jsx-runtime"],
+ },
+});