diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..56ca873
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install dependencies
+ run: sudo apt-get update && sudo apt-get install -y jq
+
+ - name: Run tests
+ run: bash tests/test-graphify.sh
+
+ shellcheck:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: ShellCheck core scripts
+ run: |
+ sudo apt-get update && sudo apt-get install -y shellcheck
+ shellcheck core/*.sh bin/*.sh || true
diff --git a/.gitignore b/.gitignore
index 125bf25..aff4610 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
graphify-out/
+system-graph/
*.pyc
__pycache__/
.env
node_modules/
+.last_build
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3c0c105
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,33 @@
+# Changelog
+
+## [1.0.0] - 2026-08-02
+
+### Added
+
+- **Standalone core** (`core/graphify-core.sh`): Build knowledge graphs without any external dependencies. Supports TypeScript, JavaScript, Python, Go, Bash, SQL, Markdown, and more.
+- **Query engine** (`core/graphify-query.sh`): Search, shortest path, impact analysis, god-nodes, neighbors, and statistics — all from the terminal.
+- **Test suite**: 31 tests covering graph building, querying, impact analysis, multi-repo merge, and error handling.
+- **GitHub Actions CI**: Automated testing on push and PR.
+- **Sample project**: Working TypeScript + Markdown example with pre-built graph output.
+- **CI integration example**: Pre-commit hook that warns about high-impact changes.
+- Incremental builds via `--update` flag (uses git diff).
+- Community detection (connected components).
+- Compact output format via `--format compact`.
+
+### Changed
+
+- Graphify Toolkit is now **fully standalone** — no external `graphify` package or API key required.
+- The `graphify` Python package is optional for semantic enrichment.
+- Updated README with comprehensive documentation, real query examples, and comparison table.
+
+## [0.1.0] - 2026-07-25
+
+### Added
+
+- Initial release with multi-repo orchestration scripts.
+- `graphify-build.sh`: Multi-repo graph builder.
+- `graphify-merge.sh`: Cross-repo graph merger with automatic edge detection.
+- `graphify-impact.sh`: Impact analysis with configurable depth.
+- `graphify-query-all.sh`: Cross-repo querying.
+- `graphify-auto-rebuild.sh`: Incremental rebuild with lockfile protection.
+- Claude Code skill for interactive graph exploration.
diff --git a/README.md b/README.md
index dd59c2c..94da3f9 100644
--- a/README.md
+++ b/README.md
@@ -1,141 +1,326 @@
Graphify Toolkit
+[](https://github.com/FvdHMBAI/agent-stack)
+
- Multi-repo knowledge graph orchestration for codebases.
- Build, merge, query, and analyze impact across repositories.
+ Turn any codebase into a queryable knowledge graph.
+ One command. Zero cloud dependencies. Works offline.
+
-## What it does
+---
-Graphify Toolkit extends [graphify](https://github.com/safishamsi/graphify) with scripts that work across multiple repositories:
+Graphify builds a JSON knowledge graph from your source code, documentation, and project structure. Query it from your terminal — find dependencies, trace impact, discover architectural bottlenecks.
-| Script | Purpose |
-|--------|---------|
-| `graphify-build.sh` | Build knowledge graphs for one or many repos |
-| `graphify-merge.sh` | Merge per-repo graphs into one system-wide graph with cross-repo edge detection |
-| `graphify-query-all.sh` | Query across all repos at once |
-| `graphify-impact.sh` | Show blast radius of a file change (affected nodes, depth control) |
-| `graphify-auto-rebuild.sh` | Incremental rebuild via cron or post-push, with lockfile protection |
+**No SaaS account. No API keys. Just `bash`, `jq`, and `python3`.**
-## Quick start
+## Quick Start
```bash
-# Install graphify first
-pip install graphifyy
-# or: uv tool install graphifyy
-
-# Clone the toolkit
+# Install
git clone https://github.com/FvdHMBAI/graphify-toolkit.git
-cd graphify-toolkit
-chmod +x bin/*.sh
+cd graphify-toolkit && bash install.sh
-# Set your repos (colon-separated)
-export GRAPHIFY_REPOS="/path/to/repo-a:/path/to/repo-b"
+# Build a graph
+graphify-core.sh /path/to/your/project
-# Build graphs for all repos + merge into system graph
-bin/graphify-build.sh
+# Query it
+graphify-query.sh search "auth"
+graphify-query.sh impact "src/auth/middleware.ts"
+graphify-query.sh path "login" "database"
+graphify-query.sh god-nodes --top 10
+```
-# Query across everything
-bin/graphify-query-all.sh "Which modules handle authentication?"
+**Three commands from zero to a fully queryable knowledge graph.**
-# Check impact before changing a file
-bin/graphify-impact.sh src/auth/login.ts
-bin/graphify-impact.sh --diff # analyze all uncommitted changes
+## What It Does
+
+Graphify extracts nodes (files, functions, classes, tables, headings) and edges (imports, exports, contains, depends_on) from your codebase:
+
+```
+your-project/
+ src/auth/middleware.ts → [file] middleware.ts
+ export authMiddleware → [function] authMiddleware()
+ import verifyToken → [edge] imports → jwt.ts
+ src/utils/jwt.ts → [file] jwt.ts
+ export verifyToken → [function] verifyToken()
```
-## Impact analysis
+Output: `graphify-out/graph.json` — a portable JSON file you can query, merge, visualize, or feed into any tool.
+
+## Supported Languages
+
+| Language | Extraction |
+|----------|-----------|
+| TypeScript / JavaScript | imports, exports, require() |
+| Python | import/from, def/class |
+| Go | imports, exported functions |
+| Bash | source/. commands, function definitions |
+| SQL | CREATE TABLE, FROM/JOIN references |
+| Markdown | links, headings |
+| YAML / JSON / TOML | structure |
+| Dockerfile / Terraform | structure |
+
+## Query Commands
+
+### Search
+
+Find nodes matching a term:
+
+```
+$ graphify-query.sh search "auth"
+
+Found 6 nodes matching 'auth':
+
+ [file ] middleware.ts src/auth/middleware.ts
+ [function ] authMiddleware() src/auth/middleware.ts
+ [function ] requireAdmin() src/auth/middleware.ts
+ [class ] AuthContext src/auth/middleware.ts
+ [directory ] auth src/auth
+```
+
+### Impact Analysis
The killer feature. Before you touch a file, see what breaks:
```
-$ bin/graphify-impact.sh src/auth/middleware.ts
+$ graphify-query.sh impact "middleware.ts"
+
+ File: middleware.ts
+ Direct nodes: 4
+ Affected (depth 2): 16
+ [directory] api, docs, utils, auth
+ [file] jwt.ts, users.ts, architecture.md
+ [function] createToken(), verifyToken(), listUsers(), createUser(), getUserById()
+ [heading] Security, Data flow, Architecture, Overview
- File: src/auth/middleware.ts
- Direct nodes: 3
- Affected nodes (depth 2): 47
- [function] validateToken, refreshSession, checkPermissions
- [api-route] /api/users, /api/admin, /api/bookings
- ... and 12 more
- [component] LoginForm, ProtectedRoute, AdminPanel
- ... and 8 more
- WARNING: HIGH IMPACT — 47 affected nodes!
+ WARNING: HIGH IMPACT — 16 affected nodes!
```
-Use `--diff` to analyze all files changed in your working tree at once.
+Or check all uncommitted changes at once:
+
+```bash
+graphify-query.sh impact --diff
+```
+
+### Shortest Path
+
+Trace how two concepts are connected:
+
+```
+$ graphify-query.sh path "middleware" "jwt"
+
+Path from 'middleware.ts' to 'jwt.ts' (2 hops):
+
+ middleware.ts (file)
+ -> authMiddleware() (function) [exports]
+ -> jwt.ts (file) [imports]
+```
+
+### God Nodes
+
+Find the most connected modules — your architectural bottlenecks:
+
+```
+$ graphify-query.sh god-nodes --top 5
+
+=== Top 5 most connected nodes ===
+
+ 8 connections [file ] middleware.ts src/auth/middleware.ts
+ 6 connections [file ] users.ts src/api/users.ts
+ 5 connections [file ] jwt.ts src/utils/jwt.ts
+```
+
+### Statistics
+
+```
+$ graphify-query.sh stats
-## Cross-repo merge
+=== Graph Statistics ===
-`graphify-merge.sh` detects cross-repo links automatically by matching shared function signatures. If `AuthService.validateToken()` exists in both your API and your frontend, the merge creates a `shares_implementation` edge between them.
+ Nodes: 22
+ Edges: 23
+ Communities: 1
-This reveals architectural dependencies no single-repo analysis can surface.
+ Node types:
+ function 8
+ file 5
+ directory 4
-The merged graph lives at `$GRAPHIFY_SYSTEM_DIR/graph.json` (default: `./system-graph/graph.json`).
+ Edge types:
+ exports 13
+ contains 5
+ imports 5
+```
-## Claude Code skill
+## Multi-Repo Support
-Graphify Toolkit includes a drop-in Claude Code skill for interactive graph exploration:
+Build graphs across multiple repositories and merge them into one system-wide graph:
```bash
-# Install the skill
-cp -r skill/* ~/.claude/skills/graphify/
+# Set your repos
+export GRAPHIFY_REPOS="/path/to/api:/path/to/frontend:/path/to/shared-lib"
-# Then in Claude Code:
-/graphify # build graph for current directory
-/graphify query "How does auth work?" # query the graph
-/graphify path "AuthModule" "Database" # shortest path
-/graphify explain "PaymentService" # explain a node
+# Build all + merge
+graphify-build.sh
+
+# Query across everything
+graphify-query-all.sh "Which services call the payment API?"
```
-The skill handles code, docs, PDFs, images, and videos. It uses AST extraction for code (free, no API key) and optional Gemini for semantic enrichment of documents.
+The merge engine detects when two repos share function signatures (e.g., an API client calling an API server) and creates `shares_implementation` edges automatically — revealing architectural dependencies no single-repo analysis can surface.
-## CI/CD integration
+## CI/CD Integration
-### Cron (nightly rebuild)
+### Pre-Commit Impact Gate
-```cron
-0 4 * * * GRAPHIFY_REPOS=/path/a:/path/b /opt/graphify-toolkit/bin/graphify-build.sh >> /var/log/graphify.log 2>&1
+Block commits that affect too many nodes:
+
+```bash
+cp examples/ci-integration.sh .git/hooks/pre-commit
+export GRAPHIFY_IMPACT_THRESHOLD=15
```
-### Git post-commit hook
+### Incremental Builds
+
+Only re-extract changed files (uses git diff internally):
```bash
-#!/bin/bash
-graphify-auto-rebuild.sh "$(git rev-parse --show-toplevel)" &
+graphify-core.sh /path/to/project --update
+```
+
+### Nightly Cron
+
+```cron
+0 4 * * * graphify-build.sh /path/to/repo1 /path/to/repo2 >> /var/log/graphify.log 2>&1
```
### GitHub Actions
```yaml
-- name: Update knowledge graph
+- name: Build knowledge graph
run: |
- pip install graphifyy
- ./graphify-toolkit/bin/graphify-auto-rebuild.sh .
+ sudo apt-get install -y jq
+ ./graphify-toolkit/core/graphify-core.sh .
+ ./graphify-toolkit/core/graphify-query.sh stats
```
-## Environment
+## Claude Code Skill
+
+Graphify Toolkit includes a drop-in [Claude Code](https://docs.anthropic.com/en/docs/claude-code) skill for interactive graph exploration:
+
+```bash
+# Install the skill
+cp -r skill/* ~/.claude/skills/graphify/
+
+# Then in Claude Code:
+/graphify # build graph for current directory
+/graphify query "How does auth work?" # query the graph
+/graphify path "AuthModule" "Database" # shortest path
+```
+
+## Architecture
+
+```
+graphify-toolkit/
+ core/
+ graphify-core.sh # Graph builder: code → graph.json (standalone)
+ graphify-query.sh # Query engine: search, path, impact, stats, god-nodes
+ bin/
+ graphify-build.sh # Multi-repo build orchestrator
+ graphify-merge.sh # Cross-repo graph merger (auto cross-links)
+ graphify-impact.sh # Standalone impact analysis
+ graphify-query-all.sh # Cross-repo query
+ graphify-auto-rebuild.sh # Incremental rebuild (cron/hook)
+ examples/
+ sample-project/ # Working example with TS + Markdown
+ output/ # Example graph.json
+ ci-integration.sh # Pre-commit hook example
+ tests/
+ test-graphify.sh # Test suite (31 tests)
+ skill/
+ SKILL.md # Claude Code skill integration
+```
+
+## Graph Format
+
+```json
+{
+ "nodes": [
+ {
+ "id": "src_auth_middleware_ts",
+ "label": "middleware.ts",
+ "type": "file",
+ "file_type": "typescript",
+ "source_file": "src/auth/middleware.ts",
+ "metadata": { "language": "typescript", "lines": 18, "kind": "file" },
+ "community": "0"
+ }
+ ],
+ "links": [
+ {
+ "source": "src_auth_middleware_ts",
+ "target": "src_utils_jwt_ts",
+ "relation": "imports",
+ "confidence": "EXTRACTED",
+ "weight": 1.0
+ }
+ ],
+ "stats": { "total_nodes": 22, "total_edges": 23, "communities": 1 }
+}
+```
+
+Compatible with D3.js, Gephi, Neo4j, Obsidian Canvas, or any tool that reads JSON graphs.
+
+## How It Compares
+
+| Feature | Graphify Toolkit | Sourcegraph | GitHub Code Search | grep |
+|---------|-----------------|-------------|-------------------|------|
+| Self-hosted | Yes | Enterprise only | No | Yes |
+| Dependencies | bash + jq + python3 | Docker + infra | N/A | None |
+| Knowledge graph | Full (nodes + edges + communities) | Code Intel (limited) | No | No |
+| Impact analysis | Depth-configurable, pre-commit gate | Limited | No | No |
+| Multi-repo merge | Auto cross-repo edge detection | Yes | Yes | Manual |
+| Offline | Yes | No | No | Yes |
+| Cost | Free | $$$ | Free (limited) | Free |
+| Setup time | 30 seconds | Hours | N/A | N/A |
+
+## Optional: Semantic Enrichment
+
+The standalone core uses pure AST extraction (free, no API key). For richer semantic analysis, install the [graphify](https://github.com/safishamsi/graphify) Python package:
+
+```bash
+pip install graphifyy
+export GEMINI_API_KEY="..." # optional: enables semantic extraction
+graphify-build.sh /path/to/project # uses graphify if available, falls back to core
+```
+
+## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
-| `GRAPHIFY_REPOS` | *(none)* | Colon-separated list of repo paths |
-| `GRAPHIFY_BIN` | `graphify` | Path to graphify binary |
-| `GRAPHIFY_SYSTEM_DIR` | `./system-graph` | Output dir for merged system graph |
-| `GRAPHIFY_LOG` | `/var/log/graphify-auto-rebuild.log` | Log file for auto-rebuild |
-| `GEMINI_API_KEY` | *(none)* | API key for semantic extraction (optional) |
+| `GRAPHIFY_GRAPH` | Auto-detected | Path to graph.json for queries |
+| `GRAPHIFY_PROJECT` | Current directory | Project root |
+| `GRAPHIFY_REPOS` | — | Colon-separated repo paths for multi-repo |
+| `GRAPHIFY_SYSTEM_DIR` | `./system-graph` | Merged graph output directory |
+| `GRAPHIFY_IMPACT_THRESHOLD` | 20 | Impact warning threshold |
+| `GEMINI_API_KEY` | — | Optional: semantic extraction via Gemini |
## Requirements
-- Bash 4+, Python 3.8+
-- [graphify](https://github.com/safishamsi/graphify) installed and on PATH
+- Bash 4+
+- `jq`
+- Python 3.8+ (stdlib only, no pip packages)
- Linux or macOS
## License
-MIT. See [LICENSE](LICENSE).
+[MIT](LICENSE)
---
@@ -143,3 +328,7 @@ MIT. See [LICENSE](LICENSE).
Built by Prompt & Build.
Used in production to orchestrate knowledge graphs across 7 repositories and 15+ applications.
+
+
+ If Graphify Toolkit helps you understand your codebase, consider giving it a star. It helps others find it.
+
diff --git a/core/graphify-core.sh b/core/graphify-core.sh
new file mode 100755
index 0000000..4f61877
--- /dev/null
+++ b/core/graphify-core.sh
@@ -0,0 +1,469 @@
+#!/usr/bin/env bash
+# graphify-core.sh — Build a knowledge graph from any codebase
+# Zero dependencies beyond bash + jq + python3 (stdlib only)
+#
+# Usage:
+# graphify-core.sh # full build
+# graphify-core.sh --update # incremental (changed files only)
+# graphify-core.sh --format compact # smaller output
+#
+# Output: /graphify-out/graph.json
+set -uo pipefail
+
+VERSION="1.0.0"
+TARGET="${1:-.}"
+MODE="full"
+FORMAT="standard"
+
+shift || true
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --update) MODE="incremental"; shift ;;
+ --format) FORMAT="${2:-standard}"; shift 2 ;;
+ --version) echo "graphify-core $VERSION"; exit 0 ;;
+ --help|-h)
+ echo "Usage: graphify-core.sh [--update] [--format compact]"
+ echo "Build a knowledge graph from source code, docs, and project structure."
+ echo ""
+ echo "Options:"
+ echo " --update Incremental build (only changed files)"
+ echo " --format compact Smaller JSON output (no metadata)"
+ echo " --version Show version"
+ exit 0
+ ;;
+ *) echo "Unknown option: $1" >&2; exit 1 ;;
+ esac
+done
+
+TARGET=$(cd "$TARGET" && pwd)
+OUT_DIR="$TARGET/graphify-out"
+GRAPH_FILE="$OUT_DIR/graph.json"
+mkdir -p "$OUT_DIR"
+
+command -v jq >/dev/null 2>&1 || { echo "ERROR: jq is required. Install with: apt install jq / brew install jq" >&2; exit 1; }
+command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 is required" >&2; exit 1; }
+
+# Determine files to process
+collect_files() {
+ local target="$1"
+ local mode="$2"
+
+ if [[ "$mode" == "incremental" ]] && git -C "$target" rev-parse HEAD &>/dev/null; then
+ local last_build=""
+ [[ -f "$OUT_DIR/.last_build" ]] && last_build=$(cat "$OUT_DIR/.last_build")
+
+ if [[ -n "$last_build" ]]; then
+ git -C "$target" diff --name-only "$last_build" HEAD 2>/dev/null
+ git -C "$target" diff --name-only 2>/dev/null
+ else
+ find_source_files "$target"
+ fi
+ else
+ find_source_files "$target"
+ fi
+}
+
+find_source_files() {
+ local target="$1"
+ find "$target" \
+ -not -path '*/node_modules/*' \
+ -not -path '*/.git/*' \
+ -not -path '*/graphify-out/*' \
+ -not -path '*/.next/*' \
+ -not -path '*/dist/*' \
+ -not -path '*/build/*' \
+ -not -path '*/.cache/*' \
+ -not -path '*/vendor/*' \
+ -not -path '*/__pycache__/*' \
+ -not -path '*/.venv/*' \
+ -not -name '*.lock' \
+ -not -name 'package-lock.json' \
+ -not -name '*.min.js' \
+ -not -name '*.min.css' \
+ -not -name '*.map' \
+ -type f \
+ \( -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.jsx' \
+ -o -name '*.py' -o -name '*.sh' -o -name '*.bash' \
+ -o -name '*.go' -o -name '*.rs' -o -name '*.rb' \
+ -o -name '*.java' -o -name '*.kt' -o -name '*.cs' \
+ -o -name '*.md' -o -name '*.mdx' \
+ -o -name '*.sql' -o -name '*.graphql' \
+ -o -name '*.yaml' -o -name '*.yml' -o -name '*.toml' \
+ -o -name '*.json' -o -name 'Dockerfile' -o -name '*.tf' \) \
+ | sort
+}
+
+echo "graphify-core $VERSION — building graph for $(basename "$TARGET")"
+echo "Mode: $MODE"
+
+FILE_LIST=$(collect_files "$TARGET" "$MODE")
+FILE_COUNT=$(echo "$FILE_LIST" | grep -c '.' || echo 0)
+echo "Files to process: $FILE_COUNT"
+
+if [[ "$FILE_COUNT" -eq 0 ]]; then
+ echo "No source files found."
+ exit 0
+fi
+
+# Run the extraction engine
+export _GRAPHIFY_TARGET="$TARGET"
+export _GRAPHIFY_FORMAT="$FORMAT"
+export _GRAPHIFY_MODE="$MODE"
+export _GRAPHIFY_GRAPH_FILE="$GRAPH_FILE"
+
+_GRAPHIFY_FILE_LIST=$(mktemp)
+echo "$FILE_LIST" > "$_GRAPHIFY_FILE_LIST"
+export _GRAPHIFY_FILE_LIST
+trap 'rm -f "$_GRAPHIFY_FILE_LIST"' EXIT
+
+python3 << 'PYEOF'
+import json, os, re, sys
+from collections import defaultdict
+from pathlib import Path
+
+target = os.environ["_GRAPHIFY_TARGET"]
+fmt = os.environ["_GRAPHIFY_FORMAT"]
+mode = os.environ["_GRAPHIFY_MODE"]
+graph_file = os.environ["_GRAPHIFY_GRAPH_FILE"]
+
+nodes = {}
+edges = []
+
+# Load existing graph for incremental mode
+if mode == "incremental" and os.path.exists(graph_file):
+ with open(graph_file) as f:
+ existing = json.load(f)
+ for n in existing.get("nodes", []):
+ nodes[n["id"]] = n
+ edges = existing.get("links", []) or existing.get("edges", [])
+
+def make_id(filepath):
+ """Create a stable node ID from a file path."""
+ rel = os.path.relpath(filepath, target)
+ return re.sub(r'[^a-zA-Z0-9]', '_', rel).strip('_').lower()
+
+def detect_language(filepath):
+ ext_map = {
+ '.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript',
+ '.py': 'python', '.sh': 'bash', '.bash': 'bash', '.go': 'go', '.rs': 'rust',
+ '.rb': 'ruby', '.java': 'java', '.kt': 'kotlin', '.cs': 'csharp',
+ '.md': 'markdown', '.mdx': 'markdown', '.sql': 'sql', '.graphql': 'graphql',
+ '.yaml': 'yaml', '.yml': 'yaml', '.toml': 'toml', '.json': 'json',
+ '.tf': 'terraform',
+ }
+ ext = Path(filepath).suffix.lower()
+ name = Path(filepath).name.lower()
+ if name == 'dockerfile':
+ return 'docker'
+ return ext_map.get(ext, 'unknown')
+
+def extract_imports_js(content, filepath):
+ """Extract JS/TS imports and exports."""
+ imports = []
+ exports = []
+ # import ... from '...'
+ for m in re.finditer(r'''(?:import|export)\s+.*?from\s+['"]([^'"]+)['"]''', content):
+ imports.append(m.group(1))
+ # require('...')
+ for m in re.finditer(r'''require\s*\(\s*['"]([^'"]+)['"]\s*\)''', content):
+ imports.append(m.group(1))
+ # export function/const/class
+ for m in re.finditer(r'export\s+(?:default\s+)?(?:function|const|let|var|class|interface|type|enum)\s+(\w+)', content):
+ exports.append(m.group(1))
+ return imports, exports
+
+def extract_imports_python(content, filepath):
+ """Extract Python imports."""
+ imports = []
+ exports = []
+ for m in re.finditer(r'^(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))', content, re.MULTILINE):
+ mod = m.group(1) or m.group(2)
+ imports.append(mod)
+ # def/class as exports
+ for m in re.finditer(r'^(?:def|class)\s+(\w+)', content, re.MULTILINE):
+ exports.append(m.group(1))
+ return imports, exports
+
+def extract_imports_go(content, filepath):
+ """Extract Go imports."""
+ imports = []
+ exports = []
+ for m in re.finditer(r'"([^"]+)"', content):
+ if '/' in m.group(1):
+ imports.append(m.group(1))
+ for m in re.finditer(r'^func\s+(\w+)', content, re.MULTILINE):
+ if m.group(1)[0].isupper():
+ exports.append(m.group(1))
+ return imports, exports
+
+def extract_shell_sources(content, filepath):
+ """Extract bash source/. commands."""
+ imports = []
+ exports = []
+ for m in re.finditer(r'(?:source|\.) ["\']?([^"\';\s]+)', content):
+ imports.append(m.group(1))
+ for m in re.finditer(r'^(\w+)\s*\(\s*\)', content, re.MULTILINE):
+ exports.append(m.group(1))
+ return imports, exports
+
+def extract_md_links(content, filepath):
+ """Extract markdown links and headers."""
+ imports = []
+ exports = []
+ for m in re.finditer(r'\[([^\]]+)\]\(([^)]+)\)', content):
+ link_target = m.group(2)
+ if not link_target.startswith('http'):
+ imports.append(link_target)
+ for m in re.finditer(r'^#{1,3}\s+(.+)$', content, re.MULTILINE):
+ exports.append(m.group(1).strip())
+ return imports, exports
+
+def extract_sql_refs(content, filepath):
+ """Extract SQL table references."""
+ imports = []
+ exports = []
+ for m in re.finditer(r'(?:FROM|JOIN|INTO|UPDATE|TABLE)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?["\']?(\w+)', content, re.IGNORECASE):
+ table = m.group(1).lower()
+ if table not in ('select', 'where', 'set', 'values', 'as', 'on', 'and', 'or'):
+ imports.append(f"table:{table}")
+ for m in re.finditer(r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["\']?(\w+)', content, re.IGNORECASE):
+ exports.append(f"table:{m.group(1).lower()}")
+ return imports, exports
+
+EXTRACTORS = {
+ 'typescript': extract_imports_js,
+ 'javascript': extract_imports_js,
+ 'python': extract_imports_python,
+ 'go': extract_imports_go,
+ 'bash': extract_shell_sources,
+ 'markdown': extract_md_links,
+ 'sql': extract_sql_refs,
+}
+
+def resolve_import(imp, source_file):
+ """Try to resolve an import path to a file in the project."""
+ if imp.startswith('.'):
+ base = os.path.dirname(source_file)
+ candidate = os.path.normpath(os.path.join(base, imp))
+ for ext in ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js']:
+ full = os.path.join(target, candidate + ext)
+ if os.path.exists(full):
+ return os.path.relpath(full, target)
+ # Try direct match
+ for ext in ['.ts', '.tsx', '.js', '.jsx', '.py', '.sh']:
+ candidate = os.path.join(target, imp.replace('.', '/') + ext)
+ if os.path.exists(candidate):
+ return os.path.relpath(candidate, target)
+ return None
+
+# Process files
+file_list_path = os.environ.get("_GRAPHIFY_FILE_LIST", "")
+if file_list_path and os.path.exists(file_list_path):
+ with open(file_list_path) as fl:
+ file_list = [f.strip() for f in fl.readlines() if f.strip()]
+else:
+ file_list = [f.strip() for f in sys.stdin.readlines() if f.strip()]
+processed = 0
+export_registry = defaultdict(list) # symbol -> [file_id]
+
+for filepath in file_list:
+ abs_path = filepath if os.path.isabs(filepath) else os.path.join(target, filepath)
+ if not os.path.exists(abs_path):
+ continue
+
+ rel_path = os.path.relpath(abs_path, target)
+ file_id = make_id(abs_path)
+ lang = detect_language(abs_path)
+
+ try:
+ with open(abs_path, encoding='utf-8', errors='replace') as f:
+ content = f.read()
+ except Exception:
+ continue
+
+ line_count = content.count('\n') + 1
+
+ # Create file node
+ node = {
+ "id": file_id,
+ "label": os.path.basename(abs_path),
+ "type": "file",
+ "file_type": lang,
+ "source_file": rel_path,
+ "source_location": "L1",
+ "metadata": {
+ "language": lang,
+ "lines": line_count,
+ "kind": "file",
+ },
+ }
+ nodes[file_id] = node
+
+ # Directory node
+ dir_path = os.path.dirname(rel_path)
+ if dir_path:
+ dir_id = make_id(os.path.join(target, dir_path))
+ if dir_id not in nodes:
+ nodes[dir_id] = {
+ "id": dir_id,
+ "label": os.path.basename(dir_path),
+ "type": "directory",
+ "file_type": "directory",
+ "source_file": dir_path,
+ "source_location": "",
+ "metadata": {"kind": "directory"},
+ }
+ edges.append({
+ "source": dir_id,
+ "target": file_id,
+ "relation": "contains",
+ "confidence": "EXTRACTED",
+ "weight": 1.0,
+ })
+
+ # Extract imports and exports
+ extractor = EXTRACTORS.get(lang)
+ if extractor:
+ imports, exports = extractor(content, rel_path)
+
+ # Create export nodes
+ for exp in exports:
+ exp_id = f"{file_id}__{re.sub(r'[^a-zA-Z0-9]', '_', exp).lower()}"
+ kind = "function"
+ if lang == 'markdown':
+ kind = "heading"
+ elif exp.startswith("table:"):
+ kind = "table"
+ exp = exp[6:]
+ elif exp[0:1].isupper() and lang in ('typescript', 'javascript', 'python', 'java'):
+ kind = "class"
+
+ nodes[exp_id] = {
+ "id": exp_id,
+ "label": f"{exp}()" if kind == "function" else exp,
+ "type": kind,
+ "file_type": lang,
+ "source_file": rel_path,
+ "source_location": "",
+ "metadata": {"kind": kind, "exported": True},
+ }
+ edges.append({
+ "source": file_id,
+ "target": exp_id,
+ "relation": "exports",
+ "confidence": "EXTRACTED",
+ "weight": 1.0,
+ })
+ export_registry[exp.lower()].append(exp_id)
+
+ # Process imports
+ for imp in imports:
+ resolved = resolve_import(imp, rel_path)
+ if resolved:
+ target_id = make_id(os.path.join(target, resolved))
+ edges.append({
+ "source": file_id,
+ "target": target_id,
+ "relation": "imports",
+ "confidence": "EXTRACTED",
+ "weight": 1.0,
+ })
+ else:
+ # External dependency
+ dep_id = f"ext__{re.sub(r'[^a-zA-Z0-9]', '_', imp).lower()}"
+ if dep_id not in nodes:
+ nodes[dep_id] = {
+ "id": dep_id,
+ "label": imp,
+ "type": "external",
+ "file_type": "dependency",
+ "source_file": "",
+ "source_location": "",
+ "metadata": {"kind": "external_dependency"},
+ }
+ edges.append({
+ "source": file_id,
+ "target": dep_id,
+ "relation": "depends_on",
+ "confidence": "EXTRACTED",
+ "weight": 0.5,
+ })
+
+ processed += 1
+ if processed % 100 == 0:
+ print(f" Processed {processed}/{len(file_list)} files...", file=sys.stderr)
+
+# Simple community detection (connected components + directory grouping)
+adj = defaultdict(set)
+for e in edges:
+ s, t = e.get("source", ""), e.get("target", "")
+ if s in nodes and t in nodes:
+ adj[s].add(t)
+ adj[t].add(s)
+
+community_id = 0
+visited = set()
+for nid in nodes:
+ if nid in visited:
+ continue
+ # BFS
+ queue = [nid]
+ component = []
+ while queue:
+ cur = queue.pop(0)
+ if cur in visited:
+ continue
+ visited.add(cur)
+ component.append(cur)
+ for neighbor in adj.get(cur, []):
+ if neighbor not in visited:
+ queue.append(neighbor)
+ for member in component:
+ nodes[member]["community"] = str(community_id)
+ community_id += 1
+
+# Deduplicate edges
+seen_edges = set()
+unique_edges = []
+for e in edges:
+ key = (e.get("source", ""), e.get("target", ""), e.get("relation", ""))
+ if key not in seen_edges:
+ seen_edges.add(key)
+ unique_edges.append(e)
+
+# Compact format strips metadata
+if fmt == "compact":
+ for nid in nodes:
+ if "metadata" in nodes[nid]:
+ del nodes[nid]["metadata"]
+
+# Build output
+node_list = list(nodes.values())
+graph = {
+ "nodes": node_list,
+ "links": unique_edges,
+ "stats": {
+ "files_processed": processed,
+ "total_nodes": len(node_list),
+ "total_edges": len(unique_edges),
+ "communities": community_id,
+ "version": "graphify-core-1.0.0",
+ },
+}
+
+with open(graph_file, 'w', encoding='utf-8') as f:
+ json.dump(graph, f, ensure_ascii=False)
+
+print(f" Graph built: {len(node_list)} nodes, {len(unique_edges)} edges, {community_id} communities", file=sys.stderr)
+PYEOF
+
+# Record commit hash for incremental builds
+if git -C "$TARGET" rev-parse HEAD &>/dev/null 2>&1; then
+ git -C "$TARGET" rev-parse HEAD > "$OUT_DIR/.last_build"
+fi
+
+NODES=$(jq '.stats.total_nodes' "$GRAPH_FILE" 2>/dev/null || echo 0)
+EDGES=$(jq '.stats.total_edges' "$GRAPH_FILE" 2>/dev/null || echo 0)
+echo ""
+echo "Done: $NODES nodes, $EDGES edges"
+echo "Output: $GRAPH_FILE"
diff --git a/core/graphify-query.sh b/core/graphify-query.sh
new file mode 100755
index 0000000..d30d99b
--- /dev/null
+++ b/core/graphify-query.sh
@@ -0,0 +1,390 @@
+#!/usr/bin/env bash
+# graphify-query.sh — Query a graphify knowledge graph
+#
+# Usage:
+# graphify-query.sh search "auth" # find nodes matching "auth"
+# graphify-query.sh path "login" "database" # shortest path between two nodes
+# graphify-query.sh impact "src/auth/middleware.ts" # what's affected by changes to this file
+# graphify-query.sh impact --diff # impact of all uncommitted changes
+# graphify-query.sh stats # graph statistics
+# graphify-query.sh neighbors "node_id" [--depth N] # show neighbors
+# graphify-query.sh god-nodes [--top N] # most connected nodes
+set -uo pipefail
+
+ACTION="${1:-}"
+shift || true
+
+# Find graph.json
+find_graph() {
+ if [[ -n "${GRAPHIFY_GRAPH:-}" ]] && [[ -f "$GRAPHIFY_GRAPH" ]]; then
+ echo "$GRAPHIFY_GRAPH"
+ return 0
+ fi
+ local dir="${GRAPHIFY_PROJECT:-$(pwd)}"
+ if [[ -f "$dir/graphify-out/graph.json" ]]; then
+ echo "$dir/graphify-out/graph.json"
+ return 0
+ fi
+ # Try git root
+ local root
+ root=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null || echo "")
+ if [[ -n "$root" ]] && [[ -f "$root/graphify-out/graph.json" ]]; then
+ echo "$root/graphify-out/graph.json"
+ return 0
+ fi
+ echo "ERROR: No graph.json found. Run graphify-core.sh first." >&2
+ return 1
+}
+
+GRAPH=$(find_graph) || exit 1
+
+case "$ACTION" in
+ search|find|query)
+ QUERY="${1:-}"
+ [[ -z "$QUERY" ]] && { echo "Usage: graphify-query.sh search \"\"" >&2; exit 1; }
+ python3 << PYEOF
+import json, re, sys
+
+with open("$GRAPH") as f:
+ data = json.load(f)
+
+query = "$QUERY".lower()
+terms = query.split()
+matches = []
+
+for n in data.get("nodes", []):
+ nid = n.get("id", "").lower()
+ label = n.get("label", "").lower()
+ src = n.get("source_file", "").lower()
+ ntype = n.get("type", "")
+
+ score = 0
+ for t in terms:
+ if t in label: score += 3
+ if t in nid: score += 2
+ if t in src: score += 1
+
+ if score > 0:
+ matches.append((score, n))
+
+matches.sort(key=lambda x: -x[0])
+
+if not matches:
+ print(f"No nodes matching '{query}'")
+ sys.exit(0)
+
+print(f"Found {len(matches)} nodes matching '{query}':\n")
+for score, n in matches[:20]:
+ src = n.get("source_file", "")
+ ntype = n.get("type", "unknown")
+ print(f" [{ntype:12s}] {n['label']:40s} {src}")
+
+if len(matches) > 20:
+ print(f"\n ... and {len(matches)-20} more")
+PYEOF
+ ;;
+
+ path)
+ FROM="${1:-}"
+ TO="${2:-}"
+ [[ -z "$FROM" ]] || [[ -z "$TO" ]] && { echo "Usage: graphify-query.sh path \"\" \"\"" >&2; exit 1; }
+ python3 << PYEOF
+import json, sys
+from collections import defaultdict, deque
+
+with open("$GRAPH") as f:
+ data = json.load(f)
+
+nodes = {n["id"]: n for n in data.get("nodes", [])}
+edges = data.get("links", []) or data.get("edges", [])
+
+def find_node(term):
+ term_l = term.lower()
+ best = None
+ best_score = 0
+ for nid, n in nodes.items():
+ label = n.get("label", "").lower()
+ if term_l == label or term_l == nid:
+ return nid
+ score = 0
+ if term_l in label: score = 3
+ elif term_l in nid: score = 2
+ if score > best_score:
+ best_score = score
+ best = nid
+ return best
+
+src = find_node("$FROM")
+tgt = find_node("$TO")
+
+if not src:
+ print(f"Node not found: $FROM")
+ sys.exit(1)
+if not tgt:
+ print(f"Node not found: $TO")
+ sys.exit(1)
+
+# BFS shortest path
+adj = defaultdict(list)
+for e in edges:
+ s, t = e.get("source", ""), e.get("target", "")
+ rel = e.get("relation", "")
+ adj[s].append((t, rel))
+ adj[t].append((s, rel))
+
+visited = {src}
+queue = deque([(src, [(src, "")])])
+
+while queue:
+ current, path = queue.popleft()
+ if current == tgt:
+ print(f"Path from '{nodes[src]['label']}' to '{nodes[tgt]['label']}' ({len(path)-1} hops):\n")
+ for i, (nid, rel) in enumerate(path):
+ n = nodes.get(nid, {})
+ prefix = " " + ("-> " if i > 0 else " ")
+ rel_str = f" [{rel}]" if rel else ""
+ print(f"{prefix}{n.get('label', nid):40s} ({n.get('type', '?')}){rel_str}")
+ sys.exit(0)
+
+ for neighbor, rel in adj.get(current, []):
+ if neighbor not in visited:
+ visited.add(neighbor)
+ queue.append((neighbor, path + [(neighbor, rel)]))
+
+print(f"No path found between '{nodes.get(src,{}).get('label',src)}' and '{nodes.get(tgt,{}).get('label',tgt)}'")
+PYEOF
+ ;;
+
+ impact)
+ TARGET_FILE="${1:-}"
+ DEPTH=2
+ DIFF_MODE=false
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --diff) DIFF_MODE=true; shift ;;
+ --depth) DEPTH="${2:-2}"; shift 2 ;;
+ *) TARGET_FILE="$1"; shift ;;
+ esac
+ done
+
+ if [[ "$DIFF_MODE" == "true" ]]; then
+ CHANGED=$(git diff --name-only HEAD 2>/dev/null; git diff --cached --name-only 2>/dev/null)
+ if [[ -z "$CHANGED" ]]; then
+ echo "No changed files found"
+ exit 0
+ fi
+ echo "=== Impact analysis for uncommitted changes ==="
+ echo ""
+ while IFS= read -r file; do
+ [[ "$file" == *.lock ]] && continue
+ [[ "$file" == package-lock* ]] && continue
+ "$0" impact "$file" --depth "$DEPTH"
+ echo ""
+ done <<< "$CHANGED"
+ exit 0
+ fi
+
+ [[ -z "$TARGET_FILE" ]] && { echo "Usage: graphify-query.sh impact [--depth N]" >&2; exit 1; }
+ python3 << PYEOF
+import json, sys
+from collections import defaultdict
+
+with open("$GRAPH") as f:
+ data = json.load(f)
+
+nodes = {n["id"]: n for n in data.get("nodes", [])}
+edges = data.get("links", []) or data.get("edges", [])
+target_file = "$TARGET_FILE"
+depth = $DEPTH
+
+# Find matching nodes
+target_base = target_file.rsplit("/", 1)[-1].rsplit(".", 1)[0] if "/" in target_file else target_file.rsplit(".", 1)[0]
+matched = []
+for nid, n in nodes.items():
+ src = n.get("source_file", "")
+ if target_file in src or target_base in nid.lower():
+ matched.append(nid)
+
+if not matched:
+ print(f" No graph nodes found for {target_file}")
+ sys.exit(0)
+
+adj = defaultdict(set)
+for e in edges:
+ s, t = e.get("source", ""), e.get("target", "")
+ if s and t:
+ adj[s].add(t)
+ adj[t].add(s)
+
+affected = set()
+frontier = set(matched)
+for d in range(depth):
+ next_frontier = set()
+ for nid in frontier:
+ for neighbor in adj.get(nid, []):
+ if neighbor not in affected and neighbor not in set(matched):
+ next_frontier.add(neighbor)
+ affected.add(neighbor)
+ frontier = next_frontier
+
+print(f" File: {target_file}")
+print(f" Direct nodes: {len(matched)}")
+print(f" Affected (depth {depth}): {len(affected)}")
+
+if affected:
+ by_type = defaultdict(list)
+ for nid in list(affected)[:40]:
+ n = nodes.get(nid, {})
+ ntype = n.get("type", "unknown")
+ by_type[ntype].append(n.get("label", nid)[:50])
+
+ for t, items in sorted(by_type.items()):
+ shown = ", ".join(items[:5])
+ extra = f" ... +{len(items)-5}" if len(items) > 5 else ""
+ print(f" [{t}] {shown}{extra}")
+
+if len(affected) > 20:
+ print(f"\n WARNING: HIGH IMPACT — {len(affected)} affected nodes!")
+PYEOF
+ ;;
+
+ stats)
+ python3 << PYEOF
+import json
+from collections import Counter
+
+with open("$GRAPH") as f:
+ data = json.load(f)
+
+nodes = data.get("nodes", [])
+edges = data.get("links", []) or data.get("edges", [])
+stats = data.get("stats", {})
+
+print("=== Graph Statistics ===\n")
+print(f" Nodes: {len(nodes)}")
+print(f" Edges: {len(edges)}")
+print(f" Communities: {stats.get('communities', '?')}")
+print(f" Version: {stats.get('version', 'unknown')}")
+print()
+
+types = Counter(n.get("type", "unknown") for n in nodes)
+print(" Node types:")
+for t, c in types.most_common():
+ print(f" {t:20s} {c:5d}")
+
+rels = Counter(e.get("relation", "unknown") for e in edges)
+print("\n Edge types:")
+for r, c in rels.most_common():
+ print(f" {r:20s} {c:5d}")
+
+langs = Counter(n.get("file_type", "?") for n in nodes if n.get("type") == "file")
+if langs:
+ print("\n Languages:")
+ for l, c in langs.most_common():
+ print(f" {l:20s} {c:5d}")
+PYEOF
+ ;;
+
+ god-nodes|gods|hubs)
+ TOP="${1:-10}"
+ [[ "${1:-}" == "--top" ]] && TOP="${2:-10}"
+ python3 << PYEOF
+import json
+from collections import Counter
+
+with open("$GRAPH") as f:
+ data = json.load(f)
+
+nodes = {n["id"]: n for n in data.get("nodes", [])}
+edges = data.get("links", []) or data.get("edges", [])
+
+degree = Counter()
+for e in edges:
+ degree[e.get("source", "")] += 1
+ degree[e.get("target", "")] += 1
+
+print(f"=== Top $TOP most connected nodes ===\n")
+for nid, count in degree.most_common(int("$TOP")):
+ n = nodes.get(nid, {})
+ print(f" {count:4d} connections [{n.get('type','?'):10s}] {n.get('label', nid):40s} {n.get('source_file','')}")
+PYEOF
+ ;;
+
+ neighbors)
+ NODE="${1:-}"
+ DEPTH=1
+ [[ "${2:-}" == "--depth" ]] && DEPTH="${3:-1}"
+ [[ -z "$NODE" ]] && { echo "Usage: graphify-query.sh neighbors \"\" [--depth N]" >&2; exit 1; }
+ python3 << PYEOF
+import json
+from collections import defaultdict
+
+with open("$GRAPH") as f:
+ data = json.load(f)
+
+nodes = {n["id"]: n for n in data.get("nodes", [])}
+edges = data.get("links", []) or data.get("edges", [])
+
+# Find node
+target = None
+for nid in nodes:
+ if "$NODE".lower() in nid.lower() or "$NODE".lower() in nodes[nid].get("label","").lower():
+ target = nid
+ if "$NODE".lower() == nid.lower() or "$NODE".lower() == nodes[nid].get("label","").lower():
+ break
+
+if not target:
+ print(f"Node not found: $NODE")
+ import sys; sys.exit(1)
+
+adj = defaultdict(list)
+for e in edges:
+ s, t = e.get("source", ""), e.get("target", "")
+ rel = e.get("relation", "")
+ adj[s].append((t, rel, "->"))
+ adj[t].append((s, rel, "<-"))
+
+n = nodes[target]
+print(f"Neighbors of '{n['label']}' ({n.get('type','?')}):\n")
+
+visited = {target}
+frontier = [(target, 0)]
+while frontier:
+ cur, d = frontier.pop(0)
+ if d >= int("$DEPTH"):
+ continue
+ for neighbor, rel, direction in adj.get(cur, []):
+ if neighbor not in visited:
+ visited.add(neighbor)
+ nn = nodes.get(neighbor, {})
+ indent = " " * (d + 1)
+ print(f"{indent}{direction} [{rel:12s}] {nn.get('label', neighbor):40s} ({nn.get('type','?')})")
+ frontier.append((neighbor, d + 1))
+
+print(f"\n Total: {len(visited)-1} neighbors (depth $DEPTH)")
+PYEOF
+ ;;
+
+ ""|--help|-h)
+ echo "graphify-query.sh — Query a graphify knowledge graph"
+ echo ""
+ echo "Commands:"
+ echo " search Find nodes matching a term"
+ echo " path Shortest path between two nodes"
+ echo " impact [--depth N] What's affected by changes to a file"
+ echo " impact --diff Impact of all uncommitted changes"
+ echo " stats Graph statistics"
+ echo " neighbors [--depth] Show node neighbors"
+ echo " god-nodes [--top N] Most connected nodes"
+ echo ""
+ echo "Environment:"
+ echo " GRAPHIFY_GRAPH= Path to graph.json"
+ echo " GRAPHIFY_PROJECT= Project root (auto-detects graphify-out/)"
+ ;;
+
+ *)
+ echo "Unknown command: $ACTION" >&2
+ echo "Run 'graphify-query.sh --help' for usage" >&2
+ exit 1
+ ;;
+esac
diff --git a/examples/ci-integration.sh b/examples/ci-integration.sh
new file mode 100755
index 0000000..16aca20
--- /dev/null
+++ b/examples/ci-integration.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# Example: Pre-commit hook that warns about high-impact changes
+# Install: cp examples/ci-integration.sh .git/hooks/pre-commit
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+QUERY="${SCRIPT_DIR}/../core/graphify-query.sh"
+THRESHOLD="${GRAPHIFY_IMPACT_THRESHOLD:-20}"
+
+if [[ ! -f "graphify-out/graph.json" ]]; then
+ exit 0
+fi
+
+export GRAPHIFY_GRAPH="graphify-out/graph.json"
+
+CHANGED=$(git diff --cached --name-only | grep -E '\.(ts|tsx|js|jsx|py|sh|go|rs)$' || true)
+[[ -z "$CHANGED" ]] && exit 0
+
+HIGH_IMPACT=false
+
+while IFS= read -r file; do
+ RESULT=$("$QUERY" impact "$file" 2>&1)
+ AFFECTED=$(echo "$RESULT" | grep -oP 'Affected.*?(\d+)' | grep -oP '\d+$' || echo 0)
+
+ if [[ "$AFFECTED" -gt "$THRESHOLD" ]]; then
+ echo "WARNING: $file affects $AFFECTED nodes (threshold: $THRESHOLD)"
+ HIGH_IMPACT=true
+ fi
+done <<< "$CHANGED"
+
+if $HIGH_IMPACT; then
+ echo ""
+ echo "High-impact changes detected. Review before committing."
+ echo "Override with: git commit --no-verify"
+ exit 1
+fi
+
+exit 0
diff --git a/examples/output/graph.json b/examples/output/graph.json
new file mode 100644
index 0000000..93dd33b
--- /dev/null
+++ b/examples/output/graph.json
@@ -0,0 +1 @@
+{"nodes": [{"id": "docs_architecture_md", "label": "architecture.md", "type": "file", "file_type": "markdown", "source_file": "docs/architecture.md", "source_location": "L1", "metadata": {"language": "markdown", "lines": 22, "kind": "file"}, "community": "0"}, {"id": "docs", "label": "docs", "type": "directory", "file_type": "directory", "source_file": "docs", "source_location": "", "metadata": {"kind": "directory"}, "community": "0"}, {"id": "docs_architecture_md__architecture", "label": "Architecture", "type": "heading", "file_type": "markdown", "source_file": "docs/architecture.md", "source_location": "", "metadata": {"kind": "heading", "exported": true}, "community": "0"}, {"id": "docs_architecture_md__overview", "label": "Overview", "type": "heading", "file_type": "markdown", "source_file": "docs/architecture.md", "source_location": "", "metadata": {"kind": "heading", "exported": true}, "community": "0"}, {"id": "docs_architecture_md__data_flow", "label": "Data flow", "type": "heading", "file_type": "markdown", "source_file": "docs/architecture.md", "source_location": "", "metadata": {"kind": "heading", "exported": true}, "community": "0"}, {"id": "docs_architecture_md__security", "label": "Security", "type": "heading", "file_type": "markdown", "source_file": "docs/architecture.md", "source_location": "", "metadata": {"kind": "heading", "exported": true}, "community": "0"}, {"id": "src_api_health_ts", "label": "health.ts", "type": "file", "file_type": "typescript", "source_file": "src/api/health.ts", "source_location": "L1", "metadata": {"language": "typescript", "lines": 7, "kind": "file"}, "community": "0"}, {"id": "src_api", "label": "api", "type": "directory", "file_type": "directory", "source_file": "src/api", "source_location": "", "metadata": {"kind": "directory"}, "community": "0"}, {"id": "src_api_health_ts__healthcheck", "label": "healthCheck()", "type": "function", "file_type": "typescript", "source_file": "src/api/health.ts", "source_location": "", "metadata": {"kind": "function", "exported": true}, "community": "0"}, {"id": "src_api_users_ts", "label": "users.ts", "type": "file", "file_type": "typescript", "source_file": "src/api/users.ts", "source_location": "L1", "metadata": {"language": "typescript", "lines": 26, "kind": "file"}, "community": "0"}, {"id": "src_api_users_ts__getuserbyid", "label": "getUserById()", "type": "function", "file_type": "typescript", "source_file": "src/api/users.ts", "source_location": "", "metadata": {"kind": "function", "exported": true}, "community": "0"}, {"id": "src_api_users_ts__listusers", "label": "listUsers()", "type": "function", "file_type": "typescript", "source_file": "src/api/users.ts", "source_location": "", "metadata": {"kind": "function", "exported": true}, "community": "0"}, {"id": "src_api_users_ts__createuser", "label": "createUser()", "type": "function", "file_type": "typescript", "source_file": "src/api/users.ts", "source_location": "", "metadata": {"kind": "function", "exported": true}, "community": "0"}, {"id": "src_auth_middleware_ts", "label": "middleware.ts", "type": "file", "file_type": "typescript", "source_file": "src/auth/middleware.ts", "source_location": "L1", "metadata": {"language": "typescript", "lines": 19, "kind": "file"}, "community": "0"}, {"id": "src_auth", "label": "auth", "type": "directory", "file_type": "directory", "source_file": "src/auth", "source_location": "", "metadata": {"kind": "directory"}, "community": "0"}, {"id": "src_auth_middleware_ts__authcontext", "label": "AuthContext", "type": "class", "file_type": "typescript", "source_file": "src/auth/middleware.ts", "source_location": "", "metadata": {"kind": "class", "exported": true}, "community": "0"}, {"id": "src_auth_middleware_ts__authmiddleware", "label": "authMiddleware()", "type": "function", "file_type": "typescript", "source_file": "src/auth/middleware.ts", "source_location": "", "metadata": {"kind": "function", "exported": true}, "community": "0"}, {"id": "src_auth_middleware_ts__requireadmin", "label": "requireAdmin()", "type": "function", "file_type": "typescript", "source_file": "src/auth/middleware.ts", "source_location": "", "metadata": {"kind": "function", "exported": true}, "community": "0"}, {"id": "src_utils_jwt_ts", "label": "jwt.ts", "type": "file", "file_type": "typescript", "source_file": "src/utils/jwt.ts", "source_location": "L1", "metadata": {"language": "typescript", "lines": 11, "kind": "file"}, "community": "0"}, {"id": "src_utils", "label": "utils", "type": "directory", "file_type": "directory", "source_file": "src/utils", "source_location": "", "metadata": {"kind": "directory"}, "community": "0"}, {"id": "src_utils_jwt_ts__verifytoken", "label": "verifyToken()", "type": "function", "file_type": "typescript", "source_file": "src/utils/jwt.ts", "source_location": "", "metadata": {"kind": "function", "exported": true}, "community": "0"}, {"id": "src_utils_jwt_ts__createtoken", "label": "createToken()", "type": "function", "file_type": "typescript", "source_file": "src/utils/jwt.ts", "source_location": "", "metadata": {"kind": "function", "exported": true}, "community": "0"}], "links": [{"source": "docs", "target": "docs_architecture_md", "relation": "contains", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "docs_architecture_md", "target": "docs_architecture_md__architecture", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "docs_architecture_md", "target": "docs_architecture_md__overview", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "docs_architecture_md", "target": "docs_architecture_md__data_flow", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "docs_architecture_md", "target": "docs_architecture_md__security", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "docs_architecture_md", "target": "src_auth_middleware_ts", "relation": "imports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "docs_architecture_md", "target": "src_utils_jwt_ts", "relation": "imports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_api", "target": "src_api_health_ts", "relation": "contains", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_api_health_ts", "target": "src_api_health_ts__healthcheck", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_api", "target": "src_api_users_ts", "relation": "contains", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_api_users_ts", "target": "src_api_users_ts__getuserbyid", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_api_users_ts", "target": "src_api_users_ts__listusers", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_api_users_ts", "target": "src_api_users_ts__createuser", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_api_users_ts", "target": "src_auth_middleware_ts", "relation": "imports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_auth", "target": "src_auth_middleware_ts", "relation": "contains", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_auth_middleware_ts", "target": "src_auth_middleware_ts__authcontext", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_auth_middleware_ts", "target": "src_auth_middleware_ts__authmiddleware", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_auth_middleware_ts", "target": "src_auth_middleware_ts__requireadmin", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_auth_middleware_ts", "target": "src_utils_jwt_ts", "relation": "imports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_auth_middleware_ts", "target": "src_api_users_ts", "relation": "imports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_utils", "target": "src_utils_jwt_ts", "relation": "contains", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_utils_jwt_ts", "target": "src_utils_jwt_ts__verifytoken", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}, {"source": "src_utils_jwt_ts", "target": "src_utils_jwt_ts__createtoken", "relation": "exports", "confidence": "EXTRACTED", "weight": 1.0}], "stats": {"files_processed": 5, "total_nodes": 22, "total_edges": 23, "communities": 1, "version": "graphify-core-1.0.0"}}
\ No newline at end of file
diff --git a/examples/sample-project/docs/architecture.md b/examples/sample-project/docs/architecture.md
new file mode 100644
index 0000000..7e573f4
--- /dev/null
+++ b/examples/sample-project/docs/architecture.md
@@ -0,0 +1,21 @@
+# Architecture
+
+## Overview
+
+The sample project demonstrates a typical API structure with:
+
+- **Auth layer** — JWT-based authentication via [middleware](../src/auth/middleware.ts)
+- **API routes** — User management and health checks
+- **Utils** — Shared helpers like [JWT utilities](../src/utils/jwt.ts)
+
+## Data flow
+
+1. Request arrives at API endpoint
+2. Auth middleware extracts and verifies JWT
+3. Route handler processes the request
+4. Response returned
+
+## Security
+
+All protected endpoints require a valid JWT token.
+Admin-only endpoints check the `role` claim.
diff --git a/examples/sample-project/src/api/health.ts b/examples/sample-project/src/api/health.ts
new file mode 100644
index 0000000..2235220
--- /dev/null
+++ b/examples/sample-project/src/api/health.ts
@@ -0,0 +1,6 @@
+export function healthCheck(): { status: string; uptime: number } {
+ return {
+ status: 'ok',
+ uptime: process.uptime(),
+ }
+}
diff --git a/examples/sample-project/src/api/users.ts b/examples/sample-project/src/api/users.ts
new file mode 100644
index 0000000..62c5c80
--- /dev/null
+++ b/examples/sample-project/src/api/users.ts
@@ -0,0 +1,25 @@
+import { authMiddleware, requireAdmin } from '../auth/middleware'
+
+interface User {
+ id: string
+ name: string
+ email: string
+}
+
+const users: User[] = []
+
+export function getUserById(id: string): User | undefined {
+ return users.find(u => u.id === id)
+}
+
+export function listUsers(req: Request): User[] {
+ const ctx = authMiddleware(req)
+ requireAdmin(ctx)
+ return users
+}
+
+export function createUser(name: string, email: string): User {
+ const user = { id: crypto.randomUUID(), name, email }
+ users.push(user)
+ return user
+}
diff --git a/examples/sample-project/src/auth/middleware.ts b/examples/sample-project/src/auth/middleware.ts
new file mode 100644
index 0000000..271ca97
--- /dev/null
+++ b/examples/sample-project/src/auth/middleware.ts
@@ -0,0 +1,18 @@
+import { verifyToken } from '../utils/jwt'
+import { getUserById } from '../api/users'
+
+export interface AuthContext {
+ userId: string
+ role: 'admin' | 'user'
+}
+
+export function authMiddleware(req: Request): AuthContext {
+ const token = req.headers.get('Authorization')?.replace('Bearer ', '')
+ if (!token) throw new Error('No token')
+ const payload = verifyToken(token)
+ return { userId: payload.sub, role: payload.role }
+}
+
+export function requireAdmin(ctx: AuthContext) {
+ if (ctx.role !== 'admin') throw new Error('Forbidden')
+}
diff --git a/examples/sample-project/src/utils/jwt.ts b/examples/sample-project/src/utils/jwt.ts
new file mode 100644
index 0000000..29a0c24
--- /dev/null
+++ b/examples/sample-project/src/utils/jwt.ts
@@ -0,0 +1,10 @@
+export function verifyToken(token: string): { sub: string; role: string } {
+ const [, payload] = token.split('.')
+ return JSON.parse(atob(payload))
+}
+
+export function createToken(userId: string, role: string): string {
+ const header = btoa(JSON.stringify({ alg: 'HS256' }))
+ const payload = btoa(JSON.stringify({ sub: userId, role }))
+ return `${header}.${payload}.signature`
+}
diff --git a/install.sh b/install.sh
new file mode 100755
index 0000000..6735653
--- /dev/null
+++ b/install.sh
@@ -0,0 +1,60 @@
+#!/usr/bin/env bash
+# Graphify Toolkit installer
+# Installs core scripts and orchestration tools to PATH
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+INSTALL_DIR="${GRAPHIFY_INSTALL_DIR:-$HOME/.local/bin}"
+
+echo "Graphify Toolkit — Installer"
+echo ""
+
+# Check dependencies
+MISSING=""
+command -v jq >/dev/null 2>&1 || MISSING="${MISSING} jq"
+command -v python3 >/dev/null 2>&1 || MISSING="${MISSING} python3"
+
+if [[ -n "$MISSING" ]]; then
+ echo "Missing dependencies:$MISSING"
+ echo ""
+ echo "Install with:"
+ echo " Ubuntu/Debian: sudo apt install$MISSING"
+ echo " macOS: brew install$MISSING"
+ echo ""
+ exit 1
+fi
+
+# Create install directory
+mkdir -p "$INSTALL_DIR"
+
+# Install core scripts
+echo "Installing core scripts to $INSTALL_DIR..."
+for script in "$SCRIPT_DIR"/core/*.sh; do
+ [[ -f "$script" ]] || continue
+ name=$(basename "$script")
+ cp "$script" "$INSTALL_DIR/$name"
+ chmod +x "$INSTALL_DIR/$name"
+ echo " $name"
+done
+
+# Install bin scripts
+echo "Installing orchestration tools..."
+for script in "$SCRIPT_DIR"/bin/*.sh; do
+ [[ -f "$script" ]] || continue
+ name=$(basename "$script")
+ cp "$script" "$INSTALL_DIR/$name"
+ chmod +x "$INSTALL_DIR/$name"
+ echo " $name"
+done
+
+echo ""
+
+# Check PATH
+if ! echo "$PATH" | grep -q "$INSTALL_DIR"; then
+ echo "NOTE: $INSTALL_DIR is not in your PATH."
+ echo "Add it with:"
+ echo " echo 'export PATH=\"$INSTALL_DIR:\$PATH\"' >> ~/.bashrc"
+ echo ""
+fi
+
+echo "Done. Run 'graphify-core.sh /path/to/project' to build your first graph."
diff --git a/tests/test-graphify.sh b/tests/test-graphify.sh
new file mode 100755
index 0000000..47111ca
--- /dev/null
+++ b/tests/test-graphify.sh
@@ -0,0 +1,234 @@
+#!/usr/bin/env bash
+# Test suite for graphify-toolkit core
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
+CORE="$ROOT_DIR/core/graphify-core.sh"
+QUERY="$ROOT_DIR/core/graphify-query.sh"
+SAMPLE="$ROOT_DIR/examples/sample-project"
+
+PASS=0
+FAIL=0
+ERRORS=""
+
+assert() {
+ local desc="$1"
+ local result="$2"
+ local expected="$3"
+ if echo "$result" | grep -qE "$expected"; then
+ PASS=$((PASS + 1))
+ echo " PASS: $desc"
+ else
+ FAIL=$((FAIL + 1))
+ ERRORS="${ERRORS}\n FAIL: $desc\n expected: $expected\n got: $(echo "$result" | head -3)"
+ echo " FAIL: $desc"
+ fi
+}
+
+assert_exit() {
+ local desc="$1"
+ local cmd="$2"
+ local expected_code="$3"
+ eval "$cmd" >/dev/null 2>&1
+ local actual=$?
+ if [[ "$actual" -eq "$expected_code" ]]; then
+ PASS=$((PASS + 1))
+ echo " PASS: $desc"
+ else
+ FAIL=$((FAIL + 1))
+ ERRORS="${ERRORS}\n FAIL: $desc (exit $actual, expected $expected_code)"
+ echo " FAIL: $desc"
+ fi
+}
+
+# Clean up any previous test output
+rm -rf "$SAMPLE/graphify-out" 2>/dev/null
+
+echo "=== graphify-toolkit test suite ==="
+echo ""
+
+# ─── Test 1: Graph Building ───
+echo "--- Core: Graph Building ---"
+
+OUTPUT=$("$CORE" "$SAMPLE" 2>&1)
+assert "graphify-core runs successfully" "$OUTPUT" "Done:"
+assert "graph reports nodes" "$OUTPUT" "[0-9]+ nodes"
+assert "graph reports edges" "$OUTPUT" "[0-9]+ edges"
+
+# Check output file
+assert "graph.json exists" "$(ls "$SAMPLE/graphify-out/graph.json" 2>&1)" "graph.json"
+
+# Check graph content
+NODES=$(python3 -c "import json; print(len(json.load(open('$SAMPLE/graphify-out/graph.json'))['nodes']))")
+EDGES=$(python3 -c "import json; print(len(json.load(open('$SAMPLE/graphify-out/graph.json'))['links']))")
+assert "graph has nodes" "$NODES" "^[1-9]"
+assert "graph has edges" "$EDGES" "^[1-9]"
+
+# Check node structure
+NODE_KEYS=$(python3 -c "
+import json
+n = json.load(open('$SAMPLE/graphify-out/graph.json'))['nodes'][0]
+print(' '.join(sorted(n.keys())))
+")
+assert "nodes have id field" "$NODE_KEYS" "id"
+assert "nodes have label field" "$NODE_KEYS" "label"
+assert "nodes have type field" "$NODE_KEYS" "type"
+
+# Check that imports are detected
+IMPORT_EDGES=$(python3 -c "
+import json
+edges = json.load(open('$SAMPLE/graphify-out/graph.json'))['links']
+print(sum(1 for e in edges if e.get('relation') == 'imports'))
+")
+assert "import edges detected" "$IMPORT_EDGES" "^[1-9]"
+
+# Check exports detected
+EXPORT_EDGES=$(python3 -c "
+import json
+edges = json.load(open('$SAMPLE/graphify-out/graph.json'))['links']
+print(sum(1 for e in edges if e.get('relation') == 'exports'))
+")
+assert "export edges detected" "$EXPORT_EDGES" "^[1-9]"
+
+# Check specific nodes
+HAS_MIDDLEWARE=$(python3 -c "
+import json
+nodes = json.load(open('$SAMPLE/graphify-out/graph.json'))['nodes']
+print('found' if any('middleware' in n.get('label','').lower() for n in nodes) else 'missing')
+")
+assert "middleware node found" "$HAS_MIDDLEWARE" "found"
+
+HAS_JWT=$(python3 -c "
+import json
+nodes = json.load(open('$SAMPLE/graphify-out/graph.json'))['nodes']
+print('found' if any('jwt' in n.get('label','').lower() for n in nodes) else 'missing')
+")
+assert "jwt node found" "$HAS_JWT" "found"
+
+echo ""
+
+# ─── Test 2: Incremental Build ───
+echo "--- Core: Incremental Build ---"
+
+# Run again — should work in incremental mode if git is available
+OUTPUT2=$("$CORE" "$SAMPLE" --update 2>&1)
+assert "incremental build runs" "$OUTPUT2" "Done:|No source files"
+
+echo ""
+
+# ─── Test 3: Query - Search ───
+echo "--- Query: Search ---"
+
+export GRAPHIFY_GRAPH="$SAMPLE/graphify-out/graph.json"
+
+SEARCH_RESULT=$("$QUERY" search "auth" 2>&1)
+assert "search finds auth nodes" "$SEARCH_RESULT" "auth"
+assert "search shows node type" "$SEARCH_RESULT" "\[.*\]"
+
+SEARCH_EMPTY=$("$QUERY" search "nonexistent_xyz_12345" 2>&1)
+assert "search returns empty for garbage" "$SEARCH_EMPTY" "No nodes matching"
+
+echo ""
+
+# ─── Test 4: Query - Stats ───
+echo "--- Query: Stats ---"
+
+STATS=$("$QUERY" stats 2>&1)
+assert "stats shows node count" "$STATS" "Nodes:"
+assert "stats shows edge count" "$STATS" "Edges:"
+assert "stats shows node types" "$STATS" "Node types:"
+assert "stats shows edge types" "$STATS" "Edge types:"
+
+echo ""
+
+# ─── Test 5: Query - God Nodes ───
+echo "--- Query: God Nodes ---"
+
+GODS=$("$QUERY" god-nodes --top 5 2>&1)
+assert "god-nodes returns results" "$GODS" "connections"
+
+echo ""
+
+# ─── Test 6: Query - Impact ───
+echo "--- Query: Impact ---"
+
+IMPACT=$("$QUERY" impact "middleware.ts" 2>&1)
+assert "impact finds middleware" "$IMPACT" "middleware"
+assert "impact shows affected count" "$IMPACT" "Affected"
+
+echo ""
+
+# ─── Test 7: Query - Path ───
+echo "--- Query: Path ---"
+
+PATH_RESULT=$("$QUERY" path "middleware" "jwt" 2>&1)
+assert "path finds route" "$PATH_RESULT" "hop|Path"
+
+echo ""
+
+# ─── Test 8: Query - Neighbors ───
+echo "--- Query: Neighbors ---"
+
+NEIGHBORS=$("$QUERY" neighbors "middleware" 2>&1)
+assert "neighbors returns results" "$NEIGHBORS" "Neighbors|Total"
+
+echo ""
+
+# ─── Test 9: Multi-repo Merge ───
+echo "--- Multi-repo Merge ---"
+
+MERGE_SCRIPT="$ROOT_DIR/bin/graphify-merge.sh"
+if [[ -x "$MERGE_SCRIPT" ]]; then
+ # Create a second tiny project
+ SAMPLE2=$(mktemp -d)
+ mkdir -p "$SAMPLE2/src"
+ cat > "$SAMPLE2/src/index.ts" << 'EOF'
+import { authMiddleware } from 'shared-auth'
+export function main() { console.log('hello') }
+EOF
+ "$CORE" "$SAMPLE2" >/dev/null 2>&1
+
+ export GRAPHIFY_SYSTEM_DIR=$(mktemp -d)
+ "$MERGE_SCRIPT" "$SAMPLE" "$SAMPLE2" 2>&1 | tail -3
+ MERGE_RESULT=$?
+
+ if [[ -f "$GRAPHIFY_SYSTEM_DIR/graph.json" ]]; then
+ MERGED_NODES=$(python3 -c "import json; print(json.load(open('$GRAPHIFY_SYSTEM_DIR/graph.json'))['stats']['totalNodes'])")
+ assert "merged graph has nodes from both" "$MERGED_NODES" "^[1-9]"
+ else
+ assert "merge creates graph.json" "missing" "graph.json"
+ fi
+
+ rm -rf "$SAMPLE2" "$GRAPHIFY_SYSTEM_DIR"
+else
+ echo " SKIP: graphify-merge.sh not found"
+fi
+
+echo ""
+
+# ─── Test 10: Help / Error handling ───
+echo "--- Error Handling ---"
+
+assert_exit "core --help exits 0" "'$CORE' --help" 0
+assert_exit "core --version exits 0" "'$CORE' --version" 0
+assert_exit "query --help exits 0" "'$QUERY' --help" 0
+assert_exit "query bad command exits 1" "'$QUERY' badcmd" 1
+
+echo ""
+
+# ─── Cleanup ───
+rm -rf "$SAMPLE/graphify-out" 2>/dev/null
+unset GRAPHIFY_GRAPH GRAPHIFY_SYSTEM_DIR
+
+# ─── Summary ───
+echo "=== Results ==="
+echo " PASS: $PASS"
+echo " FAIL: $FAIL"
+if [[ $FAIL -gt 0 ]]; then
+ echo -e "\nFailures:$ERRORS"
+ exit 1
+fi
+echo ""
+echo "All tests passed."
+exit 0