chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
.git
|
||||
.github
|
||||
.venv
|
||||
venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
.mypy_cache
|
||||
dist
|
||||
build
|
||||
*.egg-info
|
||||
graphify-out
|
||||
graphify-benchmark
|
||||
graphify_eval
|
||||
graphify_test
|
||||
worked
|
||||
llm-stack-corpus
|
||||
llm-stack-demo
|
||||
product-site
|
||||
ebook
|
||||
tests
|
||||
docs
|
||||
*.md
|
||||
!README.md
|
||||
@@ -0,0 +1,6 @@
|
||||
# Tell GitHub Linguist to ignore generated/example HTML files when calculating
|
||||
# the repo's primary language. Without this, large graph.html artifacts in
|
||||
# worked/ dominate the byte count and the repo shows as HTML instead of Python.
|
||||
worked/**/*.html linguist-vendored=true
|
||||
graphify-out/**/*.html linguist-vendored=true
|
||||
*.html linguist-detectable=false
|
||||
@@ -0,0 +1 @@
|
||||
github: safishamsi
|
||||
@@ -0,0 +1,106 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "main"]
|
||||
pull_request:
|
||||
branches: ["v1", "v2", "v3", "v4", "v5", "v6", "v7", "v8", "main"]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
skillgen-check:
|
||||
# Fast lint-style guard: the skill files under graphify/ are generated from
|
||||
# the fragments in tools/skillgen/. This fails if someone hand-edited a
|
||||
# generated file or forgot to re-run the generator and bless expected/, and it
|
||||
# runs the build-time validators that guard per-host coverage, the file_type
|
||||
# enum, the monolith round-trips, and the always-on round-trips.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# The audit-coverage, monolith-roundtrip, and always-on-roundtrip
|
||||
# validators read blobs from origin/v8. A shallow checkout omits that
|
||||
# ref, so fetch the full history here and the validators run for real
|
||||
# (rather than skipping). The other jobs stay shallow.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# --frozen keeps uv from re-resolving and rewriting uv.lock as a side
|
||||
# effect of `uv run`; the lock is committed and must not churn in CI.
|
||||
- name: Check generated skill artifacts are up to date
|
||||
run: uv run --frozen python -m tools.skillgen --check
|
||||
|
||||
- name: Audit per-host v8 coverage
|
||||
run: uv run --frozen python -m tools.skillgen --audit-coverage
|
||||
|
||||
- name: Check the file_type enum is a singleton
|
||||
run: uv run --frozen python -m tools.skillgen --schema-singleton
|
||||
|
||||
- name: Round-trip the monoliths against v8
|
||||
run: uv run --frozen python -m tools.skillgen --monolith-roundtrip
|
||||
|
||||
- name: Round-trip the always-on blocks against v8
|
||||
run: uv run --frozen python -m tools.skillgen --always-on-roundtrip
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.10", "3.12"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# test_skillgen.py reads pre-split skill bodies from the immutable
|
||||
# baseline commit via `git show`; a shallow checkout omits that history
|
||||
# and the baseline tests fail. Full history mirrors the skillgen-check job.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
# --frozen installs straight from the committed uv.lock without re-resolving
|
||||
# or rewriting it, so CI never churns the lock.
|
||||
- name: Install dependencies
|
||||
run: uv sync --all-extras --frozen
|
||||
|
||||
- name: Run tests
|
||||
run: uv run --frozen pytest tests/ -q --tb=short
|
||||
|
||||
- name: Verify install works end-to-end
|
||||
run: |
|
||||
uv run --frozen graphify --help
|
||||
uv run --frozen graphify install
|
||||
|
||||
security-scan:
|
||||
# The dev deps include bandit and pip-audit. Run them in CI so a new
|
||||
# HIGH-severity finding or vulnerable dependency is caught on the PR that
|
||||
# introduces it, rather than at the next manual audit.
|
||||
# Non-blocking for now (continue-on-error) to avoid breaking CI on
|
||||
# pre-existing findings; remove continue-on-error after the initial
|
||||
# cleanup pass.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: bandit (static security analysis)
|
||||
continue-on-error: true
|
||||
run: uv run --frozen bandit -r graphify -ll
|
||||
|
||||
- name: pip-audit (dependency vulnerabilities)
|
||||
continue-on-error: true
|
||||
run: uv run --frozen pip-audit --strict
|
||||
@@ -0,0 +1,64 @@
|
||||
name: Release graph asset
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-graph:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # needed to upload release assets
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v8.1.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install graphify
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Build graph of graphify source (AST-only, no API cost)
|
||||
# graphify/skills/ contains 126+ .md files that trigger the LLM pass.
|
||||
# Temporarily ignore all .md files so extraction stays pure AST (no API key needed).
|
||||
run: |
|
||||
echo "*.md" >> .graphifyignore
|
||||
echo "*.txt" >> .graphifyignore
|
||||
uv run --frozen graphify extract graphify/ --out .
|
||||
git checkout .graphifyignore 2>/dev/null || rm -f .graphifyignore
|
||||
|
||||
- name: Cluster, label communities and generate GRAPH_REPORT.md
|
||||
# cluster-only writes GRAPH_REPORT.md and names communities (no LLM needed
|
||||
# for basic numeric labels; --no-label skips LLM labeling entirely).
|
||||
run: uv run --frozen graphify cluster-only . --no-label
|
||||
|
||||
- name: Generate HTML viewer
|
||||
run: uv run --frozen graphify export html --graph graphify-out/graph.json
|
||||
|
||||
- name: Bundle release asset
|
||||
run: |
|
||||
mkdir -p dist-graph
|
||||
cp graphify-out/graph.json dist-graph/
|
||||
cp graphify-out/graph.html dist-graph/
|
||||
[ -f graphify-out/GRAPH_REPORT.md ] && cp graphify-out/GRAPH_REPORT.md dist-graph/ || true
|
||||
tar -czf graphify-self-graph.tar.gz -C dist-graph .
|
||||
echo "Asset contents:"
|
||||
tar -tzf graphify-self-graph.tar.gz
|
||||
|
||||
- name: Upload to GitHub release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: graphify-self-graph.tar.gz
|
||||
|
||||
- name: Upload as workflow artifact (for workflow_dispatch runs)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: graphify-self-graph
|
||||
path: graphify-self-graph.tar.gz
|
||||
retention-days: 7
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
*.so
|
||||
*.egg
|
||||
.graphify/
|
||||
graphify-out/
|
||||
.graphify_*.json
|
||||
.graphify_python
|
||||
.claude/
|
||||
skills/
|
||||
# The packaged skill bundles under graphify/skills/ are generated, committed
|
||||
# artifacts (rendered by tools/skillgen). Keep them tracked even though the
|
||||
# broad skills/ rule above ignores install-target skill dirs elsewhere.
|
||||
!graphify/skills/
|
||||
!graphify/skills/**
|
||||
# The skillgen core fragments are the human-edited source of the lean SKILL.md.
|
||||
# A global "core" ignore (for core dumps) would otherwise drop them.
|
||||
!tools/skillgen/fragments/core/
|
||||
!tools/skillgen/fragments/core/**
|
||||
docs/superpowers/
|
||||
.vscode/
|
||||
.kilo
|
||||
openspec/
|
||||
# Local benchmark scripts — never commit
|
||||
scripts/run_k2_*.py
|
||||
scripts/llm.py
|
||||
scripts/benchmark_kimi*.json
|
||||
scripts/benchmark_kimi*.py
|
||||
paper/
|
||||
|
||||
# macOS Finder metadata
|
||||
.DS_Store
|
||||
@@ -0,0 +1,22 @@
|
||||
# Run with: uv run pre-commit install (pre-commit is already a dev dependency)
|
||||
# One-off across the tree: uv run pre-commit run --all-files
|
||||
#
|
||||
# The skillgen hook is the local anti-drift guard. The skill files under
|
||||
# graphify/ are generated from the fragments in tools/skillgen/; a hand-edit to
|
||||
# a generated file fails this check the same way CI does. Run
|
||||
# `python -m tools.skillgen` then `--bless` to regenerate after a fragment edit.
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: skillgen-check
|
||||
name: skillgen --check (generated skill artifacts are up to date)
|
||||
entry: python -m tools.skillgen --check
|
||||
language: system
|
||||
pass_filenames: false
|
||||
always_run: true
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--config", "pyproject.toml"]
|
||||
@@ -0,0 +1,8 @@
|
||||
## graphify
|
||||
|
||||
This project has a graphify knowledge graph at graphify-out/.
|
||||
|
||||
Rules:
|
||||
- Before answering architecture or codebase questions, read graphify-out/GRAPH_REPORT.md for god nodes and community structure
|
||||
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
||||
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
|
||||
@@ -0,0 +1,85 @@
|
||||
# Architecture
|
||||
|
||||
graphify is a Claude Code skill backed by a Python library. The skill orchestrates the library; the library can be used standalone.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
detect() → extract() → build_graph() → cluster() → analyze() → report() → export()
|
||||
```
|
||||
|
||||
Each stage is a single function in its own module. They communicate through plain Python dicts and NetworkX graphs - no shared state, no side effects outside `graphify-out/`.
|
||||
|
||||
## Module responsibilities
|
||||
|
||||
| Module | Function | Input → Output |
|
||||
|--------|----------|----------------|
|
||||
| `detect.py` | `collect_files(root)` | directory → `[Path]` filtered list |
|
||||
| `extract.py` | `extract(path)` | file path → `{nodes, edges}` dict |
|
||||
| `build.py` | `build_graph(extractions)` | list of extraction dicts → `nx.Graph` |
|
||||
| `cluster.py` | `cluster(G)` | graph → graph with `community` attr on each node |
|
||||
| `analyze.py` | `analyze(G)` | graph → analysis dict (god nodes, surprises, questions) |
|
||||
| `report.py` | `render_report(G, analysis)` | graph + analysis → GRAPH_REPORT.md string |
|
||||
| `export.py` | `export(G, out_dir, ...)` | graph → Obsidian vault, graph.json, graph.html, graph.svg |
|
||||
| `callflow_html.py` | `write_callflow_html(...)` | graphify-out files → Mermaid architecture/call-flow HTML |
|
||||
| `ingest.py` | `ingest(url, ...)` | URL → file saved to corpus dir |
|
||||
| `cache.py` | `check_semantic_cache / save_semantic_cache` | files → (cached, uncached) split |
|
||||
| `security.py` | validation helpers | URL / path / label → validated or raises |
|
||||
| `validate.py` | `validate_extraction(data)` | extraction dict → raises on schema errors |
|
||||
| `serve.py` | `start_server(graph_path)` | graph file path → MCP stdio server |
|
||||
| `watch.py` | `watch(root, flag_path)` | directory → writes flag file on change |
|
||||
| `benchmark.py` | `run_benchmark(graph_path)` | graph file → corpus vs subgraph token comparison |
|
||||
|
||||
## Extraction output schema
|
||||
|
||||
Every extractor returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{"id": "unique_string", "label": "human name", "source_file": "path", "source_location": "L42"}
|
||||
],
|
||||
"edges": [
|
||||
{"source": "id_a", "target": "id_b", "relation": "calls|imports|uses|...", "confidence": "EXTRACTED|INFERRED|AMBIGUOUS"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`validate.py` enforces this schema before `build_graph()` consumes it.
|
||||
|
||||
## Confidence labels
|
||||
|
||||
| Label | Meaning |
|
||||
|-------|---------|
|
||||
| `EXTRACTED` | Relationship is explicitly stated in the source (e.g., an import statement, a direct call) |
|
||||
| `INFERRED` | Relationship is a reasonable deduction (e.g., call-graph second pass, co-occurrence in context) |
|
||||
| `AMBIGUOUS` | Relationship is uncertain; flagged for human review in GRAPH_REPORT.md |
|
||||
|
||||
## Adding a new language extractor
|
||||
|
||||
1. Add a `extract_<lang>(path: Path) -> dict` function in `extract.py` following the existing pattern (tree-sitter parse → walk nodes → collect `nodes` and `edges` → call-graph second pass for INFERRED `calls` edges).
|
||||
2. Register the file suffix in `extract()` dispatch and `collect_files()`.
|
||||
3. Add the suffix to `CODE_EXTENSIONS` in `detect.py` and `_WATCHED_EXTENSIONS` in `watch.py`.
|
||||
4. Add the tree-sitter package to `pyproject.toml` dependencies.
|
||||
5. Add a fixture file to `tests/fixtures/` and tests to `tests/test_languages.py`.
|
||||
|
||||
## Security
|
||||
|
||||
All external input passes through `graphify/security.py` before use:
|
||||
|
||||
- URLs → `validate_url()` (http/https only) + `_NoFileRedirectHandler` (blocks file:// redirects)
|
||||
- Fetched content → `safe_fetch()` / `safe_fetch_text()` (size cap, timeout)
|
||||
- Graph file paths → `validate_graph_path()` (must resolve inside `graphify-out/`)
|
||||
- Node labels → `sanitize_label()` (strips control chars, caps 256 chars, HTML-escapes)
|
||||
|
||||
See `SECURITY.md` for the full threat model.
|
||||
|
||||
## Testing
|
||||
|
||||
One test file per module under `tests/`. Run with:
|
||||
|
||||
```bash
|
||||
pytest tests/ -q
|
||||
```
|
||||
|
||||
All tests are pure unit tests - no network calls, no file system side effects outside `tmp_path`.
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
# graphify Benchmarks
|
||||
|
||||
How graphify performs as conversational long-term memory and as a
|
||||
code-intelligence layer, measured on an open harness with competing systems run
|
||||
under identical conditions (same model, same budgets, same grader).
|
||||
|
||||
Last updated: 2026-07-05.
|
||||
|
||||
## Summary
|
||||
|
||||
graphify's deterministic graph plus hybrid retrieval has the best retrieval
|
||||
recall on LOCOMO of any system tested, the best LOCOMO QA accuracy per dollar,
|
||||
ties for the best LongMemEval score, and builds its index with zero LLM credits.
|
||||
Every system was run on the same harness with one shared model (Kimi K2.6),
|
||||
identical budgets, and a judge blind-validated against a second independent judge
|
||||
(90.6% agreement, Cohen's kappa 0.81).
|
||||
|
||||
Highlights:
|
||||
- LOCOMO retrieval recall@10 of 0.497, about 10x mem0 (0.048) and above BM25 (0.362).
|
||||
- LOCOMO QA accuracy of 45.3%: +18 points over mem0, +14 over BM25, and within
|
||||
4.4 points of supermemory at about a tenth of supermemory's ingest cost.
|
||||
- LongMemEval-S of 76%, tied for best with dense RAG.
|
||||
- Zero LLM credits to build the graph, and about 11x cheaper memory ingest than
|
||||
supermemory ($1.40 vs $15.67).
|
||||
|
||||
## Results at a glance
|
||||
|
||||
| Suite | Dataset (n) | Metric | graphify | Field |
|
||||
|---|---|---|---|---|
|
||||
| Memory | LOCOMO (300) | QA accuracy | 45.3% | supermemory 49.7% (11x ingest cost), bm25 31.3%, mem0 27.3% |
|
||||
| Memory | LOCOMO (300) | recall@10 | 0.497 | bm25 0.362, mem0 0.048 |
|
||||
| Memory | LongMemEval-S (50) | QA accuracy | 76% | dense RAG 76%, hybrid 74%, mem0 70% |
|
||||
| Cost | LOCOMO ingest | USD | ~$1.40 | supermemory $15.67, mem0 $3.48 |
|
||||
| Cost | graph build | LLM credits | $0 | n/a |
|
||||
|
||||
## Harness
|
||||
|
||||
graphify's own harness. Competing systems (mem0, supermemory) are run as
|
||||
adapters inside it, so every system sees the same model, token budget, and
|
||||
grader.
|
||||
|
||||
```
|
||||
ingest -> index -> search -> answer -> grade
|
||||
(build) (store) (retrieve) (Kimi K2.6) (key-fact coverage)
|
||||
```
|
||||
|
||||
- Memory suite (`memory/`): graphify's graph retrieval vs dedicated memory
|
||||
systems (mem0, supermemory) and classic baselines (BM25, dense RAG,
|
||||
hybrid RRF). mem0 and supermemory run self-hosted as adapters, wired through
|
||||
a proxy so their LLM calls also use Kimi K2.6.
|
||||
- Code suite (`crosstool/`): a fixed coding agent (Claude Opus 4.8, at most 14
|
||||
turns, a grep/read/list floor plus one code-intelligence tool) answers graded
|
||||
questions on ERPNext, a roughly 1M-LOC production repo
|
||||
([frappe/erpnext](https://github.com/frappe/erpnext)), with a temporal
|
||||
sub-suite of 689 weekly AST checkpoints from 2011 to 2026.
|
||||
|
||||
## Datasets
|
||||
|
||||
- LOCOMO (`locomo10.json`, n=300): multi-session conversational QA.
|
||||
- LongMemEval-S (n=50, English subset): long-horizon conversational memory.
|
||||
- ERPNext: a large real-world Python codebase for code intelligence.
|
||||
|
||||
LOCOMO and LongMemEval are the same academic datasets other memory systems
|
||||
report on, so results are cross-referenceable. Datasets are not redistributed;
|
||||
the harness documents the expected local layout.
|
||||
|
||||
## Judge and grading
|
||||
|
||||
Answers are graded by Kimi K2.6 against a gold set of atomic key facts a correct
|
||||
answer must contain:
|
||||
|
||||
```
|
||||
coverage = (covered + 0.5 * partial) / total
|
||||
```
|
||||
|
||||
Every verdict cites a verbatim quote from the answer, so grades are auditable
|
||||
rather than one opaque score.
|
||||
|
||||
Judge validation: the judge was blind-validated against a second, independent
|
||||
judge on a sampled set at 90.6% agreement, Cohen's kappa 0.81 (substantial
|
||||
agreement). Most published memory benchmarks disclose no judge validation at
|
||||
all; we publish ours so the grading itself can be audited.
|
||||
|
||||
## Fairness rules
|
||||
|
||||
- One model for every LLM role: Kimi K2.6 via Moonshot.
|
||||
- One shared local embedder where the system allows it: BGE-m3 (1024-d,
|
||||
multilingual).
|
||||
- Identical token budgets. Every run writes a spend ledger and respects
|
||||
`--max-spend`.
|
||||
- Graphs build AST-only with no LLM (an unset API key produces zero credits);
|
||||
embeddings use a local deterministic model.
|
||||
|
||||
## Results: conversational memory
|
||||
|
||||
### LOCOMO (n=300)
|
||||
|
||||
Sorted by recall@10.
|
||||
|
||||
| System | QA accuracy | recall@10 | Ingest cost |
|
||||
|---|---|---|---|
|
||||
| **graphify** (graph-expand) | **45.3%** | **0.497** | ~$1.40 |
|
||||
| hybrid RRF | 43.3% | 0.493 | $0 (shared index) |
|
||||
| graphify (SurrealDB engine) | 43.3% | 0.485 | $0 (shared index) |
|
||||
| dense RAG | 41.3% | 0.439 | $0 (shared index) |
|
||||
| BM25 | 31.3% | 0.362 | $0 (shared index) |
|
||||
| supermemory | 49.7% | 0.149* | $15.67 |
|
||||
| mem0 | 27.3% | 0.048 | $3.48 |
|
||||
|
||||
Bold marks graphify's primary configuration, not the column maximum. Baselines
|
||||
retrieve from the same harness-built index, so they incur no separate ingest
|
||||
cost.
|
||||
|
||||
`*` Retrieval-recall is embedder-confounded: supermemory's self-host locks in
|
||||
its own 768-d English-only embedder rather than the shared BGE-m3. The
|
||||
QA-accuracy axis (a shared Kimi reader and judge over each system's hits) is the
|
||||
clean comparison.
|
||||
|
||||
Reading: supermemory scores a few points higher on raw QA, but at about 11x the
|
||||
ingest cost ($15.67 vs $1.40) and with about 3x worse retrieval recall. graphify
|
||||
has the best retrieval recall on LOCOMO of any system tested, the best QA of the
|
||||
systems on the shared embedder, and does it for about a tenth of supermemory's
|
||||
cost. It retrieves the right memory about 10x more often than mem0 and answers
|
||||
+18 points more accurately. A seed-only ablation (no graph expansion) still
|
||||
scores 42.7% at $1.40 ingest, so most of the accuracy holds at the cheapest
|
||||
setting.
|
||||
|
||||
### LongMemEval-S (n=50)
|
||||
|
||||
| System | QA accuracy | recall@10 |
|
||||
|---|---|---|
|
||||
| **graphify** (graph-expand) | **76%** | **0.844** |
|
||||
| dense RAG | 76% | 0.848 |
|
||||
| graphify (SurrealDB engine) | 74% | 0.833 |
|
||||
| hybrid RRF | 74% | 0.822 |
|
||||
| BM25 | 70% | 0.710 |
|
||||
| mem0 | 70% | 0.344 |
|
||||
|
||||
graphify ties dense RAG for the best QA accuracy (76%); dense RAG edges it on
|
||||
recall (0.848 vs 0.844). Both retrieve far more than mem0 (recall 0.344).
|
||||
|
||||
## Results: code intelligence
|
||||
|
||||
On ERPNext (a roughly 1M-LOC production repo), giving a fixed coding agent one
|
||||
graphify tool lifts key-fact coverage across the graded question set (n=6) from
|
||||
70.8% (a grep and read baseline) to 82.0%, at about 140K tokens per query.
|
||||
graphify pays for itself in accuracy against searching raw files, and avoids the
|
||||
context-stuffing anti-pattern of packing the whole repo into every turn (which
|
||||
costs roughly 20x the tokens for lower coverage).
|
||||
|
||||
## Results: temporal (15 years of ERPNext)
|
||||
|
||||
689 weekly AST checkpoints, 2011 to 2026, built deterministically with no LLM.
|
||||
|
||||
| Checkpoint | Nodes | Edges | Files |
|
||||
|---|---|---|---|
|
||||
| 2011-06-08 | 3,069 | 2,900 | 1,032 |
|
||||
| 2026-06-24 | 22,620 | 48,710 | 3,758 |
|
||||
|
||||
The graph grows about 7x in nodes and 17x in edges across the span. As the
|
||||
codebase grows, plain lexical retrieval finds less of the answer while graph and
|
||||
semantic retrieval scale with it, and the AST extraction itself stays stable.
|
||||
|
||||
## Cost and token economics
|
||||
|
||||
- Graph construction costs zero LLM credits. graphify extracts with tree-sitter
|
||||
(deterministic, about 40 languages) and a local embedder, so building the
|
||||
index uses no API tokens. Most memory and semantic-retrieval systems pay a
|
||||
per-document LLM ingest cost.
|
||||
- Memory ingest is about 11x cheaper: graphify's LOCOMO ingest runs around
|
||||
$1.40 against supermemory's $15.67.
|
||||
- Every number here is backed by a per-run spend ledger in the harness output.
|
||||
|
||||
## Reproducing
|
||||
|
||||
Set `MOONSHOT_API_KEY`. Datasets are fetched to the local layout documented in
|
||||
the harness. Each run respects `--max-spend` and writes a spend ledger.
|
||||
|
||||
```bash
|
||||
# Memory (LOCOMO). This invokes the SurrealDB-engine row (43.3%); the
|
||||
# graph-expand headline (45.3%) is a separate adapter in the same harness.
|
||||
python memory/runner.py --phase 3 --split locomo --n 300 \
|
||||
--adapters graphify_v1_surreal --cn natural --workers 6 --max-spend 15
|
||||
|
||||
# Code cross-tool (ERPNext)
|
||||
python crosstool/run.py --repo erpnext --max-spend <budget>
|
||||
```
|
||||
+1423
File diff suppressed because it is too large
Load Diff
+25
@@ -0,0 +1,25 @@
|
||||
# graphify MCP server as a shared HTTP service (issue #1143).
|
||||
#
|
||||
# Build: docker build -t graphify .
|
||||
# Run: docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \
|
||||
# /data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
|
||||
#
|
||||
# Builds from source so the image includes the Streamable HTTP transport even
|
||||
# before it lands on PyPI. The graph.json is mounted at runtime (-v), never
|
||||
# baked into the image.
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY . /app
|
||||
|
||||
# The [mcp] extra pulls mcp + starlette + uvicorn, which the HTTP transport needs.
|
||||
RUN pip install --no-cache-dir ".[mcp]"
|
||||
|
||||
# Run as a non-root user — the server is network-exposed.
|
||||
RUN useradd --create-home --uid 10001 graphify
|
||||
USER graphify
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["python", "-m", "graphify.serve"]
|
||||
CMD ["/data/graph.json", "--transport", "http", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Safi Shamsi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,852 @@
|
||||
<p align="center">
|
||||
<a href="https://graphify.com"><img src="https://raw.githubusercontent.com/Graphify-Labs/graphify/v8/docs/logo.png" width="300" height="140" alt="Graphify"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/25296?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-25296" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/25296" alt="Graphify-Labs%2Fgraphify | Trendshift" width="250" height="55"/></a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<details><summary><b>Read this in other languages</b></summary>
|
||||
|
||||
🇺🇸 <a href="README.md">English</a> | 🇨🇳 <a href="docs/translations/README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="docs/translations/README.ja-JP.md">日本語</a> | 🇰🇷 <a href="docs/translations/README.ko-KR.md">한국어</a> | 🇩🇪 <a href="docs/translations/README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="docs/translations/README.fr-FR.md">Français</a> | 🇪🇸 <a href="docs/translations/README.es-ES.md">Español</a> | 🇮🇳 <a href="docs/translations/README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="docs/translations/README.pt-BR.md">Português</a> | 🇷🇺 <a href="docs/translations/README.ru-RU.md">Русский</a> | 🇸🇦 <a href="docs/translations/README.ar-SA.md">العربية</a> | 🇮🇷 <a href="docs/translations/README.fa-IR.md">فارسی</a> | 🇮🇹 <a href="docs/translations/README.it-IT.md">Italiano</a> | 🇵🇱 <a href="docs/translations/README.pl-PL.md">Polski</a> | 🇳🇱 <a href="docs/translations/README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="docs/translations/README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="docs/translations/README.uk-UA.md">Українська</a> | 🇻🇳 <a href="docs/translations/README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="docs/translations/README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="docs/translations/README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="docs/translations/README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="docs/translations/README.ro-RO.md">Română</a> | 🇨🇿 <a href="docs/translations/README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="docs/translations/README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="docs/translations/README.da-DK.md">Dansk</a> | 🇳🇴 <a href="docs/translations/README.no-NO.md">Norsk</a> | 🇭🇺 <a href="docs/translations/README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="docs/translations/README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="docs/translations/README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="docs/translations/README.zh-TW.md">繁體中文</a> | 🇵🇭 <a href="docs/translations/README.fil-PH.md">Filipino</a> | 🇮🇱 <a href="docs/translations/README.he-IL.md">עברית</a>
|
||||
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://img.shields.io/pepy/dt/graphifyy?color=blue&label=downloads" alt="Downloads"/></a>
|
||||
<a href="https://discord.gg/598Ad9zQZ"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
<img src="https://img.shields.io/badge/Y%20Combinator-S26-F0652F?style=flat&logo=ycombinator&logoColor=white" alt="YC S26"/>
|
||||
</p>
|
||||
|
||||
Type `/graphify` in your AI coding assistant and it maps your entire project (code, docs, PDFs, images, videos) into a **knowledge graph** you can **query instead of grepping** through files.
|
||||
|
||||
- **Code maps for free, fully local.** Code is parsed with tree-sitter AST: deterministic, no LLM, nothing leaves your machine. (Docs, PDFs, images and video use your assistant's model, or a configured API key, for a semantic pass.)
|
||||
- **Every edge is explained.** Each connection is tagged `EXTRACTED` (explicit in the source) or `INFERRED` (resolved by graphify), so you can tell what was read directly from what was inferred.
|
||||
- **Not a vector index.** No embeddings, no vector store: a real graph you traverse. Ask a question, trace the path between two things, or explain one concept.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Graphify-Labs/graphify/v8/docs/graph-hero.png" alt="graphify's interactive graph.html showing the FastAPI codebase as a force-directed knowledge graph with a legend of detected communities" width="900">
|
||||
</p>
|
||||
<p align="center">
|
||||
<em>The FastAPI codebase mapped by graphify. Every node is a concept, colors are detected communities, and the whole thing is clickable in graph.html.</em>
|
||||
</p>
|
||||
|
||||
**Get started** (30 seconds):
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy # install the CLI (or: pipx install graphifyy)
|
||||
graphify install # register the skill with your AI assistant
|
||||
```
|
||||
|
||||
Then, in your AI assistant:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
That's it. You get **three files**:
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html open in any browser — click nodes, filter, search
|
||||
├── GRAPH_REPORT.md the highlights: key concepts, surprising connections, suggested questions
|
||||
└── graph.json the full graph — query it anytime without re-reading your files
|
||||
```
|
||||
|
||||
**Works in** Claude Code, Cursor, Codex, Gemini CLI, GitHub Copilot, and 15+ more — [pick your platform](#install).
|
||||
|
||||
---
|
||||
|
||||
## See it in action
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/Graphify-Labs/graphify/v8/docs/demo-path.svg" alt="graphify path query: a terminal asks for the shortest path between FastAPI and ModelField, and the answer lights up hop by hop across the knowledge graph" width="900">
|
||||
</p>
|
||||
|
||||
Once the graph is built you query it instead of reading files. Real output, graphify run on the FastAPI codebase shown above:
|
||||
|
||||
```text
|
||||
$ graphify explain "APIRouter"
|
||||
Node: APIRouter
|
||||
Source: routing.py L2210
|
||||
Community: 2
|
||||
Degree: 47
|
||||
|
||||
Connections (47):
|
||||
--> RequestValidationError [uses] [INFERRED]
|
||||
--> Dependant [uses] [INFERRED]
|
||||
--> .get() [method] [EXTRACTED]
|
||||
<-- __init__.py [imports] [EXTRACTED]
|
||||
...
|
||||
|
||||
$ graphify path "FastAPI" "ModelField"
|
||||
Shortest path (3 hops):
|
||||
FastAPI --uses--> DefaultPlaceholder <--references-- get_request_handler() --references--> ModelField
|
||||
```
|
||||
|
||||
Every edge carries a **confidence tag** (`EXTRACTED` = explicit in the source, `INFERRED` = derived by resolution), so you can tell what was read directly from what was inferred. `graphify query "<question>"` returns a scoped subgraph for a plain-language question, and `graphify path A B` traces how any two things connect.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
What you get out of the box:
|
||||
|
||||
| Capability | What you get |
|
||||
|---|---|
|
||||
| **God nodes** | The most-connected concepts, so you see what everything flows through |
|
||||
| **Communities** | The graph split into subsystems (Leiden), with LLM-free labels |
|
||||
| **Cross-file links** | `calls` / `imports` / `inherits` / `mixes_in` resolved across ~40 languages via tree-sitter AST |
|
||||
| **Query, path, explain** | Ask a question, trace the path between two things, or explain one concept, all against `graph.json` |
|
||||
| **Rationale + doc refs** | `# NOTE:` / `# WHY:` comments and ADR/RFC citations become first-class nodes linked to the code |
|
||||
| **Beyond code** | Docs, PDFs, images, and video/audio all map into the same graph |
|
||||
| **Local-first** | Code is parsed locally with tree-sitter (no LLM, nothing leaves your machine); only the semantic pass over docs/media calls a backend, and only if you configure one |
|
||||
|
||||
---
|
||||
|
||||
## Benchmarks
|
||||
|
||||
| Benchmark | Metric | graphify | Field |
|
||||
|---|---|---|---|
|
||||
| LOCOMO (n=300) | recall@10 | **0.497** | mem0 0.048, supermemory 0.149 |
|
||||
| LOCOMO (n=300) | QA accuracy | 45.3% | supermemory 49.7%, mem0 27.3% |
|
||||
| LongMemEval-S (n=50) | QA accuracy | **76%** | tied with dense RAG |
|
||||
| Graph build | LLM credits | **0** | per-token for most systems |
|
||||
|
||||
Every system ran on the same harness with the same model and budgets, scored by a judge blind-validated against a second judge (90.6% agreement, Cohen's kappa 0.81). Full per-system tables, the code-intelligence result, and reproduction commands: **[BENCHMARKS.md](./BENCHMARKS.md)**.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Minimum | Check | Install |
|
||||
|---|---|---|---|
|
||||
| Python | 3.10+ | `python --version` | [python.org](https://www.python.org/downloads/) |
|
||||
| uv *(recommended)* | any | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| pipx *(alternative)* | any | `pipx --version` | `pip install pipx` |
|
||||
|
||||
**macOS quick install (Homebrew):**
|
||||
```bash
|
||||
brew install python@3.12 uv
|
||||
```
|
||||
|
||||
**Windows quick install:**
|
||||
```powershell
|
||||
winget install astral-sh.uv
|
||||
```
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt install python3.12 python3-pip pipx
|
||||
# or install uv:
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
> **Official package:** The PyPI package is `graphifyy` (double-y). Other `graphify*` packages on PyPI are not affiliated. The CLI command is still `graphify`.
|
||||
|
||||
**Step 1 — install the package:**
|
||||
|
||||
```bash
|
||||
# Recommended (isolated env; if 'graphify' isn't found after, run: uv tool update-shell):
|
||||
uv tool install graphifyy
|
||||
|
||||
# Alternatives:
|
||||
pipx install graphifyy
|
||||
pip install graphifyy # may need PATH setup — see note below
|
||||
```
|
||||
|
||||
**Step 2 — register the skill with your AI assistant:**
|
||||
|
||||
```bash
|
||||
graphify install
|
||||
```
|
||||
|
||||
That's it. Open your AI assistant and type `/graphify .`
|
||||
|
||||
To install the assistant skill into the current repository instead of your user
|
||||
profile, add `--project`:
|
||||
|
||||
```bash
|
||||
graphify install --project
|
||||
graphify install --project --platform codex
|
||||
```
|
||||
|
||||
Project-scoped installs write under the current directory, for example
|
||||
`.claude/skills/graphify/SKILL.md` or `.agents/skills/graphify/SKILL.md` (plus a
|
||||
`references/` sidecar the skill loads on demand), and
|
||||
print a `git add` hint for files that can be committed.
|
||||
Per-platform commands that support project-scoped installs accept the same flag,
|
||||
for example `graphify claude install --project` or `graphify codex install --project`.
|
||||
|
||||
> **PowerShell note:** Use `graphify .` not `/graphify .` — the leading slash is a path separator in PowerShell.
|
||||
|
||||
> **`graphify: command not found`?** `uv tool install` / `pipx install` put the `graphify` command in their tool bin dir (`~/.local/bin`). If your shell can't find it right after install — common on a fresh macOS + zsh setup — that dir isn't on your `PATH` yet: run `uv tool update-shell` (or `pipx ensurepath`), then open a new terminal. With plain `pip`, add `~/.local/bin` (Linux) or `~/Library/Python/3.x/bin` (Mac) to your PATH, or run `python -m graphify`.
|
||||
|
||||
> **Running with `uvx` / `uv tool run` instead of installing?** Name the package, not the command: `uvx --from graphifyy graphify install`. Plain `uvx graphify …` fails (`No solution found … no versions of graphify`) because `uv tool run` reads the first word as a *package*, and the package is `graphifyy` — the `graphify` command lives inside it.
|
||||
|
||||
> **Avoid `pip install` on Mac/Windows** if possible. The skill resolves Python at runtime from `graphify-out/.graphify_python`; if that points to a different environment than where `pip` installed the package, you'll get `ModuleNotFoundError: No module named 'graphify'`. `uv tool install` and `pipx install` isolate the package in their own env and avoid this entirely.
|
||||
|
||||
> **Git hooks and uv tool / pipx:** `graphify hook install` embeds the current interpreter path directly into the hook scripts at install time, so the post-commit hook fires correctly even in GUI git clients and CI runners where `~/.local/bin` is not on PATH. If you reinstall or upgrade graphify, re-run `graphify hook install` to refresh the embedded path.
|
||||
|
||||
<details>
|
||||
<summary><b>Pick your platform</b> (20+ assistants, click to expand)</summary>
|
||||
|
||||
| Platform | Install command |
|
||||
|----------|----------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (auto-detected) or `graphify install --platform windows` |
|
||||
| CodeBuddy | `graphify install --platform codebuddy` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| Kilo Code | `graphify install --platform kilo` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kimi Code | `graphify install --platform kimi` |
|
||||
| Amp | `graphify amp install` |
|
||||
| Agent Skills (cross-framework) | `graphify install --platform agents` (alias `--platform skills`) |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Pi coding agent | `graphify install --platform pi` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Devin CLI | `graphify devin install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
Codex users also need `multi_agent = true` under `[features]` in `~/.codex/config.toml` for parallel extraction. CodeBuddy uses the same Agent tool and PreToolUse hook mechanism as Claude Code. Factory Droid uses the `Task` tool for parallel subagent dispatch. OpenClaw and Aider use sequential extraction (parallel agent support is still early on those platforms). Trae uses the Agent tool for parallel subagent dispatch and does **not** support `PreToolUse` hooks, so AGENTS.md is the always-on mechanism.
|
||||
|
||||
`--platform agents` (alias `--platform skills`) targets the generic cross-framework [Agent-Skills](https://github.com/anthropics/skills) locations: the spec's user-global `~/.agents/skills/` (read by `npx skills` and spec-compliant frameworks) for a global install, and `./.agents/skills/` for a project (`--project`) install. The bare `graphify install` stays single-platform (Claude Code) by design — use the named `agents` platform when you want the skill discoverable by any framework that reads `.agents/skills`.
|
||||
|
||||
> Codex uses `$graphify` instead of `/graphify`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Optional extras</b> (install only what you need)</summary>
|
||||
|
||||
| Extra | What it adds | Install |
|
||||
|---|---|---|
|
||||
| `pdf` | PDF extraction | `uv tool install "graphifyy[pdf]"` |
|
||||
| `office` | `.docx` and `.xlsx` support | `uv tool install "graphifyy[office]"` |
|
||||
| `google` | Google Sheets rendering | `uv tool install "graphifyy[google]"` |
|
||||
| `video` | Video/audio transcription (faster-whisper + yt-dlp) | `uv tool install "graphifyy[video]"` |
|
||||
| `mcp` | MCP stdio server | `uv tool install "graphifyy[mcp]"` |
|
||||
| `neo4j` | Neo4j push support | `uv tool install "graphifyy[neo4j]"` |
|
||||
| `falkordb` | FalkorDB push support | `uv tool install "graphifyy[falkordb]"` |
|
||||
| `svg` | SVG graph export | `uv tool install "graphifyy[svg]"` |
|
||||
| `leiden` | Leiden community detection (Python < 3.13 only) | `uv tool install "graphifyy[leiden]"` |
|
||||
| `ollama` | Ollama local inference | `uv tool install "graphifyy[ollama]"` |
|
||||
| `openai` | OpenAI / OpenAI-compatible APIs | `uv tool install "graphifyy[openai]"` |
|
||||
| `gemini` | Google Gemini API | `uv tool install "graphifyy[gemini]"` |
|
||||
| `anthropic` | Anthropic Claude API (`--backend claude`, uses `ANTHROPIC_API_KEY`) | `uv tool install "graphifyy[anthropic]"` |
|
||||
| `bedrock` | AWS Bedrock (uses IAM, no API key) | `uv tool install "graphifyy[bedrock]"` |
|
||||
| `azure` | Azure OpenAI Service (`--backend azure`, uses `AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`) | `uv tool install "graphifyy[openai]"` |
|
||||
| `sql` | SQL schema extraction | `uv tool install "graphifyy[sql]"` |
|
||||
| `postgres` | Live PostgreSQL introspection (`--postgres DSN`) | `uv tool install "graphifyy[postgres]"` |
|
||||
| `dm` | BYOND DreamMaker `.dm`/`.dme` AST extraction (may need a C compiler + `python3-dev` if no wheel matches your platform) | `uv tool install "graphifyy[dm]"` |
|
||||
| `terraform` | Terraform / HCL `.tf`/`.tfvars`/`.hcl` AST extraction | `uv tool install "graphifyy[terraform]"` |
|
||||
| `pascal` | Pascal / Delphi `.pas`/`.dpr`/`.dpk`/`.inc` AST extraction (more accurate `calls`/`inherits` edges; falls back to a regex extractor when absent) | `uv tool install "graphifyy[pascal]"` |
|
||||
| `chinese` | Chinese query segmentation (jieba) | `uv tool install "graphifyy[chinese]"` |
|
||||
| `all` | Everything above | `uv tool install "graphifyy[all]"` |
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## Make your assistant always use the graph
|
||||
|
||||
Run this once in your project after building a graph:
|
||||
|
||||
| Platform | Command |
|
||||
|----------|---------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| CodeBuddy | `graphify codebuddy install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| Kilo Code | `graphify kilo install` |
|
||||
| GitHub Copilot CLI | `graphify copilot install` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify aider install` |
|
||||
| OpenClaw | `graphify claw install` |
|
||||
| Factory Droid | `graphify droid install` |
|
||||
| Trae | `graphify trae install` |
|
||||
| Trae CN | `graphify trae-cn install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Hermes | `graphify hermes install` |
|
||||
| Kimi Code | `graphify install --platform kimi` |
|
||||
| Amp | `graphify amp install` |
|
||||
| Agent Skills (cross-framework) | `graphify agents install` (alias `graphify skills install`) |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Pi coding agent | `graphify pi install` |
|
||||
| Devin CLI | `graphify devin install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
This writes a small config file that tells your assistant to consult the knowledge graph for codebase questions, preferring scoped queries like `graphify query "<question>"` over reading the full report or grepping raw files.
|
||||
|
||||
- **Hook platforms** (Claude Code, Gemini CLI): a hook fires automatically before search-style tool calls (and, on Claude Code, before reading source files one by one via the Read/Glob tools) and nudges your assistant toward the graph path.
|
||||
- **Instruction-file platforms** (Codex, OpenCode, Cursor, etc.): persistent instruction files (`AGENTS.md`, `.cursor/rules/`, etc.) provide the same query-first guidance.
|
||||
|
||||
`GRAPH_REPORT.md` is still available for broad architecture review.
|
||||
|
||||
**CodeBuddy** does the same two things as Claude Code: writes a `CODEBUDDY.md` section telling CodeBuddy to read `graphify-out/GRAPH_REPORT.md` before answering architecture questions, and installs `PreToolUse` hooks (`.codebuddy/settings.json`) that fire before Bash search commands and file reads, nudging toward `graphify query` instead.
|
||||
|
||||
**Codex** writes to `AGENTS.md` and also installs a `PreToolUse` hook in `.codex/hooks.json` that fires before every Bash tool call, same always-on mechanism as Claude Code.
|
||||
|
||||
**Kilo Code** installs the Graphify skill to `~/.config/kilo/skills/graphify/SKILL.md` and a native `/graphify` command to `~/.config/kilo/command/graphify.md`. `graphify kilo install` also writes `AGENTS.md` plus a native `tool.execute.before` plugin (`.kilo/plugins/graphify.js` + `.kilo/kilo.json` or `.kilo/kilo.jsonc` registration) so Kilo gets the same always-on graph reminder behavior through native `.kilo` config.
|
||||
|
||||
**Cursor** writes `.cursor/rules/graphify.mdc` with `alwaysApply: true`, so Cursor includes it in every conversation automatically, no hook needed.
|
||||
|
||||
To remove graphify from all platforms at once: `graphify uninstall` (add `--purge` to also delete `graphify-out/`). Or use the per-platform command (e.g. `graphify claude uninstall`).
|
||||
|
||||
---
|
||||
|
||||
## What's in the report
|
||||
|
||||
- **God nodes** — the most-connected concepts in your project. Everything flows through these.
|
||||
- **Surprising connections** — links between things that live in different files or modules. Ranked by how unexpected they are.
|
||||
- **The "why"** — inline comments (`# NOTE:`, `# WHY:`, `# HACK:`), docstrings, and design rationale from docs are extracted as separate nodes linked to the code they explain.
|
||||
- **Suggested questions** — 4–5 questions the graph is uniquely positioned to answer.
|
||||
- **Confidence tags** — every inferred relationship is marked `EXTRACTED`, `INFERRED`, or `AMBIGUOUS`. You always know what was found vs guessed.
|
||||
|
||||
---
|
||||
|
||||
## What files it handles
|
||||
|
||||
| Type | Extensions |
|
||||
|------|-----------|
|
||||
| Code (36 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) |
|
||||
| Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) |
|
||||
| Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) |
|
||||
| MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements |
|
||||
| Package manifests | `apm.yml` `pyproject.toml` `go.mod` `pom.xml` — one canonical package node per package (by name) plus `depends_on` edges, so a package referenced from many manifests is a single hub |
|
||||
| Docs | `.md .mdx .qmd .html .txt .rst .yaml .yml` (markdown `[text](./other.md)` links and `[[wikilinks]]` become `references` edges between docs) |
|
||||
| Office | `.docx .xlsx` (requires `uv tool install graphifyy[office]`) |
|
||||
| Google Workspace | `.gdoc .gsheet .gslides` (opt-in; requires `gws` auth and `--google-workspace`; Sheets need `uv tool install graphifyy[google]`) |
|
||||
| PDFs | `.pdf` |
|
||||
| Images | `.png .jpg .webp .gif` |
|
||||
| Video / Audio | `.mp4 .mov .mp3 .wav` and more (requires `uv tool install graphifyy[video]`) |
|
||||
| YouTube / URLs | any video URL (requires `uv tool install graphifyy[video]`) |
|
||||
|
||||
Code is extracted **locally with no API calls** (AST via tree-sitter). Everything else goes through your AI assistant's model API.
|
||||
|
||||
Google Drive for desktop `.gdoc`, `.gsheet`, and `.gslides` files are shortcut
|
||||
pointers, not document content. To include native Google Docs, Sheets, and Slides
|
||||
in a headless extraction, install and authenticate the
|
||||
[`gws` CLI](https://github.com/googleworkspace/cli), then run:
|
||||
|
||||
```bash
|
||||
uv tool install "graphifyy[google]" # needed for Google Sheets table rendering
|
||||
gws auth login -s drive
|
||||
graphify extract ./docs --google-workspace
|
||||
```
|
||||
|
||||
You can also set `GRAPHIFY_GOOGLE_WORKSPACE=1`. Graphify exports shortcuts into
|
||||
`graphify-out/converted/` as Markdown sidecars, then extracts those files.
|
||||
|
||||
---
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
/graphify . # build graph for current folder
|
||||
/graphify ./docs --update # re-extract only changed files
|
||||
/graphify . --cluster-only # rerun clustering without re-extracting
|
||||
/graphify . --cluster-only --resolution 1.5 # more granular communities
|
||||
/graphify . --cluster-only --exclude-hubs 99 # suppress utility super-hubs from god-node rankings
|
||||
/graphify . --no-viz # skip the HTML, just the report + JSON
|
||||
/graphify . --wiki # build a markdown wiki from the graph
|
||||
graphify export callflow-html # Mermaid architecture/call-flow HTML (auto-regenerates on every git commit if hook is installed)
|
||||
|
||||
/graphify query "what connects auth to the database?"
|
||||
/graphify path "UserService" "DatabasePool"
|
||||
/graphify explain "RateLimiter"
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # fetch a paper and add it
|
||||
/graphify add <youtube-url> # transcribe and add a video
|
||||
|
||||
graphify hook install # auto-rebuild on git commit
|
||||
graphify merge-graphs a.json b.json # combine two graphs
|
||||
|
||||
graphify prs # PR dashboard: CI state, review status, worktree mapping
|
||||
graphify prs 42 # deep dive on PR #42 with graph impact
|
||||
graphify prs --triage # AI ranks your review queue (uses whatever backend is configured)
|
||||
graphify prs --conflicts # PRs sharing graph communities — merge-order risk
|
||||
```
|
||||
|
||||
See the [full command reference](#full-command-reference) below.
|
||||
|
||||
---
|
||||
|
||||
## Ignoring files
|
||||
|
||||
Create a `.graphifyignore` in your project root — same syntax as `.gitignore`, including `!` negation.
|
||||
|
||||
**`.gitignore` is respected automatically.** graphify reads the `.gitignore` in each directory. If a `.graphifyignore` is also present, the two are **merged** — `.graphifyignore` patterns are evaluated last, so they win on conflicts (including `!` negations). Adding a `.graphifyignore` only ever excludes more; it never re-includes a file your `.gitignore` already excluded. Subdirectory scoping works the same way as git — an ignore file only affects its own subtree.
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
|
||||
# only index src/, ignore everything else
|
||||
*
|
||||
!src/
|
||||
!src/**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Team setup
|
||||
|
||||
`graphify-out/` is meant to be committed to git so everyone on the team starts with a map.
|
||||
|
||||
**Recommended `.gitignore` additions:**
|
||||
```
|
||||
graphify-out/cost.json # local only
|
||||
# graphify-out/cache/ # optional: commit for speed, skip to keep repo small
|
||||
```
|
||||
|
||||
> `manifest.json` is now portable — keys are stored as relative paths and re-anchored on load, so committing it is safe and avoids a full rebuild on first checkout.
|
||||
|
||||
**Workflow:**
|
||||
1. One person runs `/graphify .` and commits `graphify-out/`.
|
||||
2. Everyone pulls — their assistant reads the graph immediately.
|
||||
3. Run `graphify hook install` to auto-rebuild after each commit (AST only, no API cost). This also sets up a git merge driver so `graph.json` is never left with conflict markers — two devs committing in parallel get their graphs union-merged automatically.
|
||||
4. When docs or papers change, run `/graphify --update` to refresh those nodes.
|
||||
|
||||
---
|
||||
|
||||
## Using the graph directly
|
||||
|
||||
```bash
|
||||
# query the graph from the terminal
|
||||
graphify query "show the auth flow"
|
||||
graphify query "what connects DigestAuth to Response?" --graph graphify-out/graph.json
|
||||
|
||||
# expose the graph as an MCP server (for repeated tool-call access)
|
||||
python -m graphify.serve graphify-out/graph.json
|
||||
python -m graphify.serve --graph graphify-out/graph.json # --graph flag also accepted
|
||||
|
||||
# register with Kimi Code:
|
||||
kimi mcp add --transport stdio graphify -- python -m graphify.serve graphify-out/graph.json
|
||||
|
||||
# or serve over HTTP so a whole team points at one URL (no local graphify needed):
|
||||
python -m graphify.serve graphify-out/graph.json --transport http --port 8080
|
||||
python -m graphify.serve graphify-out/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
|
||||
```
|
||||
|
||||
The MCP server gives your assistant structured access: `query_graph`, `get_node`, `get_neighbors`, `shortest_path`, `list_prs`, `get_pr_impact`, `triage_prs`.
|
||||
|
||||
### Shared HTTP server
|
||||
|
||||
`--transport stdio` (the default) spawns one local server per developer. `--transport http` serves the same tools over the MCP Streamable HTTP transport, so a single shared process can serve the graph for the whole team — clients point their IDE MCP config at `http://<host>:8080/mcp` instead of running graphify locally.
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `--transport {stdio,http}` | `stdio` | Transport to serve on |
|
||||
| `--host` | `127.0.0.1` | HTTP bind host (use `0.0.0.0` to expose beyond localhost) |
|
||||
| `--port` | `8080` | HTTP bind port |
|
||||
| `--api-key` | env `GRAPHIFY_API_KEY` | Require `Authorization: Bearer <key>` (or `X-API-Key`) |
|
||||
| `--path` | `/mcp` | HTTP mount path |
|
||||
| `--json-response` | off | Return plain JSON instead of SSE streams |
|
||||
| `--stateless` | off | No per-session state (for load-balanced / CI deployments) |
|
||||
| `--session-timeout` | `3600` | Reap idle stateful sessions after N seconds (`0` disables) |
|
||||
|
||||
The default `127.0.0.1` bind is loopback-only. Set `--host 0.0.0.0` **and** `--api-key` together when exposing on a shared host. Run it in a container:
|
||||
|
||||
```bash
|
||||
docker build -t graphify .
|
||||
docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \
|
||||
/data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
|
||||
```
|
||||
|
||||
> **WSL / Linux note:** Ubuntu ships `python3`, not `python`. Use a venv to avoid conflicts:
|
||||
> ```bash
|
||||
> python3 -m venv .venv && .venv/bin/pip install "graphifyy[mcp]"
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Environment variables
|
||||
|
||||
These are only needed for **headless / CI extraction** (`graphify extract`). When running via the `/graphify` skill inside your IDE, the model API is provided by your IDE session — no extra keys needed.
|
||||
|
||||
| Variable | Used for | When required |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | Claude (Anthropic) backend | `--backend claude` |
|
||||
| `ANTHROPIC_BASE_URL` | Anthropic-compatible endpoint URL (LiteLLM proxy, gateways, ...) | `--backend claude` (default: `https://api.anthropic.com`) |
|
||||
| `ANTHROPIC_MODEL` | Model name for the Claude backend — for custom endpoints, use the model name/alias your server exposes | `--backend claude` (default: `claude-sonnet-4-6`) |
|
||||
| `GEMINI_API_KEY` or `GOOGLE_API_KEY` | Google Gemini backend | `--backend gemini` |
|
||||
| `OPENAI_API_KEY` | OpenAI or OpenAI-compatible APIs | `--backend openai` (local servers accept any non-empty value) |
|
||||
| `OPENAI_BASE_URL` | OpenAI-compatible server URL (llama.cpp, vLLM, LM Studio, ...) | `--backend openai` (default: `https://api.openai.com/v1`) |
|
||||
| `OPENAI_MODEL` | Model name for the OpenAI backend — for self-hosted servers, use the model name/alias your server exposes (check its `/v1/models` endpoint), e.g. `LFM2.5-8B-A1B-UD-Q4_K_XL` for llama.cpp | `--backend openai` (default: `gpt-4.1-mini`) |
|
||||
| `DEEPSEEK_API_KEY` | DeepSeek backend | `--backend deepseek` |
|
||||
| `MOONSHOT_API_KEY` | Kimi Code backend | `--backend kimi` |
|
||||
| `OLLAMA_BASE_URL` | Ollama local inference URL | `--backend ollama` (default: `http://localhost:11434`) |
|
||||
| `OLLAMA_MODEL` | Ollama model name | `--backend ollama` (default: auto-detect) |
|
||||
| `GRAPHIFY_OLLAMA_NUM_CTX` | Override Ollama KV-cache window size | optional — auto-sized by default |
|
||||
| `GRAPHIFY_OLLAMA_KEEP_ALIVE` | Minutes to keep Ollama model loaded | optional — set `0` to unload after each chunk |
|
||||
| `AZURE_OPENAI_API_KEY` | Azure OpenAI Service backend | `--backend azure` |
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure resource endpoint URL | `--backend azure` (required alongside API key) |
|
||||
| `AZURE_OPENAI_API_VERSION` | Azure API version override | optional — default `2024-12-01-preview` |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` or `GRAPHIFY_AZURE_MODEL` | Azure deployment name | optional — default `gpt-4o` |
|
||||
| `AWS_*` / `~/.aws/credentials` | AWS Bedrock — standard credential chain | `--backend bedrock` (no API key, uses IAM) |
|
||||
| `GRAPHIFY_MAX_WORKERS` | AST parallelism thread count | optional — also `--max-workers` flag |
|
||||
| `GRAPHIFY_MAX_OUTPUT_TOKENS` | Raise output cap for dense corpora | optional — e.g. `32768` for large files |
|
||||
| `GRAPHIFY_API_TIMEOUT` | Per-call timeout in seconds for HTTP, claude-cli, and Anthropic SDK backends (default: 600) | optional — also `--api-timeout` flag |
|
||||
| `GRAPHIFY_MAX_RETRIES` | How many times to retry a rate-limited (429) request before giving up (default: 6; honors `Retry-After`) | optional — raise for strict per-org limits (e.g. kimi); `0` disables |
|
||||
| `GRAPHIFY_FORCE` | Force graph rebuild even with fewer nodes | optional — also `--force` flag |
|
||||
| `GRAPHIFY_GOOGLE_WORKSPACE` | Auto-enable Google Workspace export | optional — set to `1` |
|
||||
| `GRAPHIFY_TRIAGE_BACKEND` | Backend for `graphify prs --triage` | optional — auto-detected from available keys |
|
||||
| `GRAPHIFY_TRIAGE_MODEL` | Model override for triage | optional — e.g. `claude-opus-4-7` |
|
||||
| `GRAPHIFY_QUERY_LOG_ENABLE` | Set to `1` to turn on the local query log at `~/.cache/graphify-queries.log` (records each query/path/explain question + corpus path). Off by default — nothing is written unless you opt in (#1797) | optional |
|
||||
| `GRAPHIFY_QUERY_LOG` | Enable the query log and write it to this path instead of the default | optional — off unless this or `_ENABLE` is set |
|
||||
| `GRAPHIFY_QUERY_LOG_DISABLE` | Set to `1` to force the query log off (wins over the enable vars) | optional |
|
||||
| `GRAPHIFY_QUERY_LOG_RESPONSES` | When the log is enabled, also record full subgraph responses (off by default) | optional |
|
||||
| `GRAPHIFY_MAX_GRAPH_BYTES` | Override the 512 MiB graph.json size cap — e.g. `700MB`, `2GB`, or plain bytes | optional — useful for very large corpora |
|
||||
| `GRAPHIFY_LLM_TEMPERATURE` | Override LLM temperature for semantic extraction — e.g. `0.7`, or `none` to omit | optional — auto-omitted for o1/o3/o4/gpt-5 reasoning models |
|
||||
|
||||
---
|
||||
|
||||
## Privacy
|
||||
|
||||
- **Code files** — processed locally via tree-sitter. Nothing leaves your machine. A code-only corpus requires no API key — `graphify extract` runs fully offline.
|
||||
- **Video / audio** — transcribed locally with faster-whisper. Nothing leaves your machine.
|
||||
- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `GEMINI_API_KEY` / `GOOGLE_API_KEY` (Gemini), `MOONSHOT_API_KEY` (Kimi), `ANTHROPIC_API_KEY` (Claude), `OPENAI_API_KEY` (OpenAI), `DEEPSEEK_API_KEY` (DeepSeek), a running Ollama instance (`OLLAMA_BASE_URL`), AWS credentials via the standard provider chain (Bedrock - no API key needed, uses IAM), or the `claude` CLI binary (Claude Code - no API key needed, uses your Claude subscription). The `--dedup-llm` flag uses the same key.
|
||||
- **Data residency** — `graphify extract` auto-detects which provider to use based on which API key is set (priority: Gemini → Kimi → Claude → OpenAI → DeepSeek → Azure → Bedrock → Ollama). For code with data-residency requirements, use `--backend ollama` (fully local) or pass an explicit `--backend` flag. Kimi (`MOONSHOT_API_KEY`) routes to Moonshot AI servers in China.
|
||||
- **No telemetry**, no usage tracking, no analytics.
|
||||
- **Query logging** — every `graphify query`, `graphify path`, `graphify explain`, and MCP `query_graph` call is logged to `~/.cache/graphify-queries.log` in JSON Lines format (timestamp, question, corpus, nodes returned, duration). Full subgraph responses are **not** stored by default. Set `GRAPHIFY_QUERY_LOG_DISABLE=1` to opt out, or `GRAPHIFY_QUERY_LOG=/dev/null` to silence without disabling the code path.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`graphify: command not found` after installing**
|
||||
The CLI is installed but its bin directory isn't on your shell's `PATH`. Pick the fix for how you installed:
|
||||
- **uv** (`uv tool install graphifyy`): the command lands in uv's tool bin dir (`~/.local/bin`), which a fresh macOS/zsh setup often doesn't have on `PATH`. Run `uv tool update-shell`, then open a new terminal. (Find the dir with `uv tool dir --bin`.)
|
||||
- **pipx** (`pipx install graphifyy`): run `pipx ensurepath`, then open a new terminal.
|
||||
- **pip** (`pip install graphifyy`): pip installs scripts to a user bin dir that may not be on `PATH` — add `~/Library/Python/3.x/bin` (macOS) or `~/.local/bin` (Linux) to your `PATH` in `~/.zshrc`/`~/.bashrc`, or just run `python -m graphify`.
|
||||
|
||||
**`uvx graphify …` or `uv tool run graphify …` fails to resolve `graphify`**
|
||||
The PyPI package is `graphifyy`; `graphify` is only the command it provides. `uv tool run` treats the first word as a *package name*, so it looks for a package called `graphify` and reports `No solution found … no versions of graphify`. Name the package explicitly: `uvx --from graphifyy graphify install` (same as `uv tool run --from graphifyy graphify install`). Or `uv tool install graphifyy` once and then call `graphify` directly.
|
||||
|
||||
**`python -m graphify` works but `graphify` command doesn't**
|
||||
Your shell's `PATH` doesn't include the bin directory the command was installed to. Prefer `uv tool install` / `pipx install` over plain `pip`, then run `uv tool update-shell` / `pipx ensurepath` and open a new terminal (see the install notes above).
|
||||
|
||||
**`/graphify .` causes "path not recognized" in PowerShell**
|
||||
PowerShell treats a leading `/` as a path separator. Use `graphify .` (no slash) on Windows.
|
||||
|
||||
**Graph has fewer nodes after `--update` or rebuild**
|
||||
If a refactor deleted files, the old nodes linger. Pass `--force` (or set `GRAPHIFY_FORCE=1`) to overwrite even when the rebuild has fewer nodes.
|
||||
|
||||
**Graph has duplicate nodes for the same entity (ghost duplicates)**
|
||||
Ghost duplicates (same symbol appearing twice — once from AST extraction with a source location, once from semantic extraction without) are now automatically merged at build time. If you see this in a graph built before v0.8.33, run a full re-extract to clean up:
|
||||
```bash
|
||||
graphify extract . --force
|
||||
```
|
||||
|
||||
**Ollama runs out of VRAM / context window exceeded**
|
||||
The KV-cache window is auto-sized but may be too large for your GPU. Reduce it:
|
||||
```bash
|
||||
GRAPHIFY_OLLAMA_NUM_CTX=8192 graphify extract ./docs --backend ollama --token-budget 4000
|
||||
```
|
||||
|
||||
**`LLM returned invalid JSON` / `Unterminated string` warnings**
|
||||
The model's JSON response hit its output-token limit and was cut off mid-string. graphify auto-recovers (it splits the chunk and re-extracts the halves, and an oversized single document is first sliced at heading/paragraph boundaries so the whole file is still covered), so these warnings are noisy but not data loss. To reduce the churn, raise the output cap or shrink each chunk's output:
|
||||
```bash
|
||||
GRAPHIFY_MAX_OUTPUT_TOKENS=16384 graphify extract . --mode deep # lift the cap
|
||||
graphify extract . --mode deep --token-budget 4000 # smaller input chunks -> smaller output
|
||||
```
|
||||
With a cloud gateway like OpenRouter, prefer `--backend openai` (set `OPENAI_BASE_URL`) over the Ollama shim — it's a cleaner OpenAI-compatible path. If the model has its own max-output ceiling, lowering `--token-budget` is the reliable lever.
|
||||
|
||||
**Graph HTML is too large to open in a browser (>5000 nodes)**
|
||||
Skip HTML generation and use the JSON directly:
|
||||
```bash
|
||||
graphify cluster-only ./my-project --no-viz
|
||||
graphify query "..."
|
||||
```
|
||||
|
||||
**`graph.json` has conflict markers after two devs commit at once**
|
||||
Run `graphify hook install` — it sets up a git merge driver that union-merges `graph.json` automatically so conflicts never happen.
|
||||
|
||||
**Extraction returns empty nodes/edges for docs or PDFs**
|
||||
Docs, PDFs, and images require an LLM call — code-only corpora need no key. Check that your API key is set and the backend is correct:
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-... graphify extract ./docs --backend claude
|
||||
```
|
||||
|
||||
**Skill version mismatch warning in your IDE**
|
||||
Your installed graphify version is different from the skill file. Update:
|
||||
```bash
|
||||
uv tool upgrade graphifyy
|
||||
graphify install # overwrites the skill file
|
||||
```
|
||||
|
||||
**Claude Code prompt cache invalidated after every `graphify extract`**
|
||||
Graphify writes output files (`graph.json`, `graphify-out/`) into the workspace. If those paths aren't ignored, every write invalidates Claude Code's prompt cache, forcing a full re-upload at cache-write rates on the next turn. Add them to `.claudeignore`:
|
||||
```text
|
||||
# .claudeignore
|
||||
graph.json
|
||||
graphify-out/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Full command reference
|
||||
|
||||
```
|
||||
/graphify # run on current directory
|
||||
/graphify ./raw # run on a specific folder
|
||||
/graphify ./raw --mode deep # more aggressive relationship extraction
|
||||
/graphify ./raw --update # re-extract only changed files
|
||||
/graphify ./raw --directed # preserve edge direction
|
||||
/graphify ./raw --cluster-only # rerun clustering on existing graph
|
||||
/graphify ./raw --no-viz # skip HTML visualization
|
||||
/graphify ./raw --obsidian # generate Obsidian vault
|
||||
/graphify ./raw --obsidian --obsidian-dir ~/vault # write into an existing vault (never overwrites your own notes or .obsidian config)
|
||||
/graphify ./raw --wiki # build agent-crawlable markdown wiki
|
||||
/graphify ./raw --svg # export graph.svg
|
||||
/graphify ./raw --graphml # export for Gephi / yEd
|
||||
/graphify ./raw --neo4j # generate cypher.txt for Neo4j
|
||||
/graphify ./raw --neo4j-push bolt://localhost:7687
|
||||
/graphify ./raw --falkordb # generate cypher.txt for FalkorDB
|
||||
/graphify ./raw --falkordb-push falkordb://localhost:6379
|
||||
/graphify ./raw --watch # auto-sync as files change
|
||||
/graphify ./raw --mcp # start MCP stdio server
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762
|
||||
/graphify add <video-url>
|
||||
/graphify add https://... --author "Name" --contributor "Name"
|
||||
|
||||
/graphify query "what connects attention to the optimizer?"
|
||||
/graphify query "..." --dfs --budget 1500
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify save-result --question "Q" --answer "A" --nodes Foo Bar --outcome useful # record how a Q&A turned out (work memory; outcome ∈ useful|dead_end|corrected)
|
||||
graphify reflect # aggregate graphify-out/memory/ outcomes into reflections/LESSONS.md
|
||||
graphify reflect --if-stale # no-op when LESSONS.md is already newer than every input (cheap to run each session)
|
||||
graphify reflect --out docs/LESSONS.md # write the lessons doc somewhere else
|
||||
graphify reflect --graph graphify-out/graph.json # group lessons by community + write the work-memory overlay (.graphify_learning.json)
|
||||
# the overlay tags nodes preferred/tentative/contested (recency-weighted, with provenance);
|
||||
# graphify explain / query then show a "Lesson:" hint, flagged "code changed — re-verify" when the source moved on
|
||||
|
||||
graphify uninstall # remove from all platforms in one shot
|
||||
graphify uninstall --purge # also delete graphify-out/
|
||||
graphify uninstall --project --platform codex # remove project-scoped install files only
|
||||
|
||||
graphify hook install # post-commit + post-checkout hooks
|
||||
graphify hook uninstall
|
||||
graphify hook status
|
||||
|
||||
# always-on assistant instructions - platform-specific
|
||||
graphify claude install # CLAUDE.md + PreToolUse hook (Claude Code)
|
||||
graphify claude uninstall
|
||||
graphify codebuddy install # CODEBUDDY.md + PreToolUse hook (CodeBuddy)
|
||||
graphify codebuddy uninstall
|
||||
graphify codex install # AGENTS.md + PreToolUse hook in .codex/hooks.json (Codex)
|
||||
graphify opencode install # AGENTS.md + tool.execute.before plugin (OpenCode)
|
||||
graphify kilo install # native Kilo skill + /graphify command + AGENTS.md + .kilo plugin
|
||||
graphify kilo uninstall
|
||||
graphify cursor install # .cursor/rules/graphify.mdc (Cursor)
|
||||
graphify cursor uninstall
|
||||
graphify gemini install # GEMINI.md + BeforeTool hook (Gemini CLI)
|
||||
graphify gemini uninstall
|
||||
graphify copilot install # skill file (GitHub Copilot CLI)
|
||||
graphify copilot uninstall
|
||||
graphify aider install # AGENTS.md (Aider)
|
||||
graphify aider uninstall
|
||||
graphify claw install # AGENTS.md (OpenClaw)
|
||||
graphify claw uninstall
|
||||
graphify droid install # AGENTS.md (Factory Droid)
|
||||
graphify droid uninstall
|
||||
graphify trae install # AGENTS.md (Trae)
|
||||
graphify trae uninstall
|
||||
graphify trae-cn install # AGENTS.md (Trae CN)
|
||||
graphify trae-cn uninstall
|
||||
graphify hermes install # AGENTS.md + ~/.hermes/skills/ (Hermes)
|
||||
graphify hermes uninstall
|
||||
graphify amp install # skill file (Amp)
|
||||
graphify amp uninstall
|
||||
graphify agents install # ~/.agents/skills/ + AGENTS.md (cross-framework; alias: graphify skills)
|
||||
graphify agents uninstall
|
||||
graphify kiro install # .kiro/skills/ + .kiro/steering/graphify.md (Kiro IDE/CLI)
|
||||
graphify kiro uninstall
|
||||
graphify pi install # skill file (Pi coding agent)
|
||||
graphify pi uninstall
|
||||
graphify devin install # skill file + .windsurf/rules/graphify.md (Devin CLI)
|
||||
graphify devin uninstall
|
||||
graphify antigravity install # .agents/rules + .agents/workflows (Google Antigravity)
|
||||
graphify antigravity uninstall
|
||||
|
||||
graphify extract ./docs # headless LLM extraction for CI (no IDE needed)
|
||||
graphify extract ./docs --backend gemini # explicit backend: gemini, kimi, claude, openai, deepseek, ollama, bedrock, or claude-cli
|
||||
graphify extract ./docs --backend gemini --model gemini-3.1-pro-preview
|
||||
graphify extract ./docs --backend ollama # local Ollama (set OLLAMA_BASE_URL / OLLAMA_MODEL) - no API key needed for loopback
|
||||
OPENAI_BASE_URL=http://localhost:8080/v1 OPENAI_MODEL=my-model graphify extract ./docs --backend openai # any OpenAI-compatible server (llama.cpp, vLLM, LM Studio)
|
||||
ANTHROPIC_BASE_URL=http://localhost:4000 ANTHROPIC_MODEL=my-model graphify extract ./docs --backend claude # any Anthropic-compatible endpoint (LiteLLM proxy, gateways)
|
||||
GRAPHIFY_OLLAMA_NUM_CTX=32768 graphify extract ./docs --backend ollama # override KV-cache window (auto-sized by default)
|
||||
GRAPHIFY_OLLAMA_KEEP_ALIVE=0 graphify extract ./docs --backend ollama # unload model after each chunk (saves VRAM on small GPUs)
|
||||
graphify extract ./docs --backend bedrock # AWS Bedrock via IAM - no API key, uses AWS credential chain
|
||||
graphify extract ./docs --backend claude-cli # route through Claude Code CLI - no API key, uses your Claude subscription
|
||||
graphify extract ./docs --backend azure # Azure OpenAI (set AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT)
|
||||
graphify extract ./docs --max-workers 16 # AST parallelism (also GRAPHIFY_MAX_WORKERS)
|
||||
graphify extract --postgres "postgresql://user:pass@host/db" # introspect live PostgreSQL schema directly
|
||||
graphify extract ./my-workspace --cargo # introspect Rust Cargo workspace dependencies directly
|
||||
graphify extract ./docs --token-budget 30000 # smaller semantic chunks for local/small models
|
||||
graphify extract ./docs --max-concurrency 2 # fewer parallel LLM calls (useful for local inference)
|
||||
graphify extract ./docs --api-timeout 900 # longer HTTP timeout for slow local models (default 600s)
|
||||
graphify extract ./docs --google-workspace # export .gdoc/.gsheet/.gslides via gws before extraction
|
||||
graphify extract ./docs --mode deep # richer semantic extraction via extended system prompt
|
||||
graphify extract ./docs --no-cluster # raw extraction only, skip clustering
|
||||
graphify extract ./docs --timing # print per-stage wall-clock timings to stderr (also works on cluster-only)
|
||||
graphify extract ./docs --force # overwrite graph.json even if new graph has fewer nodes (use after refactors or to clear ghost duplicates)
|
||||
graphify extract ./docs --dedup-llm # LLM tiebreaker for ambiguous entity pairs (uses same API key)
|
||||
graphify extract ./docs --global --as myrepo # extract and register into the cross-project global graph
|
||||
GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # raise output cap for dense corpora
|
||||
|
||||
graphify export callflow-html # graphify-out/<project>-callflow.html
|
||||
graphify export callflow-html --max-sections 8 # cap generated architecture sections
|
||||
graphify export callflow-html --output docs/arch.html
|
||||
graphify export callflow-html ./some-repo/graphify-out
|
||||
|
||||
graphify global add graphify-out/graph.json --as myrepo # register a project graph into ~/.graphify/global-graph.json
|
||||
graphify global remove myrepo # remove a project from the global graph
|
||||
graphify global list # show all registered repos + node/edge counts
|
||||
graphify global path # print path to the global graph file
|
||||
|
||||
graphify prs # PR dashboard: CI, review, worktree, graph impact
|
||||
graphify prs 42 # deep dive on PR #42
|
||||
graphify prs --triage # AI triage ranking (auto-detects backend from env)
|
||||
graphify prs --worktrees # worktree → branch → PR mapping
|
||||
graphify prs --conflicts # PRs sharing graph communities (merge-order risk)
|
||||
graphify prs --base main # filter to PRs targeting a specific base branch
|
||||
graphify prs --repo owner/repo # run against a different GitHub repo
|
||||
GRAPHIFY_TRIAGE_BACKEND=kimi graphify prs --triage # use a specific backend for triage
|
||||
|
||||
graphify clone https://github.com/karpathy/nanoGPT
|
||||
graphify merge-graphs a.json b.json --out merged.json
|
||||
graphify --version # print installed version
|
||||
graphify watch ./src
|
||||
graphify check-update ./src
|
||||
graphify update ./src
|
||||
graphify update ./src --no-cluster # skip reclustering, write raw AST graph only
|
||||
graphify update ./src --force # overwrite even if new graph has fewer nodes
|
||||
graphify cluster-only ./my-project
|
||||
graphify cluster-only ./my-project --graph path/to/graph.json # custom graph location
|
||||
graphify cluster-only ./my-project --max-concurrency 16 --batch-size 200 # parallel community labeling (large graphs)
|
||||
graphify cluster-only ./my-project --resolution 1.5 # more, smaller communities
|
||||
graphify cluster-only ./my-project --exclude-hubs 99 # exclude p99 degree nodes from partitioning
|
||||
graphify cluster-only ./my-project --no-label # keep "Community N" placeholders
|
||||
graphify cluster-only ./my-project --backend=gemini # backend for community naming
|
||||
graphify cluster-only ./my-project --backend=gemini --model gemini-2.5-pro # specific model
|
||||
graphify label ./my-project # (re)name communities with the configured backend
|
||||
graphify label ./my-project --backend=openai --model gpt-4o # force a specific backend and model
|
||||
```
|
||||
|
||||
> **Community names:** inside an agent (Claude Code, Gemini CLI) the agent names communities itself. When you run the bare CLI, `cluster-only` auto-names them with the configured backend (built-in or custom OpenAI-compatible provider) — pass `--no-label` to keep `Community N`, or run `graphify label` to (re)generate names on demand.
|
||||
|
||||
---
|
||||
|
||||
## Learn more
|
||||
|
||||
- [How it works](docs/how-it-works.md) — the extraction pipeline, community detection, confidence scoring, benchmarks
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — module breakdown, how to add a language
|
||||
- [Optional integrations](docs/docker-mcp-sqlite.md) — Docker MCP Toolkit + SQLite
|
||||
- [The Memory Layer](https://safishamsi.gumroad.com/l/qetvlo) — the book on the ideas behind graphify, the architecture end to end
|
||||
|
||||
---
|
||||
|
||||
## Built on graphify: Penpax
|
||||
|
||||
[**Penpax**](https://graphify.com) is the always-on layer built on top of graphify — it applies the same graph approach to your entire working life: meetings, browser history, emails, files, and code, updating continuously in the background.
|
||||
|
||||
Built for people whose work lives across hundreds of conversations and documents they can never fully reconstruct. No cloud, fully on-device.
|
||||
|
||||
**Free trial launching soon.** [Join the waitlist →](https://www.graphify.com)
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>Contributing</summary>
|
||||
|
||||
### Development setup
|
||||
|
||||
The project uses [uv](https://docs.astral.sh/uv/) for dev workflow. Install it once, then:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/safishamsi/graphify.git
|
||||
cd graphify
|
||||
git checkout v8 # active development branch
|
||||
|
||||
# Create the project venv and install graphify + all extras + the dev group
|
||||
# (pytest). uv installs the dev dependency group by default; pass --no-dev to
|
||||
# skip it.
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
Verify the editable install:
|
||||
```bash
|
||||
uv run graphify --version
|
||||
uv run python -c "import graphify; print(graphify.__file__)"
|
||||
```
|
||||
|
||||
### Running tests
|
||||
|
||||
```bash
|
||||
uv run pytest tests/ -q # run the full suite
|
||||
uv run pytest tests/test_extract.py -q # one module
|
||||
uv run pytest tests/ -q -k "python" # filter by name
|
||||
```
|
||||
|
||||
> macOS note: the test suite includes both `sample.f90` and `sample.F90` fixtures. These collide on case-insensitive HFS+ / APFS file systems. Run on Linux or in a Docker container if you need to test both Fortran variants simultaneously.
|
||||
|
||||
### Git workflow
|
||||
|
||||
- Active development happens on the `v8` branch.
|
||||
- Commit style: `fix: <description>` / `feat: <description>` / `docs: <description>`
|
||||
- Before opening a PR, run `uv run pytest tests/ -q` and confirm it passes.
|
||||
- Add a fixture file to `tests/fixtures/` and tests to `tests/test_languages.py` for any new language extractor.
|
||||
|
||||
### What to contribute
|
||||
|
||||
**Worked examples** are the most useful contribution. Run `/graphify` on a real corpus, save the output to `worked/{slug}/`, write an honest `review.md` covering what the graph got right and wrong, and open a PR.
|
||||
|
||||
**Extraction bugs** — open an issue with the input file, the cache entry (`graphify-out/cache/`), and what was missed or wrong.
|
||||
|
||||
See [ARCHITECTURE.md](ARCHITECTURE.md) for module responsibilities and how to add a language.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#safishamsi/graphify&Date">
|
||||
<img src="https://api.star-history.com/svg?repos=safishamsi/graphify&type=Date" alt="Star History Chart" width="370"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Community and links
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/598Ad9zQZ"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://x.com/graphifyy"><img src="https://img.shields.io/badge/X-graphifyy-000000?logo=x&logoColor=white" alt="X"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://safishamsi.gumroad.com/l/qetvlo"><img src="https://img.shields.io/badge/Book-The%20Memory%20Layer-2ea44f?style=flat&logo=gitbook&logoColor=white" alt="The Memory Layer"/></a>
|
||||
</p>
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`Graphify-Labs/graphify`
|
||||
- 原始仓库:https://github.com/Graphify-Labs/graphify
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 0.3.x | Yes |
|
||||
| < 0.3 | No |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Do not open a public GitHub issue for security vulnerabilities.**
|
||||
|
||||
Report security issues via GitHub's private vulnerability reporting, or email the maintainer directly. Please include:
|
||||
|
||||
- Description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested fix (if any)
|
||||
|
||||
We will acknowledge receipt within 48 hours and aim to release a fix within 7 days for critical issues.
|
||||
|
||||
## Security Model
|
||||
|
||||
graphify is a **local development tool**. It runs as a Claude Code skill and optionally as a local MCP stdio server. It makes no network calls during graph analysis - only during `ingest` (explicit URL fetch by the user).
|
||||
|
||||
### Threat Surface
|
||||
|
||||
| Vector | Mitigation |
|
||||
|--------|-----------|
|
||||
| SSRF via URL fetch | `security.validate_url()` allows only `http` and `https` schemes, blocks private/loopback/link-local IPs, and blocks cloud metadata endpoints. Redirect targets are re-validated. All fetch paths including tweet oEmbed go through `safe_fetch()`. |
|
||||
| Oversized downloads | `safe_fetch()` streams responses and aborts at 50 MB. `safe_fetch_text()` aborts at 10 MB. |
|
||||
| Non-2xx HTTP responses | `safe_fetch()` raises `HTTPError` on non-2xx status codes - error pages are not silently treated as content. |
|
||||
| Path traversal in MCP server | `security.validate_graph_path()` resolves paths and requires them to be inside `graphify-out/`. Also requires the `graphify-out/` directory to exist. |
|
||||
| XSS in graph HTML output | `security.sanitize_label()` strips control characters, caps at 256 chars, and HTML-escapes all node labels and edge titles before pyvis embeds them. |
|
||||
| Prompt injection via node labels | `sanitize_label()` also applied to MCP text output - node labels from user-controlled source files cannot break the text format returned to agents. |
|
||||
| Prompt injection via source file content | During the semantic pass, source files are attacker-controlled text mixed into the LLM context. `_read_files()` in `llm.py` wraps every file in a hash-stamped `<untrusted_source path=... sha256=...>` delimiter block, the extraction system prompt instructs the model to treat that block as inert data and never as instructions, and `_neutralise_injection_sentinels()` defangs known chat-template/jailbreak tokens (`<\|im_start\|>`, `[INST]`, `<<SYS>>`, forged `</untrusted_source>`, etc.) before insertion. This is the table-stakes defense (issue #1210): it does not make injection impossible, but changes it from "works on first try" to "requires evasion." |
|
||||
| YAML frontmatter injection | `_yaml_str()` escapes backslashes, double quotes, and newlines before embedding user-controlled strings (webpage titles, query questions) in YAML frontmatter. |
|
||||
| Encoding crashes on source files | All tree-sitter byte slices decoded with `errors="replace"` - non-UTF-8 source files degrade gracefully instead of crashing extraction. |
|
||||
| Symlink traversal | `os.walk(..., followlinks=False)` is explicit throughout `detect.py`. |
|
||||
| Corrupted graph.json | `_load_graph()` in `serve.py` wraps `json.JSONDecodeError` and prints a clear recovery message instead of crashing. |
|
||||
|
||||
### What graphify does NOT do
|
||||
|
||||
- Does not run a network listener by default (stdio transport); `--transport http` is opt-in, documented in the README, and binds to `127.0.0.1` unless `--host 0.0.0.0` is passed
|
||||
- Does not execute code from source files (tree-sitter parses ASTs - no eval/exec)
|
||||
- Does not use `shell=True` in any subprocess call
|
||||
- Does not store credentials or API keys
|
||||
|
||||
### Optional network calls
|
||||
|
||||
- `ingest` subcommand: fetches URLs explicitly provided by the user
|
||||
- PDF extraction: reads local files only (pypdf does not make network calls)
|
||||
- watch mode: local filesystem events only (watchdog does not make network calls)
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,138 @@
|
||||
# Docker MCP Toolkit + SQLite MCP server
|
||||
|
||||
A reproducible runbook for installing the **SQLite MCP server** into the
|
||||
[Docker MCP Toolkit](https://docs.docker.com/desktop/features/mcp/) so any
|
||||
connected MCP client (Claude Code, Claude Desktop, Cursor, VS Code, etc.) gains
|
||||
six SQLite tools: `read_query`, `write_query`, `create_table`, `list_tables`,
|
||||
`describe_table`, and `append_insight`.
|
||||
|
||||
This document is *not* required to use graphify — it lives here as a known-good
|
||||
recipe for users who want a lightweight, persistent SQL workspace exposed to
|
||||
their AI clients alongside graphify's knowledge-graph tools.
|
||||
|
||||
## Why SQLite (and not `sqlite-mcp-server`)
|
||||
At time of writing the catalog ships two SQLite MCP images:
|
||||
|
||||
| Catalog name | Image | Status |
|
||||
| ------------------- | ---------------------- | ------ |
|
||||
| `SQLite` | `mcp/sqlite` | Marked "Archived" in catalog metadata, but **boots and serves correctly** |
|
||||
| `sqlite-mcp-server` | `mcp/sqlite-mcp-server`| **Broken**: entrypoint `/app/.venv/bin/mcp-server-sqlite` does not exist in the published layer |
|
||||
|
||||
Use `SQLite` (`mcp/sqlite`) until the newer image is fixed upstream.
|
||||
|
||||
## Prerequisites
|
||||
- Docker Desktop running and healthy
|
||||
- `docker info` returns a `Server Version`
|
||||
- Public socket present at `/var/run/docker.sock` (or its symlink to
|
||||
`~/.docker/run/docker.sock`)
|
||||
- Docker MCP Toolkit CLI plugin (`docker mcp`)
|
||||
- Bundled with recent Docker Desktop releases; `docker mcp --version` should
|
||||
print a version string
|
||||
|
||||
## Install
|
||||
```bash
|
||||
# Add the working SQLite server to the default MCP profile
|
||||
docker mcp profile server add default \
|
||||
--server catalog://mcp/docker-mcp-catalog/SQLite
|
||||
|
||||
# Pre-pull the image so the first tool call is fast
|
||||
docker pull mcp/sqlite:latest
|
||||
```
|
||||
|
||||
Verify the profile now contains both `fetch` (built-in) and `SQLite`:
|
||||
```bash
|
||||
docker mcp profile show default | grep -E '^[[:space:]]+name:'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
name: fetch
|
||||
name: SQLite
|
||||
```
|
||||
|
||||
The Docker MCP gateway should now expose 6 additional tools:
|
||||
```bash
|
||||
docker mcp tools count
|
||||
# → 15 tools (was 9 before adding SQLite)
|
||||
```
|
||||
|
||||
## Smoke test
|
||||
The CLI can call MCP tools directly (each call boots a fresh gateway, ~5s
|
||||
overhead per call):
|
||||
```bash
|
||||
docker mcp tools call list_tables
|
||||
docker mcp tools call create_table \
|
||||
query='CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY AUTOINCREMENT, body TEXT NOT NULL, created_at TEXT DEFAULT CURRENT_TIMESTAMP)'
|
||||
docker mcp tools call write_query \
|
||||
query="INSERT INTO notes(body) VALUES ('first row'), ('second row')"
|
||||
docker mcp tools call read_query \
|
||||
query='SELECT * FROM notes ORDER BY id'
|
||||
docker mcp tools call describe_table table_name=notes
|
||||
docker mcp tools call append_insight insight='3 rows inserted; aggregates work.'
|
||||
```
|
||||
|
||||
`read_query` should return the inserted rows with timestamps.
|
||||
|
||||
## Storage layout
|
||||
Database file lives in a Docker named volume `mcp-sqlite`, mounted at `/mcp`
|
||||
inside containers:
|
||||
```
|
||||
mcp-sqlite (named volume) → /mcp/db.sqlite
|
||||
```
|
||||
|
||||
Inspect from the host:
|
||||
```bash
|
||||
docker volume inspect mcp-sqlite
|
||||
docker run --rm -v mcp-sqlite:/mcp:ro alpine ls -la /mcp
|
||||
docker run --rm -v mcp-sqlite:/mcp:ro keinos/sqlite3 \
|
||||
sqlite3 /mcp/db.sqlite '.schema'
|
||||
```
|
||||
|
||||
The volume persists across `docker run --rm` invocations of the SQLite MCP
|
||||
container, so writes from one MCP tool call are visible to the next.
|
||||
|
||||
## Wiring into MCP clients
|
||||
Connect once per client; the gateway exposes every server in the active profile:
|
||||
```bash
|
||||
docker mcp client connect claude-code # already connected for many users
|
||||
docker mcp client connect cursor
|
||||
docker mcp client connect vscode
|
||||
docker mcp client connect claude-desktop
|
||||
# Supported: claude-code, claude-desktop, cline, codex, continue, crush,
|
||||
# cursor, gemini, goose, gordon, kiro, lmstudio, opencode, sema4,
|
||||
# vscode, zed
|
||||
```
|
||||
|
||||
Verify wiring:
|
||||
```bash
|
||||
docker mcp client ls
|
||||
```
|
||||
|
||||
## Uninstall / reset
|
||||
```bash
|
||||
# Remove server from the profile
|
||||
docker mcp profile server remove default SQLite
|
||||
|
||||
# Drop the database volume (irreversible)
|
||||
docker volume rm mcp-sqlite
|
||||
|
||||
# Remove the image
|
||||
docker rmi mcp/sqlite:latest
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
- **`starting client: calling "initialize": EOF`** — the requested server
|
||||
failed its MCP handshake. Run the image directly to see the error:
|
||||
```bash
|
||||
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0.0"}}}\n' \
|
||||
| docker run --rm -i -v mcp-sqlite:/mcp <image-ref> --db-path /mcp/db.sqlite
|
||||
```
|
||||
Common causes: missing entrypoint binary in the image (the
|
||||
`sqlite-mcp-server` failure mode) or missing required env/secrets.
|
||||
- **`cannot use --enable-all-servers with --servers flag`** — these gateway
|
||||
args are mutually exclusive; pick one.
|
||||
- **No new tools appear in `docker mcp tools count` after install** — the
|
||||
gateway may be running with `dynamic-tools` enabled, exposing only meta-tools
|
||||
(`mcp-add`, `mcp-find`, …) until a profile is activated mid-session. Either
|
||||
invoke `docker mcp tools` (which spins up an ephemeral gateway against the
|
||||
default profile) or call `mcp-activate-profile` from inside an MCP session.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 634 KiB |
@@ -0,0 +1,101 @@
|
||||
# How graphify works
|
||||
|
||||
## The three passes
|
||||
|
||||
graphify processes your files in three passes:
|
||||
|
||||
**Pass 1 — Code structure (free, no API calls)**
|
||||
Tree-sitter parses your code files and extracts classes, functions, imports, call graphs, and inline comments. This runs locally with no LLM involved. 25 languages supported. SQL files get special treatment: tables, views, foreign keys, and JOIN relationships are extracted deterministically.
|
||||
|
||||
Code files are not sent to the LLM semantic extractor in the normal pipeline. If a corpus contains only code files, Pass 3 is skipped entirely; semantic extraction is reserved for docs, papers, images, and transcripts.
|
||||
|
||||
**Pass 2 — Video and audio (local, no API calls)**
|
||||
Video and audio files are transcribed with faster-whisper. To focus the transcript on your domain, the transcription prompt is seeded with your top god nodes (the most-connected concepts in your code graph so far). Transcripts are cached — re-runs skip already-processed files.
|
||||
|
||||
**Pass 3 — Docs, papers, images (Claude subagents, costs tokens)**
|
||||
Claude runs in parallel over markdown, PDFs, images, and transcripts. Each subagent reads a batch of files and outputs a JSON fragment: nodes, edges, and any group relationships. The fragments are merged into a single graph.
|
||||
|
||||
Before Pass 3, optional converters turn supported pointer/binary formats into
|
||||
Markdown sidecars under `graphify-out/converted/`. Office files (`.docx`,
|
||||
`.xlsx`) use the `[office]` extra. Google Workspace shortcuts (`.gdoc`,
|
||||
`.gsheet`, `.gslides`) are opt-in with `--google-workspace` or
|
||||
`GRAPHIFY_GOOGLE_WORKSPACE=1` and require an authenticated `gws` CLI.
|
||||
|
||||
---
|
||||
|
||||
## How community detection works
|
||||
|
||||
Communities are found using the [Leiden algorithm](https://www.nature.com/articles/s41598-019-41695-z) — a graph-clustering method that groups nodes by edge density. Nodes with many connections between them end up in the same community.
|
||||
|
||||
**No embeddings needed.** The semantic similarity edges that Claude extracts (`semantically_similar_to`) are already in the graph, so they influence community shape directly. The graph structure is the similarity signal — there's no separate embedding step or vector database.
|
||||
|
||||
---
|
||||
|
||||
## Confidence tagging
|
||||
|
||||
Every relationship is tagged with one of three labels:
|
||||
|
||||
| Tag | Meaning |
|
||||
|-----|---------|
|
||||
| `EXTRACTED` | Found directly in the source (e.g. a function call, an import) |
|
||||
| `INFERRED` | A reasonable inference Claude made, with a `confidence_score` (0.0–1.0) |
|
||||
| `AMBIGUOUS` | Uncertain — flagged in the report for manual review |
|
||||
|
||||
EXTRACTED edges always have confidence 1.0. INFERRED edges use a discrete rubric:
|
||||
- **0.95** — near-certain (explicit cross-file reference, one plausible target)
|
||||
- **0.85** — strong evidence (naming + context align)
|
||||
- **0.75** — reasonable (contextual but not explicit)
|
||||
- **0.65** — weak (naming similarity only)
|
||||
- **0.55** — speculative
|
||||
|
||||
---
|
||||
|
||||
## Token benchmark
|
||||
|
||||
The first run extracts and builds the graph — this costs tokens. Every subsequent query reads the compact graph instead of raw files. That's where the savings compound.
|
||||
|
||||
On a mixed corpus (Karpathy repos + 5 papers + 4 images, 52 files): **71.5x fewer tokens per query** vs reading the raw files directly.
|
||||
|
||||
| Corpus | Files | Reduction |
|
||||
|--------|-------|-----------|
|
||||
| Karpathy repos + papers + images | 52 | **71.5x** |
|
||||
| graphify source + Transformer paper | 4 | **5.4x** |
|
||||
| httpx (synthetic Python library) | 6 | ~1x |
|
||||
|
||||
Token reduction scales with corpus size. Six files already fits in a context window — the graph value there is structural clarity, not compression. At 52 files the savings compound quickly.
|
||||
|
||||
Each `worked/` folder in the repo has the raw input files and actual output (`GRAPH_REPORT.md`, `graph.json`) so you can run it yourself and verify.
|
||||
|
||||
---
|
||||
|
||||
## Parallel extraction
|
||||
|
||||
Code files are extracted in parallel using `ProcessPoolExecutor` — bypasses Python's GIL for genuine multiprocessing. Doc/paper/image batches are dispatched as parallel Claude subagents. On a corpus of 84 code files, parallel AST extraction runs in about 1.66x less time than sequential.
|
||||
|
||||
---
|
||||
|
||||
## SHA256 cache
|
||||
|
||||
Every extracted file is fingerprinted by content hash. Re-runs skip unchanged files entirely — only new or modified files go through extraction again. The cache lives in `graphify-out/cache/`.
|
||||
|
||||
---
|
||||
|
||||
## The graph format
|
||||
|
||||
The output `graph.json` uses NetworkX's node-link format. Each node has:
|
||||
- `id` — stable identifier
|
||||
- `label` — human-readable name
|
||||
- `file_type` — `code`, `document`, `paper`, `image`, `rationale`
|
||||
- `source_file` — where it came from
|
||||
|
||||
See [RFC: file-level node summaries](node-summaries-rfc.md) for two proposed
|
||||
ways to add compact optional summaries for AI navigation.
|
||||
|
||||
Each edge has:
|
||||
- `source`, `target` — node IDs
|
||||
- `relation` — verb phrase (e.g. `calls`, `imports`, `implements`, `semantically_similar_to`)
|
||||
- `confidence` — `EXTRACTED`, `INFERRED`, or `AMBIGUOUS`
|
||||
- `confidence_score` — float (INFERRED only)
|
||||
- `source_file` — where the relationship was found
|
||||
|
||||
Hyperedges (group relationships connecting 3+ nodes) live in `G.graph["hyperedges"]`.
|
||||
@@ -0,0 +1,47 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Graphify">
|
||||
<defs>
|
||||
<style>
|
||||
.edge { stroke: #22c55e; stroke-width: 1.1; stroke-linecap: round; opacity: 0.55; fill: none; }
|
||||
.edge-hot { stroke: #4ade80; stroke-width: 1.2; stroke-linecap: round; opacity: 0.9; fill: none; }
|
||||
.node { fill: #040806; stroke: #22c55e; stroke-width: 1.3; }
|
||||
.node-hub { fill: #0a1410; stroke: #4ade80; stroke-width: 1.6; }
|
||||
.node-amber { fill: #040806; stroke: #f59e0b; stroke-width: 1.3; }
|
||||
.node-amber-core { fill: #f59e0b; }
|
||||
.node-core { fill: #22c55e; }
|
||||
.node-hub-core { fill: #4ade80; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- subtle halo around hub -->
|
||||
<circle cx="24" cy="24" r="11" fill="none" stroke="#22c55e" stroke-width="0.4" opacity="0.18"/>
|
||||
|
||||
<!-- edges: hub to satellites -->
|
||||
<line class="edge-hot" x1="24" y1="24" x2="10" y2="11"/>
|
||||
<line class="edge-hot" x1="24" y1="24" x2="39" y2="14"/>
|
||||
<line class="edge-hot" x1="24" y1="24" x2="38" y2="36"/>
|
||||
<line class="edge-hot" x1="24" y1="24" x2="11" y2="37"/>
|
||||
|
||||
<!-- perimeter edges -->
|
||||
<line class="edge" x1="10" y1="11" x2="39" y2="14"/>
|
||||
<line class="edge" x1="39" y1="14" x2="38" y2="36"/>
|
||||
<line class="edge" x1="38" y1="36" x2="11" y2="37"/>
|
||||
<line class="edge" x1="11" y1="37" x2="10" y2="11"/>
|
||||
|
||||
<!-- satellite nodes -->
|
||||
<circle class="node" cx="10" cy="11" r="2.6"/>
|
||||
<circle class="node-core" cx="10" cy="11" r="1.1"/>
|
||||
|
||||
<circle class="node" cx="39" cy="14" r="2.6"/>
|
||||
<circle class="node-core" cx="39" cy="14" r="1.1"/>
|
||||
|
||||
<!-- amber accent node -->
|
||||
<circle class="node-amber" cx="38" cy="36" r="2.8"/>
|
||||
<circle class="node-amber-core" cx="38" cy="36" r="1.25"/>
|
||||
|
||||
<circle class="node" cx="11" cy="37" r="2.6"/>
|
||||
<circle class="node-core" cx="11" cy="37" r="1.1"/>
|
||||
|
||||
<!-- central hub / god-node -->
|
||||
<circle class="node-hub" cx="24" cy="24" r="4.4"/>
|
||||
<circle class="node-hub-core" cx="24" cy="24" r="1.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 64" role="img" aria-label="Graphify">
|
||||
<rect width="260" height="64" rx="8" fill="#040806"/>
|
||||
<defs>
|
||||
<style>
|
||||
.logo-text { font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; font-size: 42px; font-weight: 700; letter-spacing: -1.6px; }
|
||||
</style>
|
||||
</defs>
|
||||
<!-- icon mark -->
|
||||
<circle cx="24" cy="32" r="11" fill="none" stroke="#22c55e" stroke-width="0.4" opacity="0.18"/>
|
||||
<line x1="24" y1="32" x2="10" y2="19" stroke="#4ade80" stroke-width="1.2" stroke-linecap="round" opacity="0.9"/>
|
||||
<line x1="24" y1="32" x2="39" y2="22" stroke="#4ade80" stroke-width="1.2" stroke-linecap="round" opacity="0.9"/>
|
||||
<line x1="24" y1="32" x2="38" y2="44" stroke="#4ade80" stroke-width="1.2" stroke-linecap="round" opacity="0.9"/>
|
||||
<line x1="24" y1="32" x2="11" y2="45" stroke="#4ade80" stroke-width="1.2" stroke-linecap="round" opacity="0.9"/>
|
||||
<line x1="10" y1="19" x2="39" y2="22" stroke="#22c55e" stroke-width="1.1" stroke-linecap="round" opacity="0.55"/>
|
||||
<line x1="39" y1="22" x2="38" y2="44" stroke="#22c55e" stroke-width="1.1" stroke-linecap="round" opacity="0.55"/>
|
||||
<line x1="38" y1="44" x2="11" y2="45" stroke="#22c55e" stroke-width="1.1" stroke-linecap="round" opacity="0.55"/>
|
||||
<line x1="11" y1="45" x2="10" y2="19" stroke="#22c55e" stroke-width="1.1" stroke-linecap="round" opacity="0.55"/>
|
||||
<circle cx="10" cy="19" r="2.6" fill="#040806" stroke="#22c55e" stroke-width="1.3"/>
|
||||
<circle cx="10" cy="19" r="1.1" fill="#22c55e"/>
|
||||
<circle cx="39" cy="22" r="2.6" fill="#040806" stroke="#22c55e" stroke-width="1.3"/>
|
||||
<circle cx="39" cy="22" r="1.1" fill="#22c55e"/>
|
||||
<circle cx="38" cy="44" r="2.8" fill="#040806" stroke="#f59e0b" stroke-width="1.3"/>
|
||||
<circle cx="38" cy="44" r="1.25" fill="#f59e0b"/>
|
||||
<circle cx="11" cy="45" r="2.6" fill="#040806" stroke="#22c55e" stroke-width="1.3"/>
|
||||
<circle cx="11" cy="45" r="1.1" fill="#22c55e"/>
|
||||
<circle cx="24" cy="32" r="4.4" fill="#0a1410" stroke="#4ade80" stroke-width="1.6"/>
|
||||
<circle cx="24" cy="32" r="1.8" fill="#4ade80"/>
|
||||
<!-- divider -->
|
||||
<line x1="56" y1="18" x2="56" y2="46" stroke="#22c55e" stroke-width="1" opacity="0.2"/>
|
||||
<!-- text: Graph in near-white, ify in green -->
|
||||
<text x="68" y="47" class="logo-text" fill="#e6f5ec">Graph</text>
|
||||
<text x="178" y="47" class="logo-text" fill="#22c55e">ify</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
@@ -0,0 +1,186 @@
|
||||
# RFC: file-level node summaries
|
||||
|
||||
This RFC proposes an optional way for graphify to expose compact file-level
|
||||
summaries for AI coding agents.
|
||||
|
||||
## Problem
|
||||
|
||||
`graph.json` gives agents graph structure, source files, node labels, and
|
||||
relationships. That helps avoid reading an entire repository, but agents still
|
||||
often need to inspect raw files just to answer a basic navigation question:
|
||||
|
||||
> What is this file or node responsible for?
|
||||
|
||||
A short summary near the graph node could reduce repeated file reads during
|
||||
`graphify query`, `graphify explain`, MCP node lookup, and graph navigation.
|
||||
|
||||
## Goals
|
||||
|
||||
- Help agents choose relevant files with less context.
|
||||
- Preserve graphify's offline, deterministic behavior by default.
|
||||
- Keep the first implementation small and reviewable.
|
||||
- Avoid adding long prose to `GRAPH_REPORT.md`.
|
||||
- Leave room for a future opt-in LLM-generated summary backend.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Summarizing every function, method, or local symbol in the first version.
|
||||
- Calling an LLM or remote API by default.
|
||||
- Replacing `graphify explain`; summaries should make `explain` more useful.
|
||||
- Turning `GRAPH_REPORT.md` into a full per-file index.
|
||||
|
||||
## Shared constraints for either option
|
||||
|
||||
- Start with file-level nodes only.
|
||||
- Keep each summary bounded, for example one sentence or roughly 200-300
|
||||
characters.
|
||||
- Generate deterministic summaries first from existing local signal:
|
||||
module docstrings, top comments, exported symbols, imports, relation counts,
|
||||
and community/context data.
|
||||
- Do not emit summaries by default until the storage model is agreed on.
|
||||
- Display a summary in `graphify explain <node>` when one exists.
|
||||
- Include summaries in `graphify serve` / MCP node lookup when one exists.
|
||||
|
||||
## Proposed summary contents
|
||||
|
||||
Each file-level summary should give an agent enough signal to decide whether to
|
||||
inspect the file, without replacing the file or bloating graph output.
|
||||
|
||||
Recommended fields:
|
||||
|
||||
| Field | Why it helps agents |
|
||||
| --- | --- |
|
||||
| `summary` | One bounded sentence describing the file's responsibility. This is the main token-saving field. |
|
||||
| `source_file` | Lets the agent jump to the right file without resolving a node ID manually. |
|
||||
| `label` | Keeps the summary readable in CLI, MCP, and UI contexts. |
|
||||
| `generated_by` | Distinguishes deterministic summaries from future LLM-generated summaries. |
|
||||
| `summary_version` | Allows the format to evolve without guessing how an older summary was produced. |
|
||||
|
||||
Recommended signal inside the `summary` sentence:
|
||||
|
||||
| Signal | Why it belongs in the sentence |
|
||||
| --- | --- |
|
||||
| Module docstring or top comment | Usually the highest-quality human-authored purpose statement. |
|
||||
| Exported classes/functions/symbols | Tells the agent what API surface the file provides. |
|
||||
| Important imports or dependencies | Reveals whether the file belongs to auth, database, UI, CLI, test, etc. |
|
||||
| Dominant graph relations | Shows whether the file mainly calls, imports, defines, contains, or references other nodes. |
|
||||
| Community or nearby hub label | Gives architectural context without reading the whole community report. |
|
||||
| Source location coverage when available | Helps distinguish a file-level summary from a symbol-level explanation. |
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"label": "extract.py",
|
||||
"source_file": "graphify/extract.py",
|
||||
"summary": "Extracts source files into graph nodes and relationships; defines language parsers and import/call extraction helpers.",
|
||||
"generated_by": "deterministic",
|
||||
"summary_version": 1
|
||||
}
|
||||
```
|
||||
|
||||
The summary should not include long call chains, full dependency lists, raw code,
|
||||
or every symbol in the file. Those details already belong in the graph and can
|
||||
be fetched through `graphify explain`, `graphify query`, or direct file reads
|
||||
when needed.
|
||||
|
||||
## Option A: `summary` attribute in `graph.json`
|
||||
|
||||
Add an optional `summary` field to file-level nodes:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "graphify_extract",
|
||||
"label": "extract.py",
|
||||
"file_type": "code",
|
||||
"source_file": "graphify/extract.py",
|
||||
"summary": "Extracts source files into graph nodes and relationships using language-specific parsers."
|
||||
}
|
||||
```
|
||||
|
||||
Possible user flow:
|
||||
|
||||
```bash
|
||||
graphify . --summarize-nodes
|
||||
graphify explain "extract.py"
|
||||
```
|
||||
|
||||
Pros:
|
||||
|
||||
- Single artifact for graph consumers.
|
||||
- Matches NetworkX node attributes and existing node metadata.
|
||||
- Easy for `explain`, `serve`, visualizers, and MCP tools to consume.
|
||||
- No sidecar freshness or node-ID join logic.
|
||||
|
||||
Cons:
|
||||
|
||||
- Adds text to the core graph artifact.
|
||||
- Expands the graph schema surface.
|
||||
- Consumers that dump all of `graph.json` into an LLM context would pay for all
|
||||
summaries at once.
|
||||
|
||||
## Option B: sidecar `node-summaries.json`
|
||||
|
||||
Write summaries to a separate artifact keyed by node ID:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"generator": "deterministic",
|
||||
"nodes": {
|
||||
"graphify_extract": {
|
||||
"label": "extract.py",
|
||||
"source_file": "graphify/extract.py",
|
||||
"summary": "Extracts source files into graph nodes and relationships using language-specific parsers."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Possible user flow:
|
||||
|
||||
```bash
|
||||
graphify summarize
|
||||
graphify explain "extract.py"
|
||||
```
|
||||
|
||||
Pros:
|
||||
|
||||
- Keeps `graph.json` lean and topology-focused.
|
||||
- Makes summaries clearly optional.
|
||||
- Can be regenerated independently.
|
||||
- Provides a natural place for future generator metadata.
|
||||
|
||||
Cons:
|
||||
|
||||
- Adds a second artifact that consumers must discover and load.
|
||||
- Introduces freshness and synchronization questions.
|
||||
- Every consumer that wants summaries must join by node ID.
|
||||
|
||||
## Suggested first implementation once storage is chosen
|
||||
|
||||
1. Add deterministic file-level summary generation.
|
||||
2. Store summaries using the selected option.
|
||||
3. Surface summaries in `graphify explain`.
|
||||
4. Surface summaries in `graphify serve` / MCP node lookup.
|
||||
5. Add tests for default behavior, generated summaries, missing summaries, and
|
||||
bounded summary length.
|
||||
|
||||
## Follow-up ideas
|
||||
|
||||
- Add opt-in LLM-generated summaries with explicit provider/backend selection.
|
||||
- Extend summaries to class or module-level nodes if file-level summaries prove
|
||||
useful.
|
||||
- Allow `graphify query` to include summaries for returned nodes under a budget.
|
||||
- Add cache/freshness metadata if summaries are generated separately from the
|
||||
main graph.
|
||||
|
||||
## Questions for maintainers and users
|
||||
|
||||
1. Should graphify prefer one artifact (`graph.json`) or keep generated text in a
|
||||
sidecar?
|
||||
2. Should deterministic file-level summaries be generated during graph creation,
|
||||
or only through an explicit command such as `graphify summarize`?
|
||||
3. Is `summary` the right term, or would `synopsis` better communicate a short,
|
||||
bounded description?
|
||||
4. What summary length budget would be acceptable for large repositories?
|
||||
@@ -0,0 +1,177 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
</p>
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**مهارة لمساعد برمجة الذكاء الاصطناعي.** اكتب `/graphify` في Claude Code أو Codex أو OpenCode أو Cursor أو Gemini CLI أو GitHub Copilot CLI أو VS Code Copilot Chat أو Aider أو OpenClaw أو Factory Droid أو Trae أو Hermes أو Kiro أو Google Antigravity — يقرأ ملفاتك ويبني رسماً بيانياً للمعرفة ويعيد إليك البنية التي لم تكن تعلم بوجودها. افهم قاعدة الكود بشكل أسرع. اكتشف "السبب" وراء القرارات المعمارية.
|
||||
|
||||
متعدد الوسائط بالكامل. أضف كوداً أو ملفات PDF أو markdown أو لقطات شاشة أو رسوماً بيانية أو صور سبورة أو صوراً بلغات أخرى أو ملفات فيديو وصوت — يستخرج graphify المفاهيم والعلاقات من كل ذلك ويربطها في رسم بياني واحد. يتم نسخ مقاطع الفيديو محلياً باستخدام Whisper. يدعم 25 لغة برمجة عبر tree-sitter AST (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart).
|
||||
|
||||
> يحتفظ Andrej Karpathy بمجلد `/raw` يضع فيه الأوراق البحثية والتغريدات ولقطات الشاشة والملاحظات. graphify هو الإجابة على تلك المشكلة — **71.5 مرة** أقل في الرموز لكل استعلام مقارنةً بقراءة الملفات الخام، مستمر عبر الجلسات، صادق حول ما تم العثور عليه مقابل ما تم استنتاجه.
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
/graphify . # يعمل مع أي مجلد — الكود، الملاحظات، الأوراق البحثية، كل شيء
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html رسم بياني تفاعلي — افتحه في أي متصفح، انقر على العقد، ابحث، صفّ
|
||||
├── GRAPH_REPORT.md عقد الإله، الاتصالات المفاجئة، الأسئلة المقترحة
|
||||
├── graph.json رسم بياني دائم — استعلم بعد أسابيع دون إعادة القراءة
|
||||
└── cache/ ذاكرة تخزين مؤقت SHA256 — إعادة التشغيل تعالج الملفات المتغيرة فقط
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
أضف ملف `.graphifyignore` لاستبعاد المجلدات:
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
نفس صيغة `.gitignore`.
|
||||
|
||||
## كيف يعمل
|
||||
|
||||
يعمل graphify في ثلاث مراحل. أولاً، تمريرة AST حتمية تستخرج البنية من ملفات الكود (الفئات، الدوال، الاستيرادات، رسوم بيانية الاستدعاء، docstrings، تعليقات المبرر) — دون الحاجة إلى LLM. ثانياً، يتم نسخ ملفات الفيديو والصوت محلياً باستخدام faster-whisper. ثالثاً، تعمل عوامل Claude الفرعية بالتوازي على المستندات والأوراق البحثية والصور والنصوص المكتوبة لاستخراج المفاهيم والعلاقات ومبررات التصميم. يتم دمج النتائج في رسم بياني NetworkX وتجميعها باستخدام Leiden وتصديرها كـ HTML تفاعلي وJSON قابل للاستعلام وتقرير تدقيق بلغة طبيعية.
|
||||
|
||||
**التجميع مبني على طوبولوجيا الرسم البياني — بدون embeddings.** يجد Leiden المجتمعات بواسطة كثافة الحواف. حواف التشابه الدلالي التي يستخرجها Claude (`semantically_similar_to`، مصنفة INFERRED) موجودة بالفعل في الرسم البياني. بنية الرسم البياني هي إشارة التشابه — لا حاجة لخطوة embedding منفصلة أو قاعدة بيانات متجهية.
|
||||
|
||||
كل علاقة مصنفة كـ `EXTRACTED` (وجدت مباشرة في المصدر) أو `INFERRED` (استنتاج معقول مع درجة ثقة) أو `AMBIGUOUS` (مُعلَّمة للمراجعة).
|
||||
|
||||
## التثبيت
|
||||
|
||||
**المتطلبات:** Python 3.10+ وواحد من: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes, أو [Google Antigravity](https://antigravity.google)
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
# موصى به — يعمل على Mac وLinux دون إعداد PATH
|
||||
uv tool install graphifyy && graphify install
|
||||
# أو مع pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# أو pip العادي
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
> **الحزمة الرسمية:** اسم حزمة PyPI هو `graphifyy` (تثبيت بـ `pip install graphifyy`). الحزم الأخرى المسماة `graphify*` على PyPI ليست تابعة لهذا المشروع. المستودع الرسمي الوحيد هو [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
### دعم المنصات
|
||||
|
||||
| المنصة | أمر التثبيت |
|
||||
|--------|-------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (كشف تلقائي) أو `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
افتح مساعد الكود الذكاء الاصطناعي واكتب:
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
ملاحظة: يستخدم Codex `$` بدلاً من `/` للمهارات، لذا اكتب `$graphify .`.
|
||||
|
||||
## الاستخدام
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
/graphify # المجلد الحالي
|
||||
/graphify ./raw # مجلد محدد
|
||||
/graphify ./raw --update # إعادة استخراج الملفات المتغيرة فقط
|
||||
/graphify ./raw --directed # رسم بياني موجّه
|
||||
/graphify ./raw --no-viz # تقرير + JSON فقط، بدون HTML
|
||||
/graphify ./raw --obsidian # إنشاء Obsidian vault
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # جلب ورقة بحثية
|
||||
/graphify add <video-url> # تحميل صوت، نسخ، إضافة
|
||||
/graphify query "ما الذي يربط Attention بالمحسِّن؟"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify hook install # تثبيت Git hooks
|
||||
graphify update ./src # إعادة استخراج ملفات الكود، بدون LLM
|
||||
graphify watch ./src # تحديث تلقائي للرسم البياني
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## ما ستحصل عليه
|
||||
|
||||
**عقد الإله** — المفاهيم ذات أعلى درجة (كل شيء يمر بها)
|
||||
|
||||
**الاتصالات المفاجئة** — مرتبة حسب الدرجة المركبة. حواف الكود-الورقة البحثية تحصل على تصنيف أعلى. كل نتيجة تتضمن سبباً بلغة طبيعية.
|
||||
|
||||
**الأسئلة المقترحة** — 4-5 أسئلة الرسم البياني في وضع فريد للإجابة عليها
|
||||
|
||||
**"السبب"** — يتم استخراج docstrings والتعليقات المضمنة (`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`) ومبررات التصميم كعقد `rationale_for`.
|
||||
|
||||
**درجات الثقة** — كل حافة INFERRED لها `confidence_score` (0.0-1.0).
|
||||
|
||||
**معيار الرموز** — يُطبع تلقائياً بعد كل تشغيل. على مجموعة بيانات مختلطة: **71.5 مرة** أقل في الرموز لكل استعلام مقارنةً بالملفات الخام.
|
||||
|
||||
**المزامنة التلقائية** (`--watch`) — يحدّث الرسم البياني تلقائياً عند تغيير الكود.
|
||||
|
||||
**Git hooks** (`graphify hook install`) — يثبّت خطافات post-commit وpost-checkout.
|
||||
|
||||
## الخصوصية
|
||||
|
||||
يرسل graphify محتوى الملفات إلى API نموذج مساعد الذكاء الاصطناعي الخاص بك للاستخراج الدلالي من المستندات والأوراق البحثية والصور. تتم معالجة ملفات الكود محلياً عبر tree-sitter AST. يتم نسخ ملفات الفيديو والصوت محلياً باستخدام faster-whisper. لا قياس عن بُعد، لا تتبع للاستخدام.
|
||||
|
||||
## المكدس التقني
|
||||
|
||||
NetworkX + Leiden (graspologic) + tree-sitter + vis.js. استخراج دلالي عبر Claude أو GPT-4 أو نموذج منصتك. نسخ الفيديو عبر faster-whisper + yt-dlp (اختياري).
|
||||
|
||||
## مبني على graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) هو الطبقة المؤسسية فوق graphify. حيث يحوّل graphify مجلداً من الملفات إلى رسم بياني للمعرفة، يطبّق Penpax نفس الرسم البياني على حياتك المهنية بأكملها — باستمرار.
|
||||
|
||||
**نسخة تجريبية مجانية قريباً.** [انضم إلى قائمة الانتظار →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
## سجل النجوم
|
||||
|
||||
</div>
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Dovednost pro asistenty kódování AI.** Napište `/graphify` v Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro nebo Google Antigravity — přečte vaše soubory, vytvoří znalostní graf a vrátí vám strukturu, o které jste nevěděli, že existuje. Pochopte kódovou základnu rychleji. Najděte „proč" za architektonickými rozhodnutími.
|
||||
|
||||
Plně multimodální. Přidejte kód, PDF, markdown, snímky obrazovky, diagramy, fotografie tabule, obrázky v jiných jazycích nebo video a zvukové soubory — graphify extrahuje koncepty a vztahy ze všeho a spojuje je do jediného grafu. Videa jsou přepisována lokálně pomocí Whisper. Podporuje 25 programovacích jazyků prostřednictvím tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy udržuje složku `/raw`, kde ukládá články, tweety, snímky obrazovky a poznámky. graphify je odpovědí na tento problém — **71,5x** méně tokenů na dotaz ve srovnání se čtením surových souborů, přetrvávající mezi sezeními.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interaktivní graf — otevřete v libovolném prohlížeči
|
||||
├── GRAPH_REPORT.md boží uzly, překvapivá propojení, navrhované otázky
|
||||
├── graph.json trvalý graf — dotazovatelný týdny poté
|
||||
└── cache/ SHA256 cache — opakovaná spuštění zpracovávají pouze změněné soubory
|
||||
```
|
||||
|
||||
## Jak to funguje
|
||||
|
||||
graphify pracuje ve třech průchodech. Nejprve deterministický průchod AST extrahuje strukturu z kódových souborů bez LLM. Poté jsou video a zvukové soubory přepisovány lokálně pomocí faster-whisper. Nakonec sub-agenti Claude běží paralelně na dokumentech, článcích, obrázcích a přepisech. Výsledky jsou sloučeny do grafu NetworkX, clusterovány pomocí Leiden a exportovány jako interaktivní HTML, dotazovatelný JSON a auditní zpráva.
|
||||
|
||||
Každý vztah je označen `EXTRACTED`, `INFERRED` (se skóre spolehlivosti) nebo `AMBIGUOUS`.
|
||||
|
||||
## Instalace
|
||||
|
||||
**Požadavky:** Python 3.10+ a jedno z: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) a další.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# nebo s pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# nebo pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Oficiální balíček:** Balíček PyPI se jmenuje `graphifyy`. Jediné oficiální úložiště je [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Použití
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "co spojuje Attention s optimizerem?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Co získáte
|
||||
|
||||
**Boží uzly** — koncepty s nejvyšším stupněm · **Překvapivá propojení** — seřazená podle skóre · **Navrhované otázky** · **„Proč"** — docstringy a návrhové odůvodnění extrahované jako uzly · **Benchmark tokenů** — **71,5x** méně tokenů na smíšeném korpusu.
|
||||
|
||||
## Soukromí
|
||||
|
||||
Kódové soubory jsou zpracovávány lokálně prostřednictvím tree-sitter AST. Videa jsou přepisována lokálně pomocí faster-whisper. Žádná telemetrie.
|
||||
|
||||
## Postaveno na graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) je enterprise vrstva nad graphify. **Bezplatná zkušební verze brzy.** [Přidejte se na čekací listinu →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**En færdighed til AI-kodeassistenter.** Skriv `/graphify` i Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro eller Google Antigravity — den læser dine filer, bygger en vidensgraf og giver dig den struktur tilbage, du ikke vidste eksisterede. Forstå en kodebase hurtigere. Find "hvorfor" bag arkitektoniske beslutninger.
|
||||
|
||||
Fuldt multimodal. Tilføj kode, PDF'er, markdown, skærmbilleder, diagrammer, whiteboardfotos, billeder på andre sprog eller video- og lydfiler — graphify udtrækker begreber og relationer fra alt og forbinder dem i én graf. Videoer transskriberes lokalt med Whisper. Understøtter 25 programmeringssprog via tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy opretholder en `/raw`-mappe, hvor han lægger artikler, tweets, skærmbilleder og noter. graphify er svaret på det problem — **71,5x** færre tokens pr. forespørgsel sammenlignet med at læse rå filer, vedvarende mellem sessioner.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interaktiv graf — åbn i enhver browser
|
||||
├── GRAPH_REPORT.md gudknuder, overraskende forbindelser, foreslåede spørgsmål
|
||||
├── graph.json vedvarende graf — forespørgselsbar uger senere
|
||||
└── cache/ SHA256-cache — gentagne kørsler behandler kun ændrede filer
|
||||
```
|
||||
|
||||
## Sådan fungerer det
|
||||
|
||||
graphify arbejder i tre gennemløb. Først udtrækker et deterministisk AST-gennemløb struktur fra kodefiler uden LLM. Derefter transskriberes video- og lydfiler lokalt med faster-whisper. Endelig kører Claude-underagenter parallelt på dokumenter, artikler, billeder og transskriptioner. Resultaterne flettes ind i en NetworkX-graf, klynges med Leiden og eksporteres som interaktiv HTML, forespørgselsbar JSON og revisionsrapport.
|
||||
|
||||
Hver relation er mærket `EXTRACTED`, `INFERRED` (med konfidensscore) eller `AMBIGUOUS`.
|
||||
|
||||
## Installation
|
||||
|
||||
**Krav:** Python 3.10+ og én af: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) og andre.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# eller med pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# eller pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Officiel pakke:** PyPI-pakken hedder `graphifyy`. Det eneste officielle lager er [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Brug
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "hvad forbinder Attention med optimizeren?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Hvad du får
|
||||
|
||||
**Gudknuder** — begreber med den højeste grad · **Overraskende forbindelser** — rangeret efter score · **Foreslåede spørgsmål** · **"Hvorfor"** — docstrings og designbegrundelse udtrukket som knuder · **Token-benchmark** — **71,5x** færre tokens på blandet korpus.
|
||||
|
||||
## Privatliv
|
||||
|
||||
Kodefiler behandles lokalt via tree-sitter AST. Videoer transskriberes lokalt med faster-whisper. Ingen telemetri.
|
||||
|
||||
## Bygget på graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) er enterprise-laget oven på graphify. **Gratis prøveperiode kommer snart.** [Tilmeld dig ventelisten →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,180 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
</p>
|
||||
|
||||
**Eine KI-Coding-Assistent-Skill.** Tippe `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro oder Google Antigravity — es liest deine Dateien, baut einen Wissensgraphen und gibt dir Struktur zurück, die du vorher nicht sehen konntest. Verstehe eine Codebasis schneller. Finde das „Warum" hinter Architekturentscheidungen.
|
||||
|
||||
Vollständig multimodal. Leg Code, PDFs, Markdown, Screenshots, Diagramme, Whiteboard-Fotos, Bilder in anderen Sprachen oder Video- und Audiodateien ab — graphify extrahiert Konzepte und Beziehungen aus allem und verbindet sie in einem einzigen Graphen. Videos werden lokal mit Whisper transkribiert, angetrieben durch einen domänenspezifischen Prompt aus deinem Korpus. 25 Programmiersprachen werden über tree-sitter AST unterstützt (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart).
|
||||
|
||||
> Andrej Karpathy führt einen `/raw`-Ordner, in dem er Papers, Tweets, Screenshots und Notizen ablegt. graphify ist die Antwort auf dieses Problem — 71,5-fach weniger Tokens pro Abfrage gegenüber dem Lesen der Rohdateien, persistent über Sitzungen hinweg, ehrlich darüber, was gefunden vs. erschlossen wurde.
|
||||
|
||||
```
|
||||
/graphify . # funktioniert mit jedem Ordner — Codebase, Notizen, Papers, alles
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interaktiver Graph — im Browser öffnen, Knoten anklicken, suchen, filtern
|
||||
├── GRAPH_REPORT.md Gott-Knoten, überraschende Verbindungen, vorgeschlagene Fragen
|
||||
├── graph.json persistenter Graph — Wochen später abfragen, ohne neu zu lesen
|
||||
└── cache/ SHA256-Cache — erneute Ausführungen verarbeiten nur geänderte Dateien
|
||||
```
|
||||
|
||||
Füge eine `.graphifyignore`-Datei hinzu, um Ordner auszuschließen:
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
Gleiche Syntax wie `.gitignore`. Du kannst eine einzelne `.graphifyignore` im Repo-Stammverzeichnis behalten — Muster funktionieren korrekt, auch wenn graphify auf einem Unterordner ausgeführt wird.
|
||||
|
||||
## So funktioniert es
|
||||
|
||||
graphify läuft in drei Durchgängen. Zuerst extrahiert ein deterministischer AST-Durchgang Strukturen aus Code-Dateien (Klassen, Funktionen, Importe, Aufrufgraphen, Docstrings, Begründungskommentare) — ohne LLM. Zweitens werden Video- und Audiodateien lokal mit faster-whisper transkribiert, angetrieben durch einen domänenspezifischen Prompt aus Korpus-Gott-Knoten — Transkripte werden gecacht, sodass erneute Ausführungen sofort sind. Drittens laufen Claude-Subagenten parallel über Dokumente, Papers, Bilder und Transkripte, um Konzepte, Beziehungen und Designbegründungen zu extrahieren. Die Ergebnisse werden in einem NetworkX-Graphen zusammengeführt, mit Leiden-Community-Erkennung geclustert und als interaktives HTML, abfragbares JSON und ein Klartext-Audit-Report exportiert.
|
||||
|
||||
**Clustering basiert auf Graph-Topologie — keine Embeddings.** Leiden findet Communities durch Kantendichte. Die semantischen Ähnlichkeitskanten, die Claude extrahiert (`semantically_similar_to`, markiert als INFERRED), sind bereits im Graphen, sodass sie die Community-Erkennung direkt beeinflussen. Die Graphstruktur ist das Ähnlichkeitssignal — kein separater Embedding-Schritt oder Vektordatenbank nötig.
|
||||
|
||||
Jede Beziehung ist markiert als `EXTRACTED` (direkt in der Quelle gefunden), `INFERRED` (begründete Schlussfolgerung mit Konfidenzwert) oder `AMBIGUOUS` (zur Überprüfung markiert). Du weißt immer, was gefunden vs. erschlossen wurde.
|
||||
|
||||
## Installation
|
||||
|
||||
**Voraussetzungen:** Python 3.10+ und eines von: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes oder [Google Antigravity](https://antigravity.google)
|
||||
|
||||
```bash
|
||||
# Empfohlen — funktioniert auf Mac und Linux ohne PATH-Einrichtung
|
||||
uv tool install graphifyy && graphify install
|
||||
# oder mit pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# oder einfaches pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Offizielles Paket:** Das PyPI-Paket heißt `graphifyy` (installieren mit `pip install graphifyy`). Andere Pakete mit Namen `graphify*` auf PyPI sind nicht mit diesem Projekt verbunden. Das einzige offizielle Repository ist [safishamsi/graphify](https://github.com/safishamsi/graphify). CLI und Skill-Befehl heißen weiterhin `graphify`.
|
||||
|
||||
> **`graphify: command not found`?** Verwende `uv tool install graphifyy` (empfohlen) oder `pipx install graphifyy` — beide platzieren die CLI an einem verwalteten Ort, der automatisch im PATH ist. Mit einfachem `pip` musst du möglicherweise `~/.local/bin` (Linux) oder `~/Library/Python/3.x/bin` (Mac) zum PATH hinzufügen, oder `python -m graphify` verwenden.
|
||||
|
||||
### Plattformunterstützung
|
||||
|
||||
| Plattform | Installationsbefehl |
|
||||
|-----------|---------------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (automatisch erkannt) oder `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
Dann öffne deinen KI-Coding-Assistenten und tippe:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
Hinweis: Codex verwendet `$` statt `/` für Skill-Aufrufe, also tippe `$graphify .`.
|
||||
|
||||
### Assistenten immer den Graphen nutzen lassen (empfohlen)
|
||||
|
||||
Nach dem Erstellen eines Graphen, führe dies einmal in deinem Projekt aus:
|
||||
|
||||
| Plattform | Befehl |
|
||||
|-----------|--------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| GitHub Copilot CLI | `graphify copilot install` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify aider install` |
|
||||
| OpenClaw | `graphify claw install` |
|
||||
| Factory Droid | `graphify droid install` |
|
||||
| Trae | `graphify trae install` |
|
||||
| Trae CN | `graphify trae-cn install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Hermes | `graphify hermes install` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
## Verwendung
|
||||
|
||||
```
|
||||
/graphify # aktuelles Verzeichnis verarbeiten
|
||||
/graphify ./raw # spezifischen Ordner verarbeiten
|
||||
/graphify ./raw --mode deep # aggressivere INFERRED-Kanten-Extraktion
|
||||
/graphify ./raw --update # nur geänderte Dateien neu extrahieren
|
||||
/graphify ./raw --directed # gerichteten Graphen erstellen
|
||||
/graphify ./raw --cluster-only # Clustering auf bestehendem Graphen neu ausführen
|
||||
/graphify ./raw --no-viz # kein HTML, nur Report + JSON
|
||||
/graphify ./raw --obsidian # Obsidian-Vault generieren (opt-in)
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # Paper abrufen, speichern, Graphen aktualisieren
|
||||
/graphify add <video-url> # Audio herunterladen, transkribieren, hinzufügen
|
||||
/graphify query "was verbindet Attention mit dem Optimizer?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify hook install # Git-Hooks installieren
|
||||
graphify update ./src # Code-Dateien neu extrahieren, kein LLM benötigt
|
||||
graphify watch ./src # Graphen bei Änderungen automatisch aktualisieren
|
||||
```
|
||||
|
||||
## Was du bekommst
|
||||
|
||||
**Gott-Knoten** — Konzepte mit dem höchsten Grad (durch die alles fließt)
|
||||
|
||||
**Überraschende Verbindungen** — nach Composite-Score eingestuft. Code-Paper-Kanten werden höher bewertet. Jedes Ergebnis enthält ein Klartext-Warum.
|
||||
|
||||
**Vorgeschlagene Fragen** — 4-5 Fragen, die der Graph einzigartig gut beantworten kann
|
||||
|
||||
**Das „Warum"** — Docstrings, Inline-Kommentare (`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`), und Designbegründungen aus Dokumenten werden als `rationale_for`-Knoten extrahiert.
|
||||
|
||||
**Konfidenzwerte** — jede INFERRED-Kante hat einen `confidence_score` (0,0-1,0).
|
||||
|
||||
**Token-Benchmark** — wird automatisch nach jeder Ausführung gedruckt. Auf einem gemischten Korpus: **71,5-fach** weniger Tokens pro Abfrage gegenüber Rohdateien.
|
||||
|
||||
**Auto-Sync** (`--watch`) — läuft im Hintergrund und aktualisiert den Graphen bei Codeänderungen automatisch.
|
||||
|
||||
**Git-Hooks** (`graphify hook install`) — installiert Post-Commit- und Post-Checkout-Hooks.
|
||||
|
||||
## Datenschutz
|
||||
|
||||
graphify sendet Dateiinhalte an die Modell-API deines KI-Assistenten für semantische Extraktion von Dokumenten, Papers und Bildern. Code-Dateien werden lokal via tree-sitter AST verarbeitet — kein Dateiinhalt verlässt dein Gerät für Code. Video- und Audiodateien werden lokal mit faster-whisper transkribiert. Keine Telemetrie, keine Nutzungsverfolgung.
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
NetworkX + Leiden (graspologic) + tree-sitter + vis.js. Semantische Extraktion via Claude, GPT-4 oder welches Modell deine Plattform verwendet. Video-Transkription via faster-whisper + yt-dlp (optional).
|
||||
|
||||
## Auf graphify aufgebaut — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) ist die Enterprise-Schicht über graphify. Wo graphify einen Ordner mit Dateien in einen Wissensgraphen verwandelt, wendet Penpax denselben Graphen auf dein gesamtes Arbeitsleben an — kontinuierlich.
|
||||
|
||||
**Kostenlose Testversion startet bald.** [Auf die Warteliste setzen →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
## Star-Verlauf
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Μια δεξιότητα για βοηθούς κώδικα AI.** Πληκτρολογήστε `/graphify` στο Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro ή Google Antigravity — διαβάζει τα αρχεία σας, δημιουργεί ένα γράφο γνώσης και σας επιστρέφει δομή που δεν ξέρατε ότι υπήρχε. Κατανοήστε μια βάση κώδικα γρηγορότερα. Βρείτε το «γιατί» πίσω από αρχιτεκτονικές αποφάσεις.
|
||||
|
||||
Πλήρως πολυτροπικό. Προσθέστε κώδικα, PDF, markdown, στιγμιότυπα οθόνης, διαγράμματα, φωτογραφίες πίνακα, εικόνες σε άλλες γλώσσες ή αρχεία βίντεο και ήχου — το graphify εξάγει έννοιες και σχέσεις από όλα και τα συνδέει σε ένα ενιαίο γράφο. Τα βίντεο μεταγράφονται τοπικά με το Whisper. Υποστηρίζει 25 γλώσσες προγραμματισμού μέσω tree-sitter AST.
|
||||
|
||||
> Ο Andrej Karpathy διατηρεί ένα φάκελο `/raw` όπου αποθηκεύει εργασίες, tweets, στιγμιότυπα και σημειώσεις. Το graphify είναι η απάντηση σε αυτό το πρόβλημα — **71,5x** λιγότερα token ανά ερώτημα σε σύγκριση με την ανάγνωση αρχείων, επίμονο μεταξύ συνεδριών.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html διαδραστικός γράφος — ανοίξτε σε οποιοδήποτε πρόγραμμα περιήγησης
|
||||
├── GRAPH_REPORT.md κόμβοι-θεοί, εκπληκτικές συνδέσεις, προτεινόμενες ερωτήσεις
|
||||
├── graph.json επίμονος γράφος — μπορεί να υποβληθεί σε ερωτήματα εβδομάδες αργότερα
|
||||
└── cache/ κρυφή μνήμη SHA256 — επαναλαμβανόμενες εκτελέσεις επεξεργάζονται μόνο τα αλλαγμένα αρχεία
|
||||
```
|
||||
|
||||
## Πώς λειτουργεί
|
||||
|
||||
Το graphify λειτουργεί σε τρεις διελεύσεις. Πρώτα, μια ντετερμινιστική διέλευση AST εξάγει δομή από αρχεία κώδικα χωρίς LLM. Στη συνέχεια, τα αρχεία βίντεο και ήχου μεταγράφονται τοπικά με faster-whisper. Τέλος, οι υπο-πράκτορες Claude εκτελούνται παράλληλα σε έγγραφα, εργασίες, εικόνες και μεταγραφές. Τα αποτελέσματα συγχωνεύονται σε ένα γράφο NetworkX, ομαδοποιούνται με Leiden και εξάγονται ως διαδραστική HTML, JSON για ερωτήματα και αναφορά ελέγχου.
|
||||
|
||||
Κάθε σχέση επισημαίνεται ως `EXTRACTED`, `INFERRED` (με βαθμολογία εμπιστοσύνης) ή `AMBIGUOUS`.
|
||||
|
||||
## Εγκατάσταση
|
||||
|
||||
**Απαιτήσεις:** Python 3.10+ και ένα από: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) και άλλα.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# ή με pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# ή pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Επίσημο πακέτο:** Το πακέτο PyPI ονομάζεται `graphifyy`. Το μοναδικό επίσημο αποθετήριο είναι το [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Χρήση
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "τι συνδέει το Attention με τον optimizer;"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Τι λαμβάνετε
|
||||
|
||||
**Κόμβοι-θεοί** — έννοιες με τον υψηλότερο βαθμό · **Εκπληκτικές συνδέσεις** — ταξινομημένες κατά βαθμολογία · **Προτεινόμενες ερωτήσεις** · **Το «γιατί»** — docstrings και αιτιολόγηση σχεδιασμού εξαγόμενα ως κόμβοι · **Σημείο αναφοράς token** — **71,5x** λιγότερα token σε μικτό σώμα κειμένου.
|
||||
|
||||
## Απόρρητο
|
||||
|
||||
Τα αρχεία κώδικα επεξεργάζονται τοπικά μέσω tree-sitter AST. Τα βίντεο μεταγράφονται τοπικά με faster-whisper. Χωρίς τηλεμετρία.
|
||||
|
||||
## Δημιουργήθηκε στο graphify — Penpax
|
||||
|
||||
Το [**Penpax**](https://safishamsi.github.io/penpax.ai) είναι το εταιρικό επίπεδο πάνω από το graphify. **Δωρεάν δοκιμή σύντομα.** [Εγγραφείτε στη λίστα αναμονής →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,170 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
</p>
|
||||
|
||||
**Una habilidad para asistentes de código IA.** Escribe `/graphify` en Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro o Google Antigravity — lee tus archivos, construye un grafo de conocimiento y te devuelve estructura que no sabías que existía. Entiende una base de código más rápido. Encuentra el «por qué» detrás de las decisiones arquitectónicas.
|
||||
|
||||
Totalmente multimodal. Deposita código, PDFs, markdown, capturas de pantalla, diagramas, fotos de pizarras, imágenes en otros idiomas, o archivos de video y audio — graphify extrae conceptos y relaciones de todo ello y los conecta en un solo grafo. Los videos se transcriben localmente con Whisper usando un prompt adaptado al dominio derivado de tu corpus. 25 lenguajes de programación soportados mediante tree-sitter AST (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart).
|
||||
|
||||
> Andrej Karpathy mantiene una carpeta `/raw` donde deposita papers, tweets, capturas de pantalla y notas. graphify es la respuesta a ese problema — 71,5 veces menos tokens por consulta versus leer los archivos sin procesar, persistente entre sesiones, honesto sobre lo que encontró versus lo que infirió.
|
||||
|
||||
```
|
||||
/graphify . # funciona con cualquier carpeta — tu código, notas, papers, todo
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html grafo interactivo — abrir en cualquier navegador, hacer clic en nodos, buscar
|
||||
├── GRAPH_REPORT.md nodos dios, conexiones sorprendentes, preguntas sugeridas
|
||||
├── graph.json grafo persistente — consultar semanas después sin releer
|
||||
└── cache/ caché SHA256 — las re-ejecuciones solo procesan archivos modificados
|
||||
```
|
||||
|
||||
Añade un archivo `.graphifyignore` para excluir carpetas:
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
Misma sintaxis que `.gitignore`. Puedes mantener un único `.graphifyignore` en la raíz del repositorio.
|
||||
|
||||
## Cómo funciona
|
||||
|
||||
graphify se ejecuta en tres pasadas. Primero, una pasada AST determinista extrae estructura de los archivos de código (clases, funciones, importaciones, grafos de llamadas, docstrings, comentarios de justificación) sin necesidad de LLM. Segundo, los archivos de video y audio se transcriben localmente con faster-whisper usando un prompt adaptado al dominio derivado de los nodos dios del corpus. Tercero, subagentes de Claude se ejecutan en paralelo sobre documentos, papers, imágenes y transcripciones para extraer conceptos, relaciones y justificaciones de diseño. Los resultados se fusionan en un grafo NetworkX, se agrupan con detección de comunidades Leiden, y se exportan como HTML interactivo, JSON consultable y un informe de auditoría en lenguaje natural.
|
||||
|
||||
**El clustering se basa en la topología del grafo — sin embeddings.** Leiden encuentra comunidades por densidad de aristas. Las aristas de similitud semántica que Claude extrae (`semantically_similar_to`, marcadas como INFERRED) ya están en el grafo. La estructura del grafo es la señal de similitud — no se necesita paso de embedding separado ni base de datos vectorial.
|
||||
|
||||
Cada relación está etiquetada como `EXTRACTED` (encontrada directamente en la fuente), `INFERRED` (inferencia razonable con puntuación de confianza) o `AMBIGUOUS` (marcada para revisión).
|
||||
|
||||
## Instalación
|
||||
|
||||
**Requisitos:** Python 3.10+ y uno de: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes o [Google Antigravity](https://antigravity.google)
|
||||
|
||||
```bash
|
||||
# Recomendado — funciona en Mac y Linux sin configurar el PATH
|
||||
uv tool install graphifyy && graphify install
|
||||
# o con pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# o pip simple
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Paquete oficial:** El paquete PyPI se llama `graphifyy` (instalar con `pip install graphifyy`). Otros paquetes llamados `graphify*` en PyPI no están afiliados con este proyecto. El único repositorio oficial es [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
### Soporte de plataformas
|
||||
|
||||
| Plataforma | Comando de instalación |
|
||||
|------------|------------------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (detección automática) o `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
Luego abre tu asistente de código IA y escribe:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
Nota: Codex usa `$` en lugar de `/` para habilidades, así que escribe `$graphify .`.
|
||||
|
||||
### Hacer que el asistente siempre use el grafo (recomendado)
|
||||
|
||||
Después de construir un grafo, ejecuta esto una vez en tu proyecto:
|
||||
|
||||
| Plataforma | Comando |
|
||||
|------------|---------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
## Uso
|
||||
|
||||
```
|
||||
/graphify # directorio actual
|
||||
/graphify ./raw # carpeta específica
|
||||
/graphify ./raw --mode deep # extracción de aristas INFERRED más agresiva
|
||||
/graphify ./raw --update # re-extraer solo archivos modificados
|
||||
/graphify ./raw --directed # grafo dirigido
|
||||
/graphify ./raw --cluster-only # re-ejecutar clustering en grafo existente
|
||||
/graphify ./raw --no-viz # sin HTML, solo informe + JSON
|
||||
/graphify ./raw --obsidian # generar vault de Obsidian (opt-in)
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # obtener un paper
|
||||
/graphify add <video-url> # descargar audio, transcribir, añadir
|
||||
/graphify query "¿qué conecta Attention con el optimizador?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify hook install # instalar hooks de Git
|
||||
graphify update ./src # re-extraer archivos de código, sin LLM
|
||||
graphify watch ./src # actualización automática del grafo
|
||||
```
|
||||
|
||||
## Qué obtienes
|
||||
|
||||
**Nodos dios** — conceptos con mayor grado (por donde todo pasa)
|
||||
|
||||
**Conexiones sorprendentes** — clasificadas por puntuación compuesta. Las aristas código-paper puntúan más alto. Cada resultado incluye un por qué en lenguaje natural.
|
||||
|
||||
**Preguntas sugeridas** — 4-5 preguntas que el grafo está en posición única de responder
|
||||
|
||||
**El «por qué»** — docstrings, comentarios inline (`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`), y justificaciones de diseño extraídas como nodos `rationale_for`.
|
||||
|
||||
**Puntuaciones de confianza** — cada arista INFERRED tiene un `confidence_score` (0,0-1,0).
|
||||
|
||||
**Benchmark de tokens** — impreso automáticamente tras cada ejecución. En un corpus mixto: **71,5 veces** menos tokens por consulta vs archivos sin procesar.
|
||||
|
||||
**Sincronización automática** (`--watch`) — actualiza el grafo automáticamente cuando cambia el código.
|
||||
|
||||
**Hooks de Git** (`graphify hook install`) — instala hooks post-commit y post-checkout.
|
||||
|
||||
## Privacidad
|
||||
|
||||
graphify envía contenido de archivos a la API del modelo de tu asistente IA para extracción semántica de documentos, papers e imágenes. Los archivos de código se procesan localmente mediante tree-sitter AST. Los archivos de video y audio se transcriben localmente con faster-whisper. Sin telemetría, sin seguimiento de uso.
|
||||
|
||||
## Stack técnico
|
||||
|
||||
NetworkX + Leiden (graspologic) + tree-sitter + vis.js. Extracción semántica via Claude, GPT-4 o el modelo de tu plataforma. Transcripción de video via faster-whisper + yt-dlp (opcional).
|
||||
|
||||
## Construido sobre graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) es la capa enterprise sobre graphify. Donde graphify convierte una carpeta de archivos en un grafo de conocimiento, Penpax aplica el mismo grafo a toda tu vida laboral — continuamente.
|
||||
|
||||
**Prueba gratuita próximamente.** [Unirse a la lista de espera →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
## Historial de estrellas
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,572 @@
|
||||
<p align="center">
|
||||
<a href="https://graphify.com"><img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇷 <a href="README.fa-IR.md">فارسی</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a> | 🇵🇭 <a href="README.fil-PH.md">Filipino</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.ycombinator.com/companies/graphify"><img src="https://img.shields.io/badge/Y%20Combinator-S26-F0652F?style=flat&logo=ycombinator&logoColor=white" alt="YC S26"/></a>
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v8" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://img.shields.io/pepy/dt/graphifyy?color=blue&label=downloads" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
<a href="https://x.com/graphifyy"><img src="https://img.shields.io/badge/X-graphifyy-000000?logo=x&logoColor=white" alt="X"/></a>
|
||||
</p>
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**یک مهارت برای دستیار کدنویسی هوش مصنوعی.** `/graphify` را در Claude Code، Codex، OpenCode، Kilo Code، Cursor، Gemini CLI، GitHub Copilot CLI، VS Code Copilot Chat، Aider، Amp، OpenClaw، Factory Droid، Trae، Hermes، Kimi Code، Kiro، Pi، Devin CLI یا Google Antigravity تایپ کنید — تمام پروژهتان را میخواند، یک گراف دانش میسازد، و ساختاری که نمیدانستید وجود دارد را به شما بازمیگرداند. کدبیس را سریعتر درک کنید. «چرا»ی پشت تصمیمات معماری را کشف کنید.
|
||||
|
||||
کاملاً چندوجهی. کد، PDF، مارکداون، اسکرینشات، نمودار، عکس وایتبورد، تصاویر به زبانهای دیگر، یا فایلهای ویدئو و صوتی بریزید — graphify مفاهیم و روابط را از همه آنها استخراج کرده و در یک گراف به هم متصل میکند. ویدئوها بهصورت محلی با Whisper رونویسی میشوند. ۳۶ زبان برنامهنویسی از طریق tree-sitter AST پشتیبانی میشوند.
|
||||
|
||||
> Andrej Karpathy یک پوشه `/raw` نگه میدارد که در آن مقالات، توییتها، اسکرینشاتها و یادداشتها میریزد. graphify پاسخ آن مشکل است — **۷۱.۵ برابر** کمتر توکن در هر پرسوجو در مقایسه با خواندن فایلهای خام، در طول جلسات پایدار، و صادق در مورد آنچه پیدا شده در برابر آنچه استنباط شده است.
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
همین. سه فایل دریافت میکنید:
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html در هر مرورگری باز کنید — روی گرهها کلیک کنید، فیلتر کنید، جستجو کنید
|
||||
├── GRAPH_REPORT.md نکات کلیدی: مفاهیم محوری، اتصالات شگفتانگیز، سؤالات پیشنهادی
|
||||
└── graph.json گراف کامل — هر زمان بدون نیاز به بازخوانی فایلها پرسوجو کنید
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
برای یک صفحه معماری خوانا با نمودارهای جریان فراخوانی Mermaid، اجرا کنید:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
graphify export callflow-html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## پیشنیازها
|
||||
|
||||
</div>
|
||||
|
||||
| پیشنیاز | حداقل | بررسی | نصب |
|
||||
|---|---|---|---|
|
||||
| Python | ۳.۱۰+ | `python --version` | [python.org](https://www.python.org/downloads/) |
|
||||
| uv *(پیشنهادی)* | هر نسخه | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| pipx *(جایگزین)* | هر نسخه | `pipx --version` | `pip install pipx` |
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**نصب سریع macOS (Homebrew):**
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
brew install python@3.12 uv
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**نصب سریع Windows:**
|
||||
|
||||
</div>
|
||||
|
||||
```powershell
|
||||
winget install astral-sh.uv
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
sudo apt install python3.12 python3-pip pipx
|
||||
# یا نصب uv:
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## نصب
|
||||
|
||||
> **بسته رسمی:** بسته PyPI به نام `graphifyy` است (با دو y). سایر بستههای `graphify*` در PyPI وابسته به این پروژه نیستند. دستور CLI همچنان `graphify` است.
|
||||
|
||||
**مرحله ۱ — نصب بسته:**
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
# پیشنهادی (uv بهطور خودکار graphify را در PATH قرار میدهد):
|
||||
uv tool install graphifyy
|
||||
|
||||
# جایگزینها:
|
||||
pipx install graphifyy
|
||||
pip install graphifyy # ممکن است نیاز به تنظیم PATH داشته باشد — یادداشت زیر را ببینید
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**مرحله ۲ — ثبت مهارت در دستیار هوش مصنوعی:**
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
graphify install
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
همین. دستیار هوش مصنوعیتان را باز کنید و `/graphify .` تایپ کنید.
|
||||
|
||||
برای نصب مهارت دستیار در مخزن جاری بهجای پروفایل کاربری، `--project` اضافه کنید:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
graphify install --project
|
||||
graphify install --project --platform codex
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
> **یادداشت PowerShell:** از `graphify .` استفاده کنید نه `/graphify .` — اسلش ابتدایی در PowerShell جداکننده مسیر است.
|
||||
|
||||
> **`graphify: command not found`؟** از `uv tool install graphifyy` یا `pipx install graphifyy` استفاده کنید — هر دو CLI را بهطور خودکار در PATH قرار میدهند.
|
||||
|
||||
### انتخاب پلتفرم
|
||||
|
||||
</div>
|
||||
|
||||
| پلتفرم | دستور نصب |
|
||||
|----------|----------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (تشخیص خودکار) یا `graphify install --platform windows` |
|
||||
| CodeBuddy | `graphify install --platform codebuddy` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| Kilo Code | `graphify install --platform kilo` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kimi Code | `graphify install --platform kimi` |
|
||||
| Amp | `graphify amp install` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Pi coding agent | `graphify install --platform pi` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Devin CLI | `graphify devin install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
### افزونههای اختیاری
|
||||
|
||||
فقط آنچه نیاز دارید نصب کنید:
|
||||
|
||||
</div>
|
||||
|
||||
| افزونه | چه چیزی اضافه میکند | نصب |
|
||||
|---|---|---|
|
||||
| `pdf` | استخراج PDF | `uv tool install "graphifyy[pdf]"` |
|
||||
| `office` | پشتیبانی از `.docx` و `.xlsx` | `uv tool install "graphifyy[office]"` |
|
||||
| `google` | رندرینگ Google Sheets | `uv tool install "graphifyy[google]"` |
|
||||
| `video` | رونویسی ویدئو/صوت | `uv tool install "graphifyy[video]"` |
|
||||
| `mcp` | سرور MCP stdio | `uv tool install "graphifyy[mcp]"` |
|
||||
| `neo4j` | پشتیبانی از Neo4j | `uv tool install "graphifyy[neo4j]"` |
|
||||
| `ollama` | استنتاج محلی Ollama | `uv tool install "graphifyy[ollama]"` |
|
||||
| `openai` | OpenAI / APIهای سازگار با OpenAI | `uv tool install "graphifyy[openai]"` |
|
||||
| `gemini` | Google Gemini API | `uv tool install "graphifyy[gemini]"` |
|
||||
| `anthropic` | Anthropic Claude API | `uv tool install "graphifyy[anthropic]"` |
|
||||
| `bedrock` | AWS Bedrock (از IAM استفاده میکند) | `uv tool install "graphifyy[bedrock]"` |
|
||||
| `sql` | استخراج طرح SQL | `uv tool install "graphifyy[sql]"` |
|
||||
| `all` | همه موارد بالا | `uv tool install "graphifyy[all]"` |
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## دستیار را همیشه از گراف استفاده کنید
|
||||
|
||||
پس از ساخت گراف، یک بار در پروژهتان اجرا کنید:
|
||||
|
||||
</div>
|
||||
|
||||
| پلتفرم | دستور |
|
||||
|----------|---------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| CodeBuddy | `graphify codebuddy install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| Kilo Code | `graphify kilo install` |
|
||||
| GitHub Copilot CLI | `graphify copilot install` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify aider install` |
|
||||
| OpenClaw | `graphify claw install` |
|
||||
| Factory Droid | `graphify droid install` |
|
||||
| Trae | `graphify trae install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Amp | `graphify amp install` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Devin CLI | `graphify devin install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
این یک فایل پیکربندی کوچک مینویسد که به دستیارتان میگوید برای سؤالات مربوط به کدبیس، گراف دانش را مشورت بگیرد. برای حذف graphify از همه پلتفرمها به یکباره: `graphify uninstall` (با `--purge` برای حذف `graphify-out/` نیز).
|
||||
|
||||
---
|
||||
|
||||
## محتوای گزارش
|
||||
|
||||
- **گرههای اصلی** — پرارتباطترین مفاهیم پروژه. همه چیز از اینها عبور میکند.
|
||||
- **اتصالات شگفتانگیز** — پیوندهایی بین چیزهایی که در فایلها یا ماژولهای مختلف زندگی میکنند. بر اساس میزان غیرمنتظره بودن رتبهبندی شده.
|
||||
- **«چرا»** — نظرات درونخطی (`# NOTE:`، `# WHY:`، `# HACK:`)، داکاسترینگها و منطق طراحی از اسناد بهعنوان گرههای جداگانه مرتبط با کدی که توضیح میدهند استخراج میشوند.
|
||||
- **سؤالات پیشنهادی** — ۴ تا ۵ سؤال که گراف بهطور منحصربهفرد میتواند پاسخ دهد.
|
||||
- **برچسبهای اطمینان** — هر رابطه استنباطشده با `EXTRACTED`، `INFERRED` یا `AMBIGUOUS` علامتگذاری میشود. همیشه میدانید چه چیزی پیدا شده در برابر چه چیزی حدس زده شده.
|
||||
|
||||
---
|
||||
|
||||
## فایلهای پشتیبانیشده
|
||||
|
||||
</div>
|
||||
|
||||
| نوع | پسوندها |
|
||||
|------|-----------|
|
||||
| کد (۳۶ دستور زبان tree-sitter) | `.py .ts .js .jsx .tsx .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .vue .svelte .dart` و بیشتر |
|
||||
| اسناد | `.md .mdx .html .txt .rst .yaml .yml` |
|
||||
| Office | `.docx .xlsx` (نیاز به `graphifyy[office]`) |
|
||||
| PDF | `.pdf` |
|
||||
| تصاویر | `.png .jpg .webp .gif` |
|
||||
| ویدئو / صوت | `.mp4 .mov .mp3 .wav` و بیشتر (نیاز به `graphifyy[video]`) |
|
||||
| YouTube / URLها | هر URL ویدئویی (نیاز به `graphifyy[video]`) |
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
کد بهصورت محلی با tree-sitter بدون هیچ فراخوانی API استخراج میشود. بقیه از طریق API مدل دستیار هوش مصنوعی شما پردازش میشوند.
|
||||
|
||||
---
|
||||
|
||||
## دستورات رایج
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
/graphify . # ساخت گراف برای پوشه جاری
|
||||
/graphify ./docs --update # فقط فایلهای تغییریافته را مجدداً استخراج کن
|
||||
/graphify . --cluster-only # خوشهبندی مجدد بدون استخراج مجدد
|
||||
/graphify . --no-viz # فقط گزارش + JSON، بدون HTML
|
||||
/graphify . --wiki # ساخت ویکی مارکداون از گراف
|
||||
graphify export callflow-html # HTML معماری/جریان فراخوانی Mermaid
|
||||
|
||||
/graphify query "چه چیزی auth را به پایگاه داده متصل میکند؟"
|
||||
/graphify path "UserService" "DatabasePool"
|
||||
/graphify explain "RateLimiter"
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # یک مقاله را واکشی و اضافه کن
|
||||
/graphify add <youtube-url> # رونویسی و اضافه کردن ویدئو
|
||||
|
||||
graphify hook install # بازسازی خودکار پس از هر commit
|
||||
graphify merge-graphs a.json b.json # ترکیب دو گراف
|
||||
|
||||
graphify prs # داشبورد PR: وضعیت CI، بررسی، تأثیر گراف
|
||||
graphify prs 42 # بررسی عمیق PR شماره ۴۲
|
||||
graphify prs --triage # رتبهبندی صف بررسی با هوش مصنوعی
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
[مرجع کامل دستورات](#full-command-reference) را ببینید.
|
||||
|
||||
---
|
||||
|
||||
## نادیده گرفتن فایلها
|
||||
|
||||
یک `.graphifyignore` در ریشه پروژه بسازید — همان سینتکس `.gitignore`، از جمله نفی با `!`.
|
||||
|
||||
**`.gitignore` بهطور خودکار رعایت میشود.** اگر `.graphifyignore` در یک دایرکتوری وجود نداشته باشد، graphify به `.gitignore` آن دایرکتوری بازمیگردد.
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
|
||||
# فقط src/ را ایندکس کن، بقیه را نادیده بگیر
|
||||
*
|
||||
!src/
|
||||
!src/**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## تنظیمات تیمی
|
||||
|
||||
`graphify-out/` برای commit شدن در git طراحی شده تا همه در تیم با یک نقشه شروع کنند.
|
||||
|
||||
**افزودنهای پیشنهادی به `.gitignore`:**
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
graphify-out/cost.json # فقط محلی
|
||||
# graphify-out/cache/ # اختیاری: برای سرعت commit کنید، برای کوچک نگه داشتن repo حذف کنید
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**جریان کار:**
|
||||
۱. یک نفر `/graphify .` اجرا میکند و `graphify-out/` را commit میکند.
|
||||
۲. همه pull میکنند — دستیارشان فوراً گراف را میخواند.
|
||||
۳. `graphify hook install` را اجرا کنید تا پس از هر commit بهطور خودکار بازسازی شود.
|
||||
۴. وقتی اسناد یا مقالات تغییر کردند، `/graphify --update` اجرا کنید.
|
||||
|
||||
---
|
||||
|
||||
## استفاده مستقیم از گراف
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
# پرسوجو از گراف در ترمینال
|
||||
graphify query "جریان احراز هویت را نشان بده"
|
||||
graphify query "چه چیزی DigestAuth را به Response متصل میکند؟" --graph graphify-out/graph.json
|
||||
|
||||
# نمایش گراف بهعنوان سرور MCP
|
||||
python -m graphify.serve graphify-out/graph.json
|
||||
|
||||
# یا سرویسدهی از طریق HTTP برای دسترسی تیمی:
|
||||
python -m graphify.serve graphify-out/graph.json --transport http --port 8080
|
||||
python -m graphify.serve graphify-out/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
سرور MCP دسترسی ساختاریافته میدهد: `query_graph`، `get_node`، `get_neighbors`، `shortest_path`، `list_prs`، `get_pr_impact`، `triage_prs`.
|
||||
|
||||
---
|
||||
|
||||
## متغیرهای محیطی
|
||||
|
||||
اینها فقط برای **استخراج بدون نمایشگر / CI** (`graphify extract`) لازم هستند. هنگام اجرا از طریق مهارت `/graphify` داخل IDE، API مدل توسط جلسه IDE شما تأمین میشود.
|
||||
|
||||
</div>
|
||||
|
||||
| متغیر | استفاده | شرط لزوم |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | بکاند Claude (Anthropic) | `--backend claude` |
|
||||
| `GEMINI_API_KEY` یا `GOOGLE_API_KEY` | بکاند Google Gemini | `--backend gemini` |
|
||||
| `OPENAI_API_KEY` | OpenAI یا APIهای سازگار | `--backend openai` |
|
||||
| `DEEPSEEK_API_KEY` | بکاند DeepSeek | `--backend deepseek` |
|
||||
| `MOONSHOT_API_KEY` | بکاند Kimi Code | `--backend kimi` |
|
||||
| `OLLAMA_BASE_URL` | URL استنتاج محلی Ollama | `--backend ollama` |
|
||||
| `AZURE_OPENAI_API_KEY` | بکاند Azure OpenAI | `--backend azure` |
|
||||
| `GRAPHIFY_MAX_WORKERS` | تعداد thread های موازی AST | اختیاری |
|
||||
| `GRAPHIFY_FORCE` | بازسازی اجباری گراف | اختیاری |
|
||||
| `GRAPHIFY_QUERY_LOG_DISABLE` | برای غیرفعال کردن ثبت پرسوجو `1` تنظیم کنید | اختیاری |
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## حریم خصوصی
|
||||
|
||||
- **فایلهای کد** — بهصورت محلی از طریق tree-sitter پردازش میشوند. هیچ چیزی دستگاه شما را ترک نمیکند.
|
||||
- **ویدئو / صوت** — بهصورت محلی با faster-whisper رونویسی میشوند. هیچ چیزی دستگاه شما را ترک نمیکند.
|
||||
- **اسناد، PDFها، تصاویر** — برای استخراج معنایی به API مدل دستیار هوش مصنوعی شما ارسال میشوند.
|
||||
- بدون تلهمتری، بدون ردیابی استفاده، بدون آنالیتیکس.
|
||||
|
||||
---
|
||||
|
||||
## عیبیابی
|
||||
|
||||
**`graphify: command not found` پس از `pip install graphifyy`**
|
||||
pip اسکریپتها را در دایرکتوری bin کاربر نصب میکند که ممکن است در PATH نباشد:
|
||||
- macOS: `~/Library/Python/3.x/bin` را به PATH در `~/.zshrc` اضافه کنید
|
||||
- Linux: `~/.local/bin` را به PATH در `~/.bashrc` اضافه کنید
|
||||
- یا از `uv tool install graphifyy` / `pipx install graphifyy` استفاده کنید.
|
||||
|
||||
**`/graphify .` در PowerShell "path not recognized" نشان میدهد**
|
||||
PowerShell `/` ابتدایی را بهعنوان جداکننده مسیر در نظر میگیرد. از `graphify .` (بدون اسلش) در Windows استفاده کنید.
|
||||
|
||||
**گراف پس از `--update` یا بازسازی گره کمتری دارد**
|
||||
اگر یک refactor فایلها را حذف کرده، گرههای قدیمی باقی میمانند. `--force` (یا `GRAPHIFY_FORCE=1`) را پاس دهید:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
graphify extract . --force
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**استخراج برای اسناد یا PDFها گره/یال خالی برمیگرداند**
|
||||
اسناد، PDFها و تصاویر به یک فراخوانی LLM نیاز دارند. بررسی کنید API key تنظیم شده و بکاند صحیح است:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-... graphify extract ./docs --backend claude
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## مرجع کامل دستورات
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
/graphify # اجرا روی دایرکتوری جاری
|
||||
/graphify ./raw # اجرا روی پوشه مشخص
|
||||
/graphify ./raw --mode deep # استخراج رابطههای عمیقتر
|
||||
/graphify ./raw --update # فقط فایلهای تغییریافته را مجدداً استخراج کن
|
||||
/graphify ./raw --directed # حفظ جهت یالها
|
||||
/graphify ./raw --cluster-only # خوشهبندی مجدد روی گراف موجود
|
||||
/graphify ./raw --no-viz # بدون HTML بصریسازی
|
||||
/graphify ./raw --obsidian # تولید Obsidian vault
|
||||
/graphify ./raw --wiki # ساخت ویکی مارکداون قابل خزیدن برای agent
|
||||
/graphify ./raw --svg # خروجی graph.svg
|
||||
/graphify ./raw --graphml # خروجی برای Gephi / yEd
|
||||
/graphify ./raw --neo4j # تولید cypher.txt برای Neo4j
|
||||
/graphify ./raw --watch # همگامسازی خودکار با تغییر فایلها
|
||||
/graphify ./raw --mcp # شروع سرور MCP stdio
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762
|
||||
/graphify add <video-url>
|
||||
|
||||
/graphify query "چه چیزی attention را به optimizer متصل میکند؟"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify uninstall # حذف از همه پلتفرمها
|
||||
graphify uninstall --purge # همچنین graphify-out/ را حذف کن
|
||||
|
||||
graphify hook install # hook های post-commit + post-checkout
|
||||
graphify hook uninstall
|
||||
graphify hook status
|
||||
|
||||
graphify claude install # CLAUDE.md + PreToolUse hook (Claude Code)
|
||||
graphify codex install # AGENTS.md + PreToolUse hook (Codex)
|
||||
graphify cursor install # .cursor/rules/graphify.mdc (Cursor)
|
||||
graphify gemini install # GEMINI.md + BeforeTool hook (Gemini CLI)
|
||||
graphify amp install # فایل مهارت (Amp)
|
||||
graphify kiro install # .kiro/skills/ + .kiro/steering/ (Kiro)
|
||||
graphify devin install # فایل مهارت + .windsurf/rules/ (Devin CLI)
|
||||
graphify antigravity install # .agents/rules + .agents/workflows (Google Antigravity)
|
||||
|
||||
graphify extract ./docs # استخراج بدون نمایشگر برای CI
|
||||
graphify extract ./docs --backend gemini # بکاند صریح
|
||||
graphify extract ./docs --backend ollama # Ollama محلی - بدون نیاز به API key
|
||||
graphify extract ./docs --backend bedrock # AWS Bedrock از طریق IAM
|
||||
graphify extract --postgres "postgresql://user:pass@host/db" # طرح PostgreSQL زنده
|
||||
|
||||
graphify prs # داشبورد PR
|
||||
graphify prs 42 # بررسی عمیق PR شماره ۴۲
|
||||
graphify prs --triage # رتبهبندی هوش مصنوعی
|
||||
graphify prs --conflicts # PR هایی که جوامع گراف را به اشتراک میگذارند
|
||||
|
||||
graphify export callflow-html # HTML معماری/جریان فراخوانی
|
||||
graphify merge-graphs a.json b.json --out merged.json
|
||||
graphify --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## بیشتر بدانید
|
||||
|
||||
- [نحوه عملکرد](../../docs/how-it-works.md) — پایپلاین استخراج، تشخیص جامعه، امتیازدهی اطمینان، معیارها
|
||||
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — تقسیمبندی ماژولها، نحوه اضافه کردن زبان جدید
|
||||
- [یکپارچهسازیهای اختیاری](../../docs/docker-mcp-sqlite.md) — Docker MCP Toolkit + SQLite
|
||||
|
||||
---
|
||||
|
||||
## ساختهشده روی graphify — Penpax
|
||||
|
||||
[**Penpax**](https://graphify.com) لایه همیشهروشن ساختهشده روی graphify است — همان رویکرد گراف را بر کل زندگی کاری شما اعمال میکند: جلسات، تاریخچه مرورگر، ایمیلها، فایلها و کد، بهصورت مداوم در پسزمینه بهروزرسانی میشود.
|
||||
|
||||
ساختهشده برای کسانی که کارشان در صدها مکالمه و سند پراکنده است. بدون ابر، کاملاً روی دستگاه.
|
||||
|
||||
**آزمایش رایگان بهزودی راهاندازی میشود.** [به لیست انتظار بپیوندید ←](https://graphify.com)
|
||||
|
||||
---
|
||||
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary>مشارکت در پروژه</summary>
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
### راهاندازی محیط توسعه
|
||||
|
||||
این پروژه از [uv](https://docs.astral.sh/uv/) برای جریان کار توسعه استفاده میکند. یک بار آن را نصب کنید، سپس:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/safishamsi/graphify.git
|
||||
cd graphify
|
||||
git checkout v8 # شاخه توسعه فعال
|
||||
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
### اجرای تستها
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
uv run pytest tests/ -q # اجرای کل مجموعه
|
||||
uv run pytest tests/test_extract.py -q # یک ماژول
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
### جریان کار Git
|
||||
|
||||
- توسعه فعال روی شاخه `v8` انجام میشود.
|
||||
- سبک commit: `fix: <توضیح>` / `feat: <توضیح>` / `docs: <توضیح>`
|
||||
- قبل از باز کردن PR، `uv run pytest tests/ -q` اجرا کنید و از موفقیت آن مطمئن شوید.
|
||||
|
||||
### چه چیزی مشارکت کنیم
|
||||
|
||||
**نمونههای کاری** مفیدترین نوع مشارکت هستند. `/graphify` را روی یک corpus واقعی اجرا کنید، خروجی را در `worked/{slug}/` ذخیره کنید، یک `review.md` صادقانه بنویسید که چه چیزهایی درست و نادرست بوده، و یک PR باز کنید.
|
||||
|
||||
**باگهای استخراج** — یک issue با فایل ورودی، آیتم کش (`graphify-out/cache/`) و آنچه اشتباه یا حذف شده باز کنید.
|
||||
|
||||
برای مسئولیتهای ماژول و نحوه اضافه کردن زبان جدید [ARCHITECTURE.md](../../ARCHITECTURE.md) را ببینید.
|
||||
|
||||
</div>
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Taito tekoälykoodiavustajille.** Kirjoita `/graphify` Claude Codessa, Codexissa, OpenCodessa, Cursorissa, Gemini CLI:ssä, GitHub Copilot CLI:ssä, VS Code Copilot Chatissa, Aiderissa, OpenClawissa, Factory Droidissa, Traessa, Hermeksessä, Kirossa tai Google Antigravityssa — se lukee tiedostosi, rakentaa tietograafin ja palauttaa sinulle rakenteen, jota et tiennyt olevan. Ymmärrä koodikanta nopeammin. Löydä arkkitehtuuripäätösten taustalla oleva "miksi".
|
||||
|
||||
Täysin multimodaalinen. Lisää koodia, PDF:iä, markdownia, kuvakaappauksia, kaavioita, liitutaulun valokuvia, muilla kielillä olevia kuvia tai video- ja äänitiedostoja — graphify poimii käsitteitä ja suhteita kaikesta ja yhdistää ne yhdeksi graafaksi. Videot litteroidaan paikallisesti Whisperillä. Tukee 25 ohjelmointikieltä tree-sitter AST:n kautta.
|
||||
|
||||
> Andrej Karpathy ylläpitää `/raw`-kansiota, johon hän tallentaa papereita, tviittejä, kuvakaappauksia ja muistiinpanoja. graphify on vastaus tähän ongelmaan — **71,5x** vähemmän tokeneita kyselyä kohden verrattuna raakatiedostojen lukemiseen, pysyvä istuntojen välillä.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interaktiivinen graafi — avaa missä tahansa selaimessa
|
||||
├── GRAPH_REPORT.md jumalsolmut, yllättävät yhteydet, ehdotetut kysymykset
|
||||
├── graph.json pysyvä graafi — kyselytavissa viikkojen kuluttua
|
||||
└── cache/ SHA256-välimuisti — toistuvat ajot käsittelevät vain muuttuneet tiedostot
|
||||
```
|
||||
|
||||
## Miten se toimii
|
||||
|
||||
graphify toimii kolmessa läpiajossa. Ensin deterministinen AST-läpiajo poimii rakenteen kooditiedostoista ilman LLM:ää. Sitten video- ja äänitiedostot litteroidaan paikallisesti faster-whisperillä. Lopuksi Clauden ala-agentit suoritetaan rinnakkain asiakirjoissa, papereissa, kuvissa ja litteroinneissa. Tulokset yhdistetään NetworkX-graafiin, klusteroidaan Leidenillä ja viedään interaktiivisena HTML:nä, kyselytavissa olevana JSON:na ja tarkastusraporttina.
|
||||
|
||||
Jokainen suhde on merkitty `EXTRACTED`, `INFERRED` (luottamuspisteineen) tai `AMBIGUOUS`.
|
||||
|
||||
## Asennus
|
||||
|
||||
**Vaatimukset:** Python 3.10+ ja jokin seuraavista: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) ja muut.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# tai pipx:llä
|
||||
pipx install graphifyy && graphify install
|
||||
# tai pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Virallinen paketti:** PyPI-paketti on nimeltään `graphifyy`. Ainoa virallinen repositorio on [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Käyttö
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "mikä yhdistää Attentionin optimizeriin?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Mitä saat
|
||||
|
||||
**Jumalsolmut** — korkeimman asteen käsitteet · **Yllättävät yhteydet** — pisteiden mukaan järjestetty · **Ehdotetut kysymykset** · **"Miksi"** — docstringit ja suunnitteluperusteet solmuina · **Token-vertailu** — **71,5x** vähemmän tokeneita sekakorpuksessa.
|
||||
|
||||
## Yksityisyys
|
||||
|
||||
Kooditiedostot käsitellään paikallisesti tree-sitter AST:n kautta. Videot litteroidaan paikallisesti faster-whisperillä. Ei telemetriaa.
|
||||
|
||||
## Rakennettu graphifyn päälle — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) on graphifyn päälle rakennettu yritystaso. **Ilmainen kokeilujakso tulossa pian.** [Liity odotuslistalle →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a> | 🇵🇭 <a href="README.fil-PH.md">Filipino</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Kasanayan para sa mga AI coding assistant.** I-type ang `/graphify` sa Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, o Google Antigravity — binabasa nito ang iyong mga file, gumagawa ng knowledge graph, at ibinabalik sa iyo ang mga istrukturang hindi mo alam na nandoon pala. Maunawaan ang codebase nang mas mabilis. Tuklasin ang "bakit" sa likod ng mga desisyon sa arkitektura.
|
||||
|
||||
Ganap na multimodal. Magdagdag ng code, PDF, markdown, mga screenshot, diagram, larawan ng whiteboard, mga imahe sa ibang wika, o mga video at audio file — kine-extract ng graphify ang mga konsepto at relasyon mula sa lahat ng ito at pinag-uugnay ang mga ito sa iisang graph. Ang mga video ay tina-transcribe nang lokal gamit ang Whisper. Sumusuporta ng 25 na programming language sa pamamagitan ng tree-sitter AST.
|
||||
|
||||
> Si Andrej Karpathy ay nagpapanatili ng `/raw` folder kung saan nilalagay niya ang mga papel, tweet, screenshot, at tala. Ang graphify ang sagot sa problemang iyon — **71.5x** na mas kaunting token bawat query kumpara sa pagbasa ng mga raw file, at persistent sa pagitan ng mga session.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interactive na graph — buksan sa kahit anong browser
|
||||
├── GRAPH_REPORT.md mga god node, nakakagulat na koneksyon, mga iminumungkahing tanong
|
||||
├── graph.json persistent na graph — maaaring i-query kahit pagkalipas ng mga linggo
|
||||
└── cache/ SHA256 cache — ang mga pag-uulit ay nagpo-proseso lang ng mga nabagong file
|
||||
```
|
||||
|
||||
## Paano Gumagana
|
||||
|
||||
Gumagana ang graphify sa tatlong pass. Una, isang deterministikong AST pass ang nag-e-extract ng istruktura mula sa mga code file nang walang LLM. Pagkatapos, ang mga video at audio file ay tina-transcribe nang lokal gamit ang faster-whisper. Panghuli, mga Claude sub-agent ang tumatakbo nang magkakasabay sa mga dokumento, papel, imahe, at transkripsyon. Ang mga resulta ay pinagsasama sa isang NetworkX graph, naka-cluster gamit ang Leiden, at ine-export bilang interactive na HTML, queryable na JSON, at audit report.
|
||||
|
||||
Ang bawat relasyon ay may label na `EXTRACTED`, `INFERRED` (may confidence score), o `AMBIGUOUS`.
|
||||
|
||||
## Pag-install
|
||||
|
||||
**Mga Kinakailangan:** Python 3.10+ at isa sa mga sumusunod: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) at iba pa.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# o gamit ang pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# o pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Opisyal na package:** Ang PyPI package ay pinangalanang `graphifyy`. Ang tanging opisyal na repository ay [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Paggamit
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "ano ang nagkokonekta sa Attention at sa optimizer?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Ano ang Makukuha Mo
|
||||
|
||||
**Mga god node** — mga konsepto na may pinakamataas na degree · **Mga nakakagulat na koneksyon** — naka-rank ayon sa score · **Mga iminumungkahing tanong** · **"Bakit"** — mga docstring at disenyo na rason ay ine-extract bilang mga node · **Token benchmark** — **71.5x** na mas kaunting token sa mixed corpus.
|
||||
|
||||
## Privacy
|
||||
|
||||
Ang mga code file ay prinoseso nang lokal sa pamamagitan ng tree-sitter AST. Ang mga video ay tina-transcribe nang lokal gamit ang faster-whisper. Walang telemetry.
|
||||
|
||||
## Binuo sa ibabaw ng graphify — Penpax
|
||||
|
||||
Ang [**Penpax**](https://graphify.com) ay ang enterprise layer sa ibabaw ng graphify. **Malapit nang magkaroon ng libreng trial.** [Sumali sa waitlist →](https://graphify.com)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,170 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
</p>
|
||||
|
||||
**Une compétence pour assistant de code IA.** Tapez `/graphify` dans Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro ou Google Antigravity — il lit vos fichiers, construit un graphe de connaissances et vous révèle une structure que vous ne voyiez pas auparavant. Comprenez une base de code plus rapidement. Trouvez le « pourquoi » derrière les décisions architecturales.
|
||||
|
||||
Entièrement multimodal. Déposez du code, des PDFs, du markdown, des captures d'écran, des diagrammes, des photos de tableau blanc, des images dans d'autres langues, ou des fichiers vidéo et audio — graphify extrait les concepts et les relations de tout cela et les connecte en un seul graphe. Les vidéos sont transcrites localement avec Whisper grâce à un prompt adapté au domaine. 25 langages de programmation supportés via tree-sitter AST (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart).
|
||||
|
||||
> Andrej Karpathy maintient un dossier `/raw` où il dépose des articles, tweets, captures d'écran et notes. graphify est la réponse à ce problème — 71,5 fois moins de tokens par requête versus la lecture des fichiers bruts, persistant entre les sessions, honnête sur ce qui a été trouvé versus déduit.
|
||||
|
||||
```
|
||||
/graphify . # fonctionne sur n'importe quel dossier — code, notes, articles, tout
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html graphe interactif — ouvrir dans un navigateur, cliquer, rechercher, filtrer
|
||||
├── GRAPH_REPORT.md nœuds dieu, connexions surprenantes, questions suggérées
|
||||
├── graph.json graphe persistant — interrogeable des semaines plus tard sans relire
|
||||
└── cache/ cache SHA256 — les réexécutions ne traitent que les fichiers modifiés
|
||||
```
|
||||
|
||||
Ajoutez un fichier `.graphifyignore` pour exclure des dossiers :
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
Même syntaxe que `.gitignore`. Un seul `.graphifyignore` à la racine du dépôt suffit.
|
||||
|
||||
## Comment ça fonctionne
|
||||
|
||||
graphify s'exécute en trois passes. D'abord, un passage AST déterministe extrait la structure des fichiers de code (classes, fonctions, imports, graphes d'appel, docstrings, commentaires de justification) sans LLM. Ensuite, les fichiers vidéo et audio sont transcrits localement avec faster-whisper. Enfin, des sous-agents Claude s'exécutent en parallèle sur les docs, articles, images et transcriptions pour extraire concepts, relations et justifications de conception. Les résultats sont fusionnés dans un graphe NetworkX, regroupés avec la détection de communautés Leiden, et exportés en HTML interactif, JSON interrogeable et un rapport d'audit en langage naturel.
|
||||
|
||||
**Le clustering est basé sur la topologie du graphe — pas d'embeddings.** Leiden trouve les communautés par densité d'arêtes. Les arêtes de similarité sémantique extraites par Claude (`semantically_similar_to`, marquées INFERRED) sont déjà dans le graphe. La structure du graphe est le signal de similarité — pas d'étape d'embedding séparée ni de base de données vectorielle nécessaire.
|
||||
|
||||
Chaque relation est étiquetée `EXTRACTED` (trouvée directement dans la source), `INFERRED` (déduction raisonnable avec un score de confiance) ou `AMBIGUOUS` (marquée pour révision).
|
||||
|
||||
## Installation
|
||||
|
||||
**Prérequis :** Python 3.10+ et l'un de : [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes ou [Google Antigravity](https://antigravity.google)
|
||||
|
||||
```bash
|
||||
# Recommandé — fonctionne sur Mac et Linux sans configuration du PATH
|
||||
uv tool install graphifyy && graphify install
|
||||
# ou avec pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# ou pip simple
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Package officiel :** Le package PyPI s'appelle `graphifyy` (installer avec `pip install graphifyy`). Les autres packages nommés `graphify*` sur PyPI ne sont pas affiliés à ce projet. Le seul dépôt officiel est [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
### Support des plateformes
|
||||
|
||||
| Plateforme | Commande d'installation |
|
||||
|------------|------------------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (détection automatique) ou `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
Ensuite, ouvrez votre assistant de code IA et tapez :
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
Note : Codex utilise `$` au lieu de `/` pour les compétences, tapez donc `$graphify .`.
|
||||
|
||||
### Toujours utiliser le graphe (recommandé)
|
||||
|
||||
Après avoir construit un graphe, exécutez ceci une fois dans votre projet :
|
||||
|
||||
| Plateforme | Commande |
|
||||
|------------|----------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
## Utilisation
|
||||
|
||||
```
|
||||
/graphify # répertoire courant
|
||||
/graphify ./raw # dossier spécifique
|
||||
/graphify ./raw --mode deep # extraction d'arêtes INFERRED plus agressive
|
||||
/graphify ./raw --update # ne réextraire que les fichiers modifiés
|
||||
/graphify ./raw --directed # graphe dirigé
|
||||
/graphify ./raw --cluster-only # relancer le clustering sur le graphe existant
|
||||
/graphify ./raw --no-viz # pas d'HTML, juste rapport + JSON
|
||||
/graphify ./raw --obsidian # générer un vault Obsidian (opt-in)
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # récupérer un article
|
||||
/graphify add <video-url> # télécharger l'audio, transcrire, ajouter
|
||||
/graphify query "qu'est-ce qui connecte Attention à l'optimiseur ?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify hook install # installer les hooks Git
|
||||
graphify update ./src # réextraire les fichiers de code, sans LLM
|
||||
graphify watch ./src # mise à jour automatique du graphe
|
||||
```
|
||||
|
||||
## Ce que vous obtenez
|
||||
|
||||
**Nœuds dieu** — concepts avec le plus haut degré (tout passe par eux)
|
||||
|
||||
**Connexions surprenantes** — classées par score composite. Les arêtes code-article sont mieux notées. Chaque résultat inclut un pourquoi en langage naturel.
|
||||
|
||||
**Questions suggérées** — 4-5 questions que le graphe est particulièrement bien placé pour répondre
|
||||
|
||||
**Le « pourquoi »** — docstrings, commentaires inline (`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`), et justifications de conception extraits comme nœuds `rationale_for`.
|
||||
|
||||
**Scores de confiance** — chaque arête INFERRED a un `confidence_score` (0,0-1,0).
|
||||
|
||||
**Benchmark de tokens** — affiché automatiquement après chaque exécution. Sur un corpus mixte : **71,5 fois** moins de tokens par requête vs fichiers bruts.
|
||||
|
||||
**Synchronisation automatique** (`--watch`) — met à jour le graphe automatiquement lors des modifications de code.
|
||||
|
||||
**Hooks Git** (`graphify hook install`) — installe des hooks post-commit et post-checkout.
|
||||
|
||||
## Confidentialité
|
||||
|
||||
graphify envoie le contenu des fichiers à l'API du modèle de votre assistant IA pour l'extraction sémantique des docs, articles et images. Les fichiers de code sont traités localement via tree-sitter AST. Les fichiers vidéo et audio sont transcrits localement avec faster-whisper. Aucune télémétrie, aucun suivi d'utilisation.
|
||||
|
||||
## Stack technique
|
||||
|
||||
NetworkX + Leiden (graspologic) + tree-sitter + vis.js. Extraction sémantique via Claude, GPT-4 ou le modèle de votre plateforme. Transcription vidéo via faster-whisper + yt-dlp (optionnel).
|
||||
|
||||
## Construit sur graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) est la couche enterprise au-dessus de graphify. Là où graphify transforme un dossier de fichiers en graphe de connaissances, Penpax applique le même graphe à toute votre vie professionnelle — en continu.
|
||||
|
||||
**Essai gratuit bientôt disponible.** [Rejoindre la liste d'attente →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
## Historique des étoiles
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,849 @@
|
||||
<p align="center">
|
||||
<a href="https://graphify.com"><img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇷 <a href="README.fa-IR.md">فارسی</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a> | 🇵🇭 <a href="README.fil-PH.md">Filipino</a> | 🇮🇱 <a href="README.he-IL.md">עברית</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.ycombinator.com/companies/graphify"><img src="https://img.shields.io/badge/Y%20Combinator-S26-F0652F?style=flat&logo=ycombinator&logoColor=white" alt="YC S26"/></a>
|
||||
<a href="https://discord.gg/598Ad9zQZ"><img src="https://img.shields.io/badge/Discord-Join-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://safishamsi.gumroad.com/l/qetvlo"><img src="https://img.shields.io/badge/Book-The%20Memory%20Layer-2ea44f?style=flat&logo=gitbook&logoColor=white" alt="The Memory Layer"/></a>
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v8" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://img.shields.io/pepy/dt/graphifyy?color=blue&label=downloads" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
<a href="https://x.com/graphifyy"><img src="https://img.shields.io/badge/X-graphifyy-000000?logo=x&logoColor=white" alt="X"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#safishamsi/graphify&Date">
|
||||
<img src="https://api.star-history.com/svg?repos=safishamsi/graphify&type=Date" alt="Star History Chart" width="370"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
הקלידו `/graphify` בעוזר ה-AI לכתיבת קוד שלכם, והוא ימפה את הפרויקט כולו — קוד, מסמכים, קובצי PDF, תמונות, סרטונים — לגרף ידע שאפשר לשאול עליו שאלות, במקום לחפש בקבצים עם grep.
|
||||
|
||||
עובד ב-Claude Code, Codex, OpenCode, Kilo Code, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, Amp, OpenClaw, Factory Droid, Trae, Hermes, Kimi Code, Kiro, Pi, Devin CLI ו-Google Antigravity.
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
זה הכול. מקבלים שלושה קבצים:
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html נפתח בכל דפדפן — לחיצה על צמתים, סינון, חיפוש
|
||||
├── GRAPH_REPORT.md עיקרי הדברים: מושגי מפתח, קשרים מפתיעים, שאלות מוצעות
|
||||
└── graph.json הגרף המלא — אפשר לשאול עליו בכל רגע בלי לקרוא שוב את הקבצים
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
לדף ארכיטקטורה קריא עם דיאגרמות זרימת-קריאות ב-Mermaid, הריצו:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
graphify export callflow-html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## דרישות מקדימות
|
||||
|
||||
| דרישה | מינימום | בדיקה | התקנה |
|
||||
|---|---|---|---|
|
||||
| Python | 3.10+ | `python --version` | [python.org](https://www.python.org/downloads/) |
|
||||
| uv *(מומלץ)* | כל גרסה | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| pipx *(חלופה)* | כל גרסה | `pipx --version` | `pip install pipx` |
|
||||
|
||||
**התקנה מהירה ב-macOS (עם Homebrew):**
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
brew install python@3.12 uv
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**התקנה מהירה ב-Windows:**
|
||||
|
||||
</div>
|
||||
|
||||
```powershell
|
||||
winget install astral-sh.uv
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
sudo apt install python3.12 python3-pip pipx
|
||||
# או התקנת uv:
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
## התקנה
|
||||
|
||||
> **החבילה הרשמית:** חבילת ה-PyPI היא `graphifyy` (עם y כפולה). חבילות `graphify*` אחרות ב-PyPI אינן קשורות לפרויקט. פקודת ה-CLI היא עדיין `graphify`.
|
||||
|
||||
**שלב 1 — התקנת החבילה:**
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
# מומלץ (סביבה מבודדת; אם הפקודה 'graphify' לא נמצאת אחר כך, הריצו: uv tool update-shell):
|
||||
uv tool install graphifyy
|
||||
|
||||
# חלופות:
|
||||
pipx install graphifyy
|
||||
pip install graphifyy # עשוי לדרוש הגדרת PATH — ראו הערה בהמשך
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**שלב 2 — רישום המיומנות (skill) אצל עוזר ה-AI שלכם:**
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
graphify install
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
זהו. פתחו את עוזר ה-AI והקלידו `/graphify .`
|
||||
|
||||
כדי להתקין את המיומנות בתוך המאגר הנוכחי במקום בפרופיל המשתמש, הוסיפו `--project`:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
graphify install --project
|
||||
graphify install --project --platform codex
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
התקנה ברמת הפרויקט כותבת לתוך התיקייה הנוכחית, למשל `.claude/skills/graphify/SKILL.md` או `.agents/skills/graphify/SKILL.md` (בתוספת תיקיית `references/` שהמיומנות טוענת לפי הצורך), ומדפיסה רמז `git add` לקבצים שאפשר לבצע להם commit. פקודות פר-פלטפורמה שתומכות בהתקנה ברמת הפרויקט מקבלות את אותו דגל, למשל `graphify claude install --project` או `graphify codex install --project`.
|
||||
|
||||
> **הערת PowerShell:** השתמשו ב-`graphify .` ולא ב-`/graphify .` — הלוכסן המוביל הוא מפריד נתיבים ב-PowerShell.
|
||||
|
||||
> **`graphify: command not found`?** `uv tool install` / `pipx install` מציבים את פקודת `graphify` בתיקיית הכלים שלהם (`~/.local/bin`). אם המעטפת (shell) לא מוצאת אותה מיד אחרי ההתקנה — נפוץ בהתקנת macOS + zsh טרייה — התיקייה הזו עדיין לא ב-`PATH`: הריצו `uv tool update-shell` (או `pipx ensurepath`) ופתחו טרמינל חדש. עם `pip` רגיל, הוסיפו את `~/.local/bin` (בלינוקס) או `~/Library/Python/3.x/bin` (במק) ל-PATH, או הריצו `python -m graphify`.
|
||||
|
||||
> **מריצים עם `uvx` / `uv tool run` בלי להתקין?** ציינו את שם החבילה, לא את שם הפקודה: `uvx --from graphifyy graphify install`. `uvx graphify …` רגיל נכשל (`No solution found … no versions of graphify`) כי `uv tool run` קורא את המילה הראשונה כשם *חבילה*, והחבילה היא `graphifyy` — פקודת `graphify` נמצאת בתוכה.
|
||||
|
||||
> **הימנעו מ-`pip install` במק/Windows** אם אפשר. המיומנות מאתרת את Python בזמן ריצה מתוך `graphify-out/.graphify_python`; אם הוא מצביע על סביבה שונה מזו שבה `pip` התקין את החבילה, תקבלו `ModuleNotFoundError: No module named 'graphify'`. `uv tool install` ו-`pipx install` מבודדים את החבילה בסביבה משלהם ונמנעים מהבעיה לחלוטין.
|
||||
|
||||
> **הוקים של Git עם uv tool / pipx:** `graphify hook install` מטמיע את נתיב המפרש הנוכחי ישירות בסקריפטי ההוק בזמן ההתקנה, כך שהוק ה-post-commit יופעל כראוי גם בלקוחות Git גרפיים וב-CI שבהם `~/.local/bin` אינו ב-PATH. אם התקנתם מחדש או שדרגתם את graphify, הריצו שוב `graphify hook install` כדי לרענן את הנתיב המוטמע.
|
||||
|
||||
### בחרו את הפלטפורמה שלכם
|
||||
|
||||
| פלטפורמה | פקודת התקנה |
|
||||
|----------|----------------|
|
||||
| Claude Code (לינוקס/מק) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (זיהוי אוטומטי) או `graphify install --platform windows` |
|
||||
| CodeBuddy | `graphify install --platform codebuddy` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| Kilo Code | `graphify install --platform kilo` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kimi Code | `graphify install --platform kimi` |
|
||||
| Amp | `graphify amp install` |
|
||||
| Agent Skills (חוצה-פלטפורמות) | `graphify install --platform agents` (כינוי: `--platform skills`) |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Pi coding agent | `graphify install --platform pi` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Devin CLI | `graphify devin install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
משתמשי Codex צריכים גם `multi_agent = true` תחת `[features]` בקובץ `~/.codex/config.toml` לצורך חילוץ מקבילי. CodeBuddy משתמש באותו מנגנון Agent tool והוק PreToolUse כמו Claude Code. Factory Droid משתמש בכלי `Task` לשיגור תת-סוכנים במקביל. OpenClaw ו-Aider משתמשים בחילוץ טורי (תמיכה בסוכנים מקביליים עדיין מוקדמת בפלטפורמות אלו). Trae משתמש ב-Agent tool לשיגור תת-סוכנים במקביל ו**אינו** תומך בהוקים מסוג PreToolUse — AGENTS.md הוא המנגנון הקבוע שם.
|
||||
|
||||
`--platform agents` (כינוי: `--platform skills`) מכוון למיקומים הגנריים חוצי-הפלטפורמות של [Agent-Skills](https://github.com/anthropics/skills): `~/.agents/skills/` הגלובלי של המשתמש (נקרא על ידי `npx skills` ומסגרות תואמות-מפרט) בהתקנה גלובלית, ו-`./.agents/skills/` בהתקנת פרויקט (`--project`). `graphify install` החשוף נשאר חד-פלטפורמי (Claude Code) בכוונה — השתמשו בפלטפורמת `agents` כשתרצו שהמיומנות תהיה זמינה לכל מסגרת שקוראת `.agents/skills`.
|
||||
|
||||
> Codex משתמש ב-`$graphify` במקום `/graphify`.
|
||||
|
||||
### תוספים אופציונליים
|
||||
|
||||
התקינו רק מה שצריך:
|
||||
|
||||
| תוסף | מה הוא מוסיף | התקנה |
|
||||
|---|---|---|
|
||||
| `pdf` | חילוץ PDF | `uv tool install "graphifyy[pdf]"` |
|
||||
| `office` | תמיכה ב-`.docx` ו-`.xlsx` | `uv tool install "graphifyy[office]"` |
|
||||
| `google` | רינדור Google Sheets | `uv tool install "graphifyy[google]"` |
|
||||
| `video` | תמלול וידאו/אודיו (faster-whisper + yt-dlp) | `uv tool install "graphifyy[video]"` |
|
||||
| `mcp` | שרת MCP stdio | `uv tool install "graphifyy[mcp]"` |
|
||||
| `neo4j` | דחיפה ל-Neo4j | `uv tool install "graphifyy[neo4j]"` |
|
||||
| `falkordb` | דחיפה ל-FalkorDB | `uv tool install "graphifyy[falkordb]"` |
|
||||
| `svg` | ייצוא גרף ל-SVG | `uv tool install "graphifyy[svg]"` |
|
||||
| `leiden` | זיהוי קהילות Leiden (Python < 3.13 בלבד) | `uv tool install "graphifyy[leiden]"` |
|
||||
| `ollama` | הרצה מקומית עם Ollama | `uv tool install "graphifyy[ollama]"` |
|
||||
| `openai` | OpenAI / ממשקי API תואמי-OpenAI | `uv tool install "graphifyy[openai]"` |
|
||||
| `gemini` | Google Gemini API | `uv tool install "graphifyy[gemini]"` |
|
||||
| `anthropic` | Anthropic Claude API (`--backend claude`, משתמש ב-`ANTHROPIC_API_KEY`) | `uv tool install "graphifyy[anthropic]"` |
|
||||
| `bedrock` | AWS Bedrock (משתמש ב-IAM, ללא מפתח API) | `uv tool install "graphifyy[bedrock]"` |
|
||||
| `azure` | Azure OpenAI Service (`--backend azure`, משתמש ב-`AZURE_OPENAI_API_KEY` + `AZURE_OPENAI_ENDPOINT`) | `uv tool install "graphifyy[openai]"` |
|
||||
| `sql` | חילוץ סכמות SQL | `uv tool install "graphifyy[sql]"` |
|
||||
| `postgres` | אינטרוספקציה של PostgreSQL חי (`--postgres DSN`) | `uv tool install "graphifyy[postgres]"` |
|
||||
| `dm` | חילוץ AST של BYOND DreamMaker `.dm`/`.dme` (עשוי לדרוש קומפיילר C + `python3-dev` אם אין wheel מתאים לפלטפורמה) | `uv tool install "graphifyy[dm]"` |
|
||||
| `terraform` | חילוץ AST של Terraform / HCL `.tf`/`.tfvars`/`.hcl` | `uv tool install "graphifyy[terraform]"` |
|
||||
| `chinese` | פילוח שאילתות בסינית (jieba) | `uv tool install "graphifyy[chinese]"` |
|
||||
| `all` | כל מה שלמעלה | `uv tool install "graphifyy[all]"` |
|
||||
|
||||
---
|
||||
|
||||
## גרמו לעוזר שלכם להשתמש בגרף תמיד
|
||||
|
||||
הריצו פעם אחת בפרויקט אחרי בניית גרף:
|
||||
|
||||
| פלטפורמה | פקודה |
|
||||
|----------|---------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| CodeBuddy | `graphify codebuddy install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| Kilo Code | `graphify kilo install` |
|
||||
| GitHub Copilot CLI | `graphify copilot install` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify aider install` |
|
||||
| OpenClaw | `graphify claw install` |
|
||||
| Factory Droid | `graphify droid install` |
|
||||
| Trae | `graphify trae install` |
|
||||
| Trae CN | `graphify trae-cn install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Hermes | `graphify hermes install` |
|
||||
| Kimi Code | `graphify install --platform kimi` |
|
||||
| Amp | `graphify amp install` |
|
||||
| Agent Skills (חוצה-פלטפורמות) | `graphify agents install` (כינוי: `graphify skills install`) |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Pi coding agent | `graphify pi install` |
|
||||
| Devin CLI | `graphify devin install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
הפקודה כותבת קובץ תצורה קטן שמנחה את העוזר שלכם להתייעץ עם גרף הידע בשאלות על בסיס הקוד — ולהעדיף שאילתות ממוקדות כמו `graphify query "<שאלה>"` על פני קריאת הדוח המלא או grep על קבצים גולמיים. בפלטפורמות שתומכות בהוקים נושאי-מטען (Claude Code, Gemini CLI), הוק מופעל אוטומטית לפני קריאות כלי בסגנון חיפוש (וב-Claude Code גם לפני קריאת קובצי מקור אחד-אחד דרך הכלים Read/Glob) ומכוון את העוזר לנתיב הגרף. באחרות (Codex, OpenCode, Cursor וכו'), קובצי ההנחיות הקבועים (`AGENTS.md`, `.cursor/rules/` וכו') מספקים את אותה הנחיית "קודם הגרף". `GRAPH_REPORT.md` עדיין זמין לסקירת ארכיטקטורה רחבה.
|
||||
|
||||
**CodeBuddy** עושה את אותם שני דברים כמו Claude Code: כותב קטע `CODEBUDDY.md` שמנחה את CodeBuddy לקרוא את `graphify-out/GRAPH_REPORT.md` לפני מענה על שאלות ארכיטקטורה, ומתקין **הוקים מסוג PreToolUse** (`.codebuddy/settings.json`) שמופעלים לפני פקודות חיפוש ב-Bash וקריאת קבצים, ומכוונים ל-`graphify query` במקום.
|
||||
|
||||
**Codex** כותב ל-`AGENTS.md` וגם מתקין **הוק PreToolUse** ב-`.codex/hooks.json` שמופעל לפני כל קריאת כלי Bash — אותו מנגנון קבוע כמו ב-Claude Code.
|
||||
|
||||
להסרת graphify מכל הפלטפורמות בבת אחת: `graphify uninstall` (הוסיפו `--purge` כדי למחוק גם את `graphify-out/`). או השתמשו בפקודה הפר-פלטפורמית (למשל `graphify claude uninstall`).
|
||||
|
||||
---
|
||||
|
||||
**Kilo Code** מתקין את מיומנות Graphify ל-`~/.config/kilo/skills/graphify/SKILL.md` ופקודת `/graphify` נטיבית ל-`~/.config/kilo/command/graphify.md`. `graphify kilo install` כותב גם `AGENTS.md` וגם **תוסף `tool.execute.before` נטיבי** (`.kilo/plugins/graphify.js` + רישום ב-`.kilo/kilo.json` או `.kilo/kilo.jsonc`) כך ש-Kilo מקבל את אותה התנהגות תזכורת-גרף קבועה דרך תצורת `.kilo` נטיבית.
|
||||
|
||||
**Cursor** כותב `.cursor/rules/graphify.mdc` עם `alwaysApply: true` — Cursor מכליל אותו בכל שיחה אוטומטית, ללא צורך בהוק.
|
||||
|
||||
## מה יש בדוח
|
||||
|
||||
- **צומתי מפתח (God nodes)** — המושגים המקושרים ביותר בפרויקט. הכול עובר דרכם.
|
||||
- **קשרים מפתיעים** — קישורים בין דברים שחיים בקבצים או מודולים שונים. מדורגים לפי מידת ההפתעה.
|
||||
- **ה"למה"** — הערות בקוד (`# NOTE:`, `# WHY:`, `# HACK:`), docstrings ורציונל עיצובי מהמסמכים מחולצים כצמתים נפרדים המקושרים לקוד שהם מסבירים.
|
||||
- **שאלות מוצעות** — 4–5 שאלות שהגרף נמצא בעמדה ייחודית לענות עליהן.
|
||||
- **תגי ביטחון** — כל קשר מוסק מסומן `EXTRACTED`, `INFERRED` או `AMBIGUOUS`. תמיד יודעים מה נמצא ומה נוחש.
|
||||
|
||||
---
|
||||
|
||||
## אילו קבצים הוא מטפל
|
||||
|
||||
| סוג | סיומות |
|
||||
|------|-----------|
|
||||
| קוד (36 דקדוקי tree-sitter) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .cu .cuh .metal .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .psm1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` דורש `uv tool install graphifyy[dm]`; CUDA `.cu`/`.cuh` ו-Metal `.metal` משתמשים בדקדוק C++) |
|
||||
| Salesforce Apex | `.cls .trigger` (מבוסס regex; מחלקות, ממשקים, enums, מתודות, טריגרים, קשתות SOQL/DML) |
|
||||
| Terraform / HCL | `.tf .tfvars .hcl` (דורש `uv tool install graphifyy[terraform]`) |
|
||||
| תצורות MCP | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — מחלץ צומתי שרתים, הפניות לחבילות, דרישות משתני סביבה |
|
||||
| מניפסטים של חבילות | `apm.yml` `pyproject.toml` `go.mod` `pom.xml` — צומת חבילה קנוני אחד לכל חבילה (לפי שם) בתוספת קשתות `depends_on`, כך שחבילה שמופנית מכמה מניפסטים היא מוקד (hub) יחיד |
|
||||
| מסמכים | `.md .mdx .qmd .html .txt .rst .yaml .yml` (קישורי markdown `[text](./other.md)` ו-`[[wikilinks]]` הופכים לקשתות `references` בין מסמכים) |
|
||||
| Office | `.docx .xlsx` (דורש `uv tool install graphifyy[office]`) |
|
||||
| Google Workspace | `.gdoc .gsheet .gslides` (opt-in; דורש אימות `gws` ו-`--google-workspace`; Sheets דורש `uv tool install graphifyy[google]`) |
|
||||
| PDF | `.pdf` |
|
||||
| תמונות | `.png .jpg .webp .gif` |
|
||||
| וידאו / אודיו | `.mp4 .mov .mp3 .wav` ועוד (דורש `uv tool install graphifyy[video]`) |
|
||||
| YouTube / כתובות URL | כל כתובת וידאו (דורש `uv tool install graphifyy[video]`) |
|
||||
|
||||
קוד מחולץ מקומית ללא קריאות API (AST באמצעות tree-sitter). כל השאר עובר דרך ה-API של מודל עוזר ה-AI שלכם.
|
||||
|
||||
קובצי `.gdoc`, `.gsheet` ו-`.gslides` של Google Drive לשולחן העבודה הם קיצורי דרך, לא תוכן מסמכים. כדי לכלול מסמכי Google Docs, Sheets ו-Slides נטיביים בחילוץ headless, התקינו ואמתו את [`gws` CLI](https://github.com/googleworkspace/cli), ואז הריצו:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
uv tool install "graphifyy[google]" # נדרש לרינדור טבלאות Google Sheets
|
||||
gws auth login -s drive
|
||||
graphify extract ./docs --google-workspace
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
אפשר גם להגדיר `GRAPHIFY_GOOGLE_WORKSPACE=1`. graphify מייצא קיצורי דרך אל `graphify-out/converted/` כקובצי Markdown נלווים, ואז מחלץ אותם.
|
||||
|
||||
---
|
||||
|
||||
## פקודות נפוצות
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
/graphify . # בניית גרף לתיקייה הנוכחית
|
||||
/graphify ./docs --update # חילוץ מחדש של קבצים שהשתנו בלבד
|
||||
/graphify . --cluster-only # הרצת אשכול מחדש בלי לחלץ מחדש
|
||||
/graphify . --cluster-only --resolution 1.5 # קהילות מפורטות יותר
|
||||
/graphify . --cluster-only --exclude-hubs 99 # הסתרת מוקדי-עזר מדירוג צומתי המפתח
|
||||
/graphify . --no-viz # דילוג על ה-HTML, רק הדוח וה-JSON
|
||||
/graphify . --wiki # בניית ויקי markdown מהגרף
|
||||
graphify export callflow-html # HTML ארכיטקטורה/זרימת-קריאות ב-Mermaid (מתחדש אוטומטית בכל commit אם ההוק מותקן)
|
||||
|
||||
/graphify query "מה מחבר את האימות למסד הנתונים?"
|
||||
/graphify path "UserService" "DatabasePool"
|
||||
/graphify explain "RateLimiter"
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # הבאת מאמר והוספתו
|
||||
/graphify add <youtube-url> # תמלול והוספת סרטון
|
||||
|
||||
graphify hook install # בנייה מחדש אוטומטית בכל commit
|
||||
graphify merge-graphs a.json b.json # מיזוג שני גרפים
|
||||
|
||||
graphify prs # לוח PR: מצב CI, סטטוס ביקורת, מיפוי worktree
|
||||
graphify prs 42 # צלילה עמוקה ל-PR #42 עם השפעת גרף
|
||||
graphify prs --triage # AI מדרג את תור הביקורות שלכם (משתמש ב-backend המוגדר)
|
||||
graphify prs --conflicts # PRs שחולקים קהילות גרף — סיכון בסדר המיזוג
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
ראו את [רשימת הפקודות המלאה](#רשימת-הפקודות-המלאה) בהמשך.
|
||||
|
||||
---
|
||||
|
||||
## התעלמות מקבצים
|
||||
|
||||
צרו קובץ `.graphifyignore` בשורש הפרויקט — אותו תחביר כמו `.gitignore`, כולל שלילה עם `!`.
|
||||
|
||||
**`.gitignore` נאכף אוטומטית.** graphify קורא את ה-`.gitignore` בכל תיקייה. אם קיים גם `.graphifyignore`, השניים **ממוזגים** — תבניות `.graphifyignore` מוערכות אחרונות, כך שהן גוברות במקרה של התנגשות (כולל שלילות `!`). הוספת `.graphifyignore` רק מחריגה עוד; היא לעולם לא תחזיר קובץ שה-`.gitignore` כבר החריג. תחולת תת-תיקיות עובדת כמו ב-git — קובץ ignore משפיע רק על תת-העץ שלו.
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
|
||||
# לאנדקס רק את src/, להתעלם מכל השאר
|
||||
*
|
||||
!src/
|
||||
!src/**
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
---
|
||||
|
||||
## עבודת צוות
|
||||
|
||||
`graphify-out/` מיועד להיכנס ל-git כדי שכל חברי הצוות יתחילו עם מפה מוכנה.
|
||||
|
||||
**תוספות מומלצות ל-`.gitignore`:**
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
graphify-out/cost.json # מקומי בלבד
|
||||
# graphify-out/cache/ # אופציונלי: commit למהירות, דילוג לשמירה על ריפו קטן
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
> `manifest.json` הוא כעת נייד — המפתחות נשמרים כנתיבים יחסיים ומעוגנים מחדש בטעינה, כך שבטוח לבצע לו commit והדבר חוסך בנייה מלאה מחדש ב-checkout הראשון.
|
||||
|
||||
**תהליך העבודה:**
|
||||
1. אחד מחברי הצוות מריץ `/graphify .` ומבצע commit ל-`graphify-out/`.
|
||||
2. כולם מושכים — העוזר שלהם קורא את הגרף מיד.
|
||||
3. הריצו `graphify hook install` לבנייה אוטומטית מחדש אחרי כל commit (AST בלבד, ללא עלות API). זה גם מגדיר merge driver של git כך ש-`graph.json` לעולם לא יישאר עם סימוני קונפליקט — שני מפתחים שמבצעים commit במקביל מקבלים מיזוג-איחוד אוטומטי של הגרפים.
|
||||
4. כשמסמכים או מאמרים משתנים, הריצו `/graphify --update` לרענון הצמתים הללו.
|
||||
|
||||
---
|
||||
|
||||
## שימוש ישיר בגרף
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
# שאילתת גרף מהטרמינל
|
||||
graphify query "הצג את זרימת האימות"
|
||||
graphify query "מה מחבר בין DigestAuth ל-Response?" --graph graphify-out/graph.json
|
||||
|
||||
# חשיפת הגרף כשרת MCP (לגישת כלים חוזרת)
|
||||
python -m graphify.serve graphify-out/graph.json
|
||||
python -m graphify.serve --graph graphify-out/graph.json # גם הדגל --graph מתקבל
|
||||
|
||||
# רישום ב-Kimi Code:
|
||||
kimi mcp add --transport stdio graphify -- python -m graphify.serve graphify-out/graph.json
|
||||
|
||||
# או הגשה על HTTP כך שכל הצוות מצביע על URL אחד (בלי graphify מקומי):
|
||||
python -m graphify.serve graphify-out/graph.json --transport http --port 8080
|
||||
python -m graphify.serve graphify-out/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
שרת ה-MCP נותן לעוזר שלכם גישה מובנית: `query_graph`, `get_node`, `get_neighbors`, `shortest_path`, `list_prs`, `get_pr_impact`, `triage_prs`.
|
||||
|
||||
### שרת HTTP משותף
|
||||
|
||||
`--transport stdio` (ברירת המחדל) מריץ שרת מקומי אחד לכל מפתח. `--transport http` מגיש את אותם כלים על גבי MCP Streamable HTTP, כך שתהליך משותף אחד יכול לשרת את הגרף לכל הצוות — הלקוחות מכוונים את תצורת ה-MCP של ה-IDE אל `http://<host>:8080/mcp` במקום להריץ graphify מקומית.
|
||||
|
||||
| דגל | ברירת מחדל | תפקיד |
|
||||
|---|---|---|
|
||||
| `--transport {stdio,http}` | `stdio` | סוג התעבורה |
|
||||
| `--host` | `127.0.0.1` | כתובת ההאזנה ל-HTTP (השתמשו ב-`0.0.0.0` לחשיפה מעבר ל-localhost) |
|
||||
| `--port` | `8080` | פורט ההאזנה |
|
||||
| `--api-key` | משתנה סביבה `GRAPHIFY_API_KEY` | דרישת `Authorization: Bearer <key>` (או `X-API-Key`) |
|
||||
| `--path` | `/mcp` | נתיב העיגון ב-HTTP |
|
||||
| `--json-response` | כבוי | החזרת JSON רגיל במקום זרמי SSE |
|
||||
| `--stateless` | כבוי | ללא מצב פר-סשן (לפריסות מאוזנות-עומס / CI) |
|
||||
| `--session-timeout` | `3600` | ניקוי סשנים לא פעילים אחרי N שניות (`0` מבטל) |
|
||||
|
||||
ברירת המחדל `127.0.0.1` היא loopback בלבד. הגדירו `--host 0.0.0.0` **וגם** `--api-key` יחד כשחושפים על מארח משותף. אפשר להריץ בקונטיינר:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
docker build -t graphify .
|
||||
docker run -p 8080:8080 -v "$(pwd)/graphify-out:/data" graphify \
|
||||
/data/graph.json --transport http --host 0.0.0.0 --api-key "$SECRET"
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
> **הערת WSL / לינוקס:** אובונטו מגיעה עם `python3`, לא `python`. השתמשו ב-venv כדי להימנע מהתנגשויות:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
python3 -m venv .venv && .venv/bin/pip install "graphifyy[mcp]"
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
---
|
||||
|
||||
## משתני סביבה
|
||||
|
||||
אלו נדרשים רק ל**חילוץ headless / CI** (`graphify extract`). בהרצה דרך מיומנות `/graphify` בתוך ה-IDE, ה-API של המודל מסופק על ידי סשן ה-IDE — לא נדרשים מפתחות נוספים.
|
||||
|
||||
| משתנה | משמש עבור | מתי נדרש |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | backend של Claude (Anthropic) | `--backend claude` |
|
||||
| `ANTHROPIC_BASE_URL` | כתובת endpoint תואם-Anthropic (פרוקסי LiteLLM, שערים, ...) | `--backend claude` (ברירת מחדל: `https://api.anthropic.com`) |
|
||||
| `ANTHROPIC_MODEL` | שם המודל ל-backend של Claude — ל-endpoints מותאמים, השתמשו בשם/כינוי שהשרת שלכם חושף | `--backend claude` (ברירת מחדל: `claude-sonnet-4-6`) |
|
||||
| `GEMINI_API_KEY` או `GOOGLE_API_KEY` | backend של Google Gemini | `--backend gemini` |
|
||||
| `OPENAI_API_KEY` | OpenAI או ממשקים תואמי-OpenAI | `--backend openai` (שרתים מקומיים מקבלים כל ערך לא ריק) |
|
||||
| `OPENAI_BASE_URL` | כתובת שרת תואם-OpenAI (llama.cpp, vLLM, LM Studio, ...) | `--backend openai` (ברירת מחדל: `https://api.openai.com/v1`) |
|
||||
| `OPENAI_MODEL` | שם המודל ל-backend של OpenAI — לשרתים בהרצה עצמית, השתמשו בשם/כינוי שהשרת חושף (בדקו את endpoint ה-`/v1/models` שלו) | `--backend openai` (ברירת מחדל: `gpt-4.1-mini`) |
|
||||
| `DEEPSEEK_API_KEY` | backend של DeepSeek | `--backend deepseek` |
|
||||
| `MOONSHOT_API_KEY` | backend של Kimi Code | `--backend kimi` |
|
||||
| `OLLAMA_BASE_URL` | כתובת הרצה מקומית של Ollama | `--backend ollama` (ברירת מחדל: `http://localhost:11434`) |
|
||||
| `OLLAMA_MODEL` | שם מודל Ollama | `--backend ollama` (ברירת מחדל: זיהוי אוטומטי) |
|
||||
| `GRAPHIFY_OLLAMA_NUM_CTX` | דריסת גודל חלון ה-KV-cache של Ollama | אופציונלי — מותאם אוטומטית כברירת מחדל |
|
||||
| `GRAPHIFY_OLLAMA_KEEP_ALIVE` | דקות להשארת מודל Ollama טעון | אופציונלי — `0` לפריקה אחרי כל chunk |
|
||||
| `AZURE_OPENAI_API_KEY` | backend של Azure OpenAI Service | `--backend azure` |
|
||||
| `AZURE_OPENAI_ENDPOINT` | כתובת ה-endpoint של משאב Azure | `--backend azure` (נדרש יחד עם מפתח ה-API) |
|
||||
| `AZURE_OPENAI_API_VERSION` | דריסת גרסת ה-API של Azure | אופציונלי — ברירת מחדל `2024-12-01-preview` |
|
||||
| `AZURE_OPENAI_DEPLOYMENT` או `GRAPHIFY_AZURE_MODEL` | שם הפריסה ב-Azure | אופציונלי — ברירת מחדל `gpt-4o` |
|
||||
| `AWS_*` / `~/.aws/credentials` | AWS Bedrock — שרשרת אישורים סטנדרטית | `--backend bedrock` (ללא מפתח API, משתמש ב-IAM) |
|
||||
| `GRAPHIFY_MAX_WORKERS` | מספר threads למקביליות AST | אופציונלי — גם דגל `--max-workers` |
|
||||
| `GRAPHIFY_MAX_OUTPUT_TOKENS` | העלאת תקרת הפלט לקורפוסים צפופים | אופציונלי — למשל `32768` לקבצים גדולים |
|
||||
| `GRAPHIFY_API_TIMEOUT` | timeout לקריאה בשניות עבור HTTP, claude-cli ו-Anthropic SDK (ברירת מחדל: 600) | אופציונלי — גם דגל `--api-timeout` |
|
||||
| `GRAPHIFY_MAX_RETRIES` | מספר ניסיונות חוזרים לבקשה שנחסמה בקצב (429) לפני ויתור (ברירת מחדל: 6; מכבד `Retry-After`) | אופציונלי — העלו למגבלות ארגוניות נוקשות; `0` מבטל |
|
||||
| `GRAPHIFY_FORCE` | כפיית בנייה מחדש של הגרף גם עם פחות צמתים | אופציונלי — גם דגל `--force` |
|
||||
| `GRAPHIFY_GOOGLE_WORKSPACE` | הפעלה אוטומטית של ייצוא Google Workspace | אופציונלי — הגדירו `1` |
|
||||
| `GRAPHIFY_TRIAGE_BACKEND` | backend עבור `graphify prs --triage` | אופציונלי — מזוהה אוטומטית מהמפתחות הזמינים |
|
||||
| `GRAPHIFY_TRIAGE_MODEL` | דריסת מודל לטריאז' | אופציונלי — למשל `claude-opus-4-7` |
|
||||
| `GRAPHIFY_QUERY_LOG` | דריסת נתיב יומן השאילתות (ברירת מחדל: `~/.cache/graphify-queries.log`) | אופציונלי — ערך ריק או `/dev/null` להשתקה |
|
||||
| `GRAPHIFY_QUERY_LOG_DISABLE` | הגדירו `1` לביטול מוחלט של יומן השאילתות | אופציונלי |
|
||||
| `GRAPHIFY_QUERY_LOG_RESPONSES` | הגדירו `1` לרישום גם של תשובות תת-גרף מלאות (כבוי כברירת מחדל) | אופציונלי |
|
||||
| `GRAPHIFY_MAX_GRAPH_BYTES` | דריסת תקרת הגודל של graph.json (512 MiB) — למשל `700MB`, `2GB` או בייטים | אופציונלי — שימושי לקורפוסים גדולים מאוד |
|
||||
| `GRAPHIFY_LLM_TEMPERATURE` | דריסת טמפרטורת ה-LLM לחילוץ סמנטי — למשל `0.7`, או `none` להשמטה | אופציונלי — מושמט אוטומטית למודלי היסק o1/o3/o4/gpt-5 |
|
||||
|
||||
---
|
||||
|
||||
## פרטיות
|
||||
|
||||
- **קובצי קוד** — מעובדים מקומית עם tree-sitter. שום דבר לא עוזב את המחשב. קורפוס של קוד בלבד אינו דורש מפתח API — `graphify extract` רץ לגמרי offline.
|
||||
- **וידאו / אודיו** — מתומלל מקומית עם faster-whisper. שום דבר לא עוזב את המחשב.
|
||||
- **מסמכים, PDF, תמונות** — נשלחים לעוזר ה-AI שלכם לחילוץ סמנטי (דרך מיומנות `/graphify`, עם המודל שסשן ה-IDE שלכם מריץ). `graphify extract` בחילוץ headless דורש `GEMINI_API_KEY` / `GOOGLE_API_KEY` (Gemini), `MOONSHOT_API_KEY` (Kimi), `ANTHROPIC_API_KEY` (Claude), `OPENAI_API_KEY` (OpenAI), `DEEPSEEK_API_KEY` (DeepSeek), מופע Ollama רץ (`OLLAMA_BASE_URL`), אישורי AWS דרך שרשרת הספקים הסטנדרטית (Bedrock — ללא מפתח API, משתמש ב-IAM), או קובץ ההרצה `claude` (Claude Code — ללא מפתח API, משתמש במנוי Claude שלכם). הדגל `--dedup-llm` משתמש באותו מפתח.
|
||||
- **מיקום נתונים (Data residency)** — `graphify extract` מזהה אוטומטית באיזה ספק להשתמש לפי המפתח שמוגדר (עדיפות: Gemini → Kimi → Claude → OpenAI → DeepSeek → Azure → Bedrock → Ollama). לקוד עם דרישות מיקום נתונים, השתמשו ב-`--backend ollama` (מקומי לחלוטין) או העבירו דגל `--backend` מפורש. Kimi (`MOONSHOT_API_KEY`) מנתב לשרתי Moonshot AI בסין.
|
||||
- ללא טלמטריה, ללא מעקב שימוש, ללא אנליטיקה.
|
||||
- **יומן שאילתות** — כל קריאת `graphify query`, `graphify path`, `graphify explain` ו-`query_graph` של MCP נרשמת ל-`~/.cache/graphify-queries.log` בפורמט JSON Lines (חותמת זמן, שאלה, קורפוס, צמתים שהוחזרו, משך). תשובות תת-גרף מלאות **אינן** נשמרות כברירת מחדל. הגדירו `GRAPHIFY_QUERY_LOG_DISABLE=1` לביטול, או `GRAPHIFY_QUERY_LOG=/dev/null` להשתקה בלי לבטל את המנגנון.
|
||||
|
||||
---
|
||||
|
||||
## פתרון בעיות
|
||||
|
||||
**`graphify: command not found` אחרי ההתקנה**
|
||||
ה-CLI מותקן אבל תיקיית ה-bin שלו אינה ב-`PATH` של המעטפת. בחרו את התיקון לפי אופן ההתקנה:
|
||||
- **uv** (`uv tool install graphifyy`): הפקודה מגיעה לתיקיית הכלים של uv (`~/.local/bin`), שהתקנת macOS/zsh טרייה לרוב לא כוללת ב-`PATH`. הריצו `uv tool update-shell` ופתחו טרמינל חדש. (מצאו את התיקייה עם `uv tool dir --bin`.)
|
||||
- **pipx** (`pipx install graphifyy`): הריצו `pipx ensurepath` ופתחו טרמינל חדש.
|
||||
- **pip** (`pip install graphifyy`): pip מתקין סקריפטים לתיקיית bin של המשתמש שאולי אינה ב-`PATH` — הוסיפו את `~/Library/Python/3.x/bin` (macOS) או `~/.local/bin` (לינוקס) ל-`PATH` ב-`~/.zshrc`/`~/.bashrc`, או פשוט הריצו `python -m graphify`.
|
||||
|
||||
**`uvx graphify …` או `uv tool run graphify …` לא מצליחים לפתור את `graphify`**
|
||||
חבילת ה-PyPI היא `graphifyy`; `graphify` הוא רק הפקודה שהיא מספקת. `uv tool run` מתייחס למילה הראשונה כשם *חבילה*, מחפש חבילה בשם `graphify` ומדווח `No solution found … no versions of graphify`. ציינו את החבילה במפורש: `uvx --from graphifyy graphify install` (זהה ל-`uv tool run --from graphifyy graphify install`). או התקינו פעם אחת עם `uv tool install graphifyy` וקראו ל-`graphify` ישירות.
|
||||
|
||||
**`python -m graphify` עובד אבל פקודת `graphify` לא**
|
||||
ה-`PATH` של המעטפת לא כולל את תיקיית ה-bin שאליה הותקנה הפקודה. העדיפו `uv tool install` / `pipx install` על פני `pip` רגיל, ואז הריצו `uv tool update-shell` / `pipx ensurepath` ופתחו טרמינל חדש (ראו הערות ההתקנה לעיל).
|
||||
|
||||
**`/graphify .` גורם ל-"path not recognized" ב-PowerShell**
|
||||
PowerShell מתייחס ל-`/` מוביל כמפריד נתיבים. השתמשו ב-`graphify .` (בלי לוכסן) ב-Windows.
|
||||
|
||||
**לגרף יש פחות צמתים אחרי `--update` או בנייה מחדש**
|
||||
אם refactor מחק קבצים, הצמתים הישנים נשארים. העבירו `--force` (או הגדירו `GRAPHIFY_FORCE=1`) לדריסה גם כשהבנייה החדשה קטנה יותר.
|
||||
|
||||
**לגרף יש צמתים כפולים לאותה ישות (ghost duplicates)**
|
||||
כפילויות רפאים (אותו סימבול מופיע פעמיים — פעם מחילוץ AST עם מיקום מקור, ופעם מחילוץ סמנטי בלעדיו) ממוזגות כעת אוטומטית בזמן הבנייה. אם אתם רואים זאת בגרף שנבנה לפני v0.8.33, הריצו חילוץ מלא מחדש לניקוי:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
graphify extract . --force
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**Ollama נגמר לו ה-VRAM / חריגה מחלון ההקשר**
|
||||
חלון ה-KV-cache מותאם אוטומטית אך עשוי להיות גדול מדי ל-GPU שלכם. הקטינו אותו:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
GRAPHIFY_OLLAMA_NUM_CTX=8192 graphify extract ./docs --backend ollama --token-budget 4000
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**אזהרות `LLM returned invalid JSON` / `Unterminated string`**
|
||||
תשובת ה-JSON של המודל פגעה במגבלת אסימוני הפלט ונחתכה באמצע מחרוזת. graphify מתאושש אוטומטית (הוא מפצל את ה-chunk ומחלץ מחדש את החצאים, ומסמך יחיד גדול מדי נחתך תחילה בגבולות כותרות/פסקאות כך שהקובץ כולו עדיין מכוסה), כך שהאזהרות רועשות אך אין אובדן נתונים. להפחתת התופעה, העלו את תקרת הפלט או הקטינו את פלט ה-chunk:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
GRAPHIFY_MAX_OUTPUT_TOKENS=16384 graphify extract . --mode deep # הרמת התקרה
|
||||
graphify extract . --mode deep --token-budget 4000 # קלטים קטנים יותר -> פלט קטן יותר
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
עם שער ענן כמו OpenRouter, העדיפו `--backend openai` (הגדירו `OPENAI_BASE_URL`) על פני שכבת ה-Ollama — זה נתיב תואם-OpenAI נקי יותר. אם למודל יש תקרת פלט משלו, הורדת `--token-budget` היא המנוף האמין.
|
||||
|
||||
**HTML של הגרף גדול מדי לפתיחה בדפדפן (מעל 5000 צמתים)**
|
||||
דלגו על יצירת ה-HTML והשתמשו ב-JSON ישירות:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
graphify cluster-only ./my-project --no-viz
|
||||
graphify query "..."
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**ל-`graph.json` יש סימוני קונפליקט אחרי ששני מפתחים ביצעו commit במקביל**
|
||||
הריצו `graphify hook install` — הוא מגדיר merge driver של git שממזג-מאחד את `graph.json` אוטומטית כך שקונפליקטים לא קורים בכלל.
|
||||
|
||||
**החילוץ מחזיר צמתים/קשתות ריקים למסמכים או PDF**
|
||||
מסמכים, PDF ותמונות דורשים קריאת LLM — קורפוסים של קוד בלבד אינם דורשים מפתח. ודאו שמפתח ה-API מוגדר וה-backend נכון:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-... graphify extract ./docs --backend claude
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
**אזהרת חוסר התאמה בגרסת המיומנות ב-IDE**
|
||||
גרסת graphify המותקנת שונה מקובץ המיומנות. עדכנו:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
uv tool upgrade graphifyy
|
||||
graphify install # דורס את קובץ המיומנות
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
---
|
||||
|
||||
## רשימת הפקודות המלאה
|
||||
|
||||
</div>
|
||||
|
||||
```
|
||||
/graphify # הרצה על התיקייה הנוכחית
|
||||
/graphify ./raw # הרצה על תיקייה מסוימת
|
||||
/graphify ./raw --mode deep # חילוץ קשרים אגרסיבי יותר
|
||||
/graphify ./raw --update # חילוץ מחדש של קבצים שהשתנו בלבד
|
||||
/graphify ./raw --directed # שמירת כיוון הקשתות
|
||||
/graphify ./raw --cluster-only # הרצת אשכול מחדש על גרף קיים
|
||||
/graphify ./raw --no-viz # דילוג על ויזואליזציית HTML
|
||||
/graphify ./raw --obsidian # יצירת כספת Obsidian
|
||||
/graphify ./raw --obsidian --obsidian-dir ~/vault # כתיבה לכספת קיימת (לעולם לא דורס פתקים או תצורת .obsidian שלכם)
|
||||
/graphify ./raw --wiki # בניית ויקי markdown שסוכנים יכולים לסרוק
|
||||
/graphify ./raw --svg # ייצוא graph.svg
|
||||
/graphify ./raw --graphml # ייצוא ל-Gephi / yEd
|
||||
/graphify ./raw --neo4j # יצירת cypher.txt ל-Neo4j
|
||||
/graphify ./raw --neo4j-push bolt://localhost:7687
|
||||
/graphify ./raw --falkordb # יצירת cypher.txt ל-FalkorDB
|
||||
/graphify ./raw --falkordb-push falkordb://localhost:6379
|
||||
/graphify ./raw --watch # סנכרון אוטומטי כשקבצים משתנים
|
||||
/graphify ./raw --mcp # הפעלת שרת MCP stdio
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762
|
||||
/graphify add <video-url>
|
||||
/graphify add https://... --author "Name" --contributor "Name"
|
||||
|
||||
/graphify query "מה מחבר את ה-attention לאופטימייזר?"
|
||||
/graphify query "..." --dfs --budget 1500
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify save-result --question "Q" --answer "A" --nodes Foo Bar --outcome useful # תיעוד תוצאת שאלה-תשובה (זיכרון עבודה; outcome ∈ useful|dead_end|corrected)
|
||||
graphify reflect # איחוד תוצאות graphify-out/memory/ אל reflections/LESSONS.md
|
||||
graphify reflect --if-stale # לא עושה דבר אם LESSONS.md כבר חדש מכל הקלטים (זול להרצה בכל סשן)
|
||||
graphify reflect --out docs/LESSONS.md # כתיבת מסמך הלקחים למקום אחר
|
||||
graphify reflect --graph graphify-out/graph.json # קיבוץ לקחים לפי קהילה + כתיבת שכבת זיכרון העבודה (.graphify_learning.json)
|
||||
# השכבה מתייגת צמתים preferred/tentative/contested (משוקלל-עדכניות, עם מקור);
|
||||
# graphify explain / query מציגים אז רמז "Lesson:", מסומן "code changed — re-verify" כשהמקור התקדם
|
||||
|
||||
graphify uninstall # הסרה מכל הפלטפורמות במכה אחת
|
||||
graphify uninstall --purge # מחיקה גם של graphify-out/
|
||||
graphify uninstall --project --platform codex # הסרת קובצי התקנה ברמת פרויקט בלבד
|
||||
|
||||
graphify hook install # הוקים של post-commit + post-checkout
|
||||
graphify hook uninstall
|
||||
graphify hook status
|
||||
|
||||
# הנחיות עוזר קבועות - פר פלטפורמה
|
||||
graphify claude install # CLAUDE.md + הוק PreToolUse (Claude Code)
|
||||
graphify claude uninstall
|
||||
graphify codebuddy install # CODEBUDDY.md + הוק PreToolUse (CodeBuddy)
|
||||
graphify codebuddy uninstall
|
||||
graphify codex install # AGENTS.md + הוק PreToolUse ב-.codex/hooks.json (Codex)
|
||||
graphify opencode install # AGENTS.md + תוסף tool.execute.before (OpenCode)
|
||||
graphify kilo install # מיומנות Kilo נטיבית + פקודת /graphify + AGENTS.md + תוסף .kilo
|
||||
graphify kilo uninstall
|
||||
graphify cursor install # .cursor/rules/graphify.mdc (Cursor)
|
||||
graphify cursor uninstall
|
||||
graphify gemini install # GEMINI.md + הוק BeforeTool (Gemini CLI)
|
||||
graphify gemini uninstall
|
||||
graphify copilot install # קובץ מיומנות (GitHub Copilot CLI)
|
||||
graphify copilot uninstall
|
||||
graphify aider install # AGENTS.md (Aider)
|
||||
graphify aider uninstall
|
||||
graphify claw install # AGENTS.md (OpenClaw)
|
||||
graphify claw uninstall
|
||||
graphify droid install # AGENTS.md (Factory Droid)
|
||||
graphify droid uninstall
|
||||
graphify trae install # AGENTS.md (Trae)
|
||||
graphify trae uninstall
|
||||
graphify trae-cn install # AGENTS.md (Trae CN)
|
||||
graphify trae-cn uninstall
|
||||
graphify hermes install # AGENTS.md + ~/.hermes/skills/ (Hermes)
|
||||
graphify hermes uninstall
|
||||
graphify amp install # קובץ מיומנות (Amp)
|
||||
graphify amp uninstall
|
||||
graphify agents install # ~/.agents/skills/ + AGENTS.md (חוצה-פלטפורמות; כינוי: graphify skills)
|
||||
graphify agents uninstall
|
||||
graphify kiro install # .kiro/skills/ + .kiro/steering/graphify.md (Kiro IDE/CLI)
|
||||
graphify kiro uninstall
|
||||
graphify pi install # קובץ מיומנות (Pi coding agent)
|
||||
graphify pi uninstall
|
||||
graphify devin install # קובץ מיומנות + .windsurf/rules/graphify.md (Devin CLI)
|
||||
graphify devin uninstall
|
||||
graphify antigravity install # .agents/rules + .agents/workflows (Google Antigravity)
|
||||
graphify antigravity uninstall
|
||||
|
||||
graphify extract ./docs # חילוץ LLM headless ל-CI (ללא IDE)
|
||||
graphify extract ./docs --backend gemini # backend מפורש: gemini, kimi, claude, openai, deepseek, ollama, bedrock או claude-cli
|
||||
graphify extract ./docs --backend gemini --model gemini-3.1-pro-preview
|
||||
graphify extract ./docs --backend ollama # Ollama מקומי (הגדירו OLLAMA_BASE_URL / OLLAMA_MODEL) - ללא מפתח API ל-loopback
|
||||
OPENAI_BASE_URL=http://localhost:8080/v1 OPENAI_MODEL=my-model graphify extract ./docs --backend openai # כל שרת תואם-OpenAI (llama.cpp, vLLM, LM Studio)
|
||||
ANTHROPIC_BASE_URL=http://localhost:4000 ANTHROPIC_MODEL=my-model graphify extract ./docs --backend claude # כל endpoint תואם-Anthropic (פרוקסי LiteLLM, שערים)
|
||||
GRAPHIFY_OLLAMA_NUM_CTX=32768 graphify extract ./docs --backend ollama # דריסת חלון ה-KV-cache (מותאם אוטומטית כברירת מחדל)
|
||||
GRAPHIFY_OLLAMA_KEEP_ALIVE=0 graphify extract ./docs --backend ollama # פריקת המודל אחרי כל chunk (חוסך VRAM ב-GPU קטן)
|
||||
graphify extract ./docs --backend bedrock # AWS Bedrock דרך IAM - ללא מפתח API, שרשרת אישורי AWS
|
||||
graphify extract ./docs --backend claude-cli # ניתוב דרך Claude Code CLI - ללא מפתח API, דרך מנוי Claude שלכם
|
||||
graphify extract ./docs --backend azure # Azure OpenAI (הגדירו AZURE_OPENAI_API_KEY + AZURE_OPENAI_ENDPOINT)
|
||||
graphify extract ./docs --max-workers 16 # מקביליות AST (גם GRAPHIFY_MAX_WORKERS)
|
||||
graphify extract --postgres "postgresql://user:pass@host/db" # אינטרוספקציה ישירה של סכמת PostgreSQL חיה
|
||||
graphify extract ./my-workspace --cargo # אינטרוספקציה ישירה של תלויות Cargo workspace ברוסט
|
||||
graphify extract ./docs --token-budget 30000 # chunks סמנטיים קטנים יותר למודלים מקומיים/קטנים
|
||||
graphify extract ./docs --max-concurrency 2 # פחות קריאות LLM מקביליות (שימושי להרצה מקומית)
|
||||
graphify extract ./docs --api-timeout 900 # timeout ארוך יותר למודלים מקומיים איטיים (ברירת מחדל 600 שניות)
|
||||
graphify extract ./docs --google-workspace # ייצוא .gdoc/.gsheet/.gslides דרך gws לפני החילוץ
|
||||
graphify extract ./docs --mode deep # חילוץ סמנטי עשיר יותר עם system prompt מורחב
|
||||
graphify extract ./docs --no-cluster # חילוץ גולמי בלבד, דילוג על אשכול
|
||||
graphify extract ./docs --timing # הדפסת זמני ריצה פר-שלב ל-stderr (עובד גם ב-cluster-only)
|
||||
graphify extract ./docs --force # דריסת graph.json גם אם לגרף החדש פחות צמתים (אחרי refactors או לניקוי כפילויות רפאים)
|
||||
graphify extract ./docs --dedup-llm # LLM כמכריע לזוגות ישויות עמומים (משתמש באותו מפתח API)
|
||||
graphify extract ./docs --global --as myrepo # חילוץ ורישום בגרף הגלובלי חוצה-הפרויקטים
|
||||
GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # הרמת תקרת הפלט לקורפוסים צפופים
|
||||
|
||||
graphify export callflow-html # graphify-out/<project>-callflow.html
|
||||
graphify export callflow-html --max-sections 8 # הגבלת מספר קטעי הארכיטקטורה שנוצרים
|
||||
graphify export callflow-html --output docs/arch.html
|
||||
graphify export callflow-html ./some-repo/graphify-out
|
||||
|
||||
graphify global add graphify-out/graph.json --as myrepo # רישום גרף פרויקט אל ~/.graphify/global-graph.json
|
||||
graphify global remove myrepo # הסרת פרויקט מהגרף הגלובלי
|
||||
graphify global list # הצגת כל המאגרים הרשומים + ספירות צמתים/קשתות
|
||||
graphify global path # הדפסת הנתיב לקובץ הגרף הגלובלי
|
||||
|
||||
graphify prs # לוח PR: CI, ביקורת, worktree, השפעת גרף
|
||||
graphify prs 42 # צלילה עמוקה ל-PR #42
|
||||
graphify prs --triage # דירוג טריאז' AI (מזהה backend אוטומטית מהסביבה)
|
||||
graphify prs --worktrees # מיפוי worktree ← branch ← PR
|
||||
graphify prs --conflicts # PRs שחולקים קהילות גרף (סיכון בסדר המיזוג)
|
||||
graphify prs --base main # סינון ל-PRs שמכוונים ל-branch בסיס מסוים
|
||||
graphify prs --repo owner/repo # הרצה מול מאגר GitHub אחר
|
||||
GRAPHIFY_TRIAGE_BACKEND=kimi graphify prs --triage # backend מסוים לטריאז'
|
||||
|
||||
graphify clone https://github.com/karpathy/nanoGPT
|
||||
graphify merge-graphs a.json b.json --out merged.json
|
||||
graphify --version # הדפסת הגרסה המותקנת
|
||||
graphify watch ./src
|
||||
graphify check-update ./src
|
||||
graphify update ./src
|
||||
graphify update ./src --no-cluster # דילוג על אשכול מחדש, כתיבת גרף AST גולמי בלבד
|
||||
graphify update ./src --force # דריסה גם אם לגרף החדש פחות צמתים
|
||||
graphify cluster-only ./my-project
|
||||
graphify cluster-only ./my-project --graph path/to/graph.json # מיקום גרף מותאם
|
||||
graphify cluster-only ./my-project --max-concurrency 16 --batch-size 200 # תיוג קהילות מקבילי (גרפים גדולים)
|
||||
graphify cluster-only ./my-project --resolution 1.5 # יותר קהילות, קטנות יותר
|
||||
graphify cluster-only ./my-project --exclude-hubs 99 # החרגת צומתי p99 מהחלוקה
|
||||
graphify cluster-only ./my-project --no-label # השארת מצייני "Community N"
|
||||
graphify cluster-only ./my-project --backend=gemini # backend לשמות הקהילות
|
||||
graphify cluster-only ./my-project --backend=gemini --model gemini-2.5-pro # מודל מסוים
|
||||
graphify label ./my-project # מתן שמות (מחדש) לקהילות עם ה-backend המוגדר
|
||||
graphify label ./my-project --backend=openai --model gpt-4o # כפיית backend ומודל מסוימים
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
> **שמות קהילות:** בתוך סוכן (Claude Code, Gemini CLI) הסוכן נותן שמות לקהילות בעצמו. בהרצת ה-CLI החשוף, `cluster-only` נותן שמות אוטומטית עם ה-backend המוגדר (מובנה או ספק תואם-OpenAI מותאם) — העבירו `--no-label` להשארת `Community N`, או הריצו `graphify label` ליצירת שמות מחדש לפי דרישה.
|
||||
|
||||
---
|
||||
|
||||
## ללמוד עוד
|
||||
|
||||
- [איך זה עובד](../how-it-works.md) — צינור החילוץ, זיהוי קהילות, ניקוד ביטחון, מדדים
|
||||
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — פירוק מודולים, איך מוסיפים שפה
|
||||
- [אינטגרציות אופציונליות](../docker-mcp-sqlite.md) — Docker MCP Toolkit + SQLite
|
||||
|
||||
---
|
||||
|
||||
## נבנה על graphify — Penpax
|
||||
|
||||
[**Penpax**](https://graphify.com) היא השכבה התמידית שנבנתה מעל graphify — היא מיישמת את אותה גישת גרף על כל חיי העבודה שלכם: פגישות, היסטוריית דפדפן, מיילים, קבצים וקוד, ומתעדכנת ברציפות ברקע.
|
||||
|
||||
נבנתה לאנשים שהעבודה שלהם מפוזרת על פני מאות שיחות ומסמכים שהם לעולם לא יוכלו לשחזר במלואם. ללא ענן, לגמרי על המכשיר.
|
||||
|
||||
**גרסת ניסיון חינמית תושק בקרוב.** [הצטרפו לרשימת ההמתנה ←](https://graphify.com)
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>תרומה לפרויקט</summary>
|
||||
|
||||
### הקמת סביבת פיתוח
|
||||
|
||||
הפרויקט משתמש ב-[uv](https://docs.astral.sh/uv/) לתהליך הפיתוח. התקינו אותו פעם אחת, ואז:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/safishamsi/graphify.git
|
||||
cd graphify
|
||||
git checkout v8 # branch הפיתוח הפעיל
|
||||
|
||||
# יצירת venv לפרויקט והתקנת graphify + כל התוספים + קבוצת dev
|
||||
# (pytest). uv מתקין את קבוצת התלויות dev כברירת מחדל; העבירו --no-dev לדילוג.
|
||||
uv sync --all-extras
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
אימות ההתקנה במצב עריכה:
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
uv run graphify --version
|
||||
uv run python -c "import graphify; print(graphify.__file__)"
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
### הרצת בדיקות
|
||||
|
||||
</div>
|
||||
|
||||
```bash
|
||||
uv run pytest tests/ -q # הרצת החבילה המלאה
|
||||
uv run pytest tests/test_extract.py -q # מודול אחד
|
||||
uv run pytest tests/ -q -k "python" # סינון לפי שם
|
||||
```
|
||||
|
||||
<div dir="rtl">
|
||||
|
||||
> הערת macOS: חבילת הבדיקות כוללת גם `sample.f90` וגם `sample.F90`. אלו מתנגשים במערכות קבצים HFS+ / APFS שאינן רגישות לרישיות. הריצו בלינוקס או בקונטיינר Docker אם צריך לבדוק את שתי גרסאות ה-Fortran יחד.
|
||||
|
||||
### תהליך עבודה ב-Git
|
||||
|
||||
- הפיתוח הפעיל מתרחש ב-branch `v8`.
|
||||
- סגנון commit: `fix: <description>` / `feat: <description>` / `docs: <description>`
|
||||
- לפני פתיחת PR, הריצו `uv run pytest tests/ -q` וודאו שהוא עובר.
|
||||
- הוסיפו קובץ fixture ל-`tests/fixtures/` ובדיקות ל-`tests/test_languages.py` לכל מחלץ שפה חדש.
|
||||
|
||||
### מה כדאי לתרום
|
||||
|
||||
**דוגמאות עבודה (worked examples)** הן התרומה השימושית ביותר. הריצו `/graphify` על קורפוס אמיתי, שמרו את הפלט ב-`worked/{slug}/`, כתבו `review.md` כן שמכסה מה הגרף קלע ומה פספס, ופתחו PR.
|
||||
|
||||
**באגים בחילוץ** — פתחו issue עם קובץ הקלט, רשומת ה-cache (`graphify-out/cache/`) ומה חסר או שגוי.
|
||||
|
||||
ראו [ARCHITECTURE.md](../../ARCHITECTURE.md) לאחריות המודולים ואיך מוסיפים שפה.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,143 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
</p>
|
||||
|
||||
**एक AI कोडिंग असिस्टेंट स्किल।** Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro या Google Antigravity में `/graphify` टाइप करें — यह आपकी फ़ाइलें पढ़ता है, एक नॉलेज ग्राफ बनाता है, और आपको वह संरचना वापस देता है जो आप नहीं जानते थे कि मौजूद है। कोडबेस को तेज़ी से समझें। आर्किटेक्चरल निर्णयों के पीछे का "क्यों" खोजें।
|
||||
|
||||
पूरी तरह मल्टीमोडल। कोड, PDFs, मार्कडाउन, स्क्रीनशॉट, डायग्राम, व्हाइटबोर्ड फोटो, अन्य भाषाओं में छवियां, या वीडियो और ऑडियो फ़ाइलें डालें — graphify इन सभी से अवधारणाएं और संबंध निकालता है और उन्हें एक ग्राफ में जोड़ता है। वीडियो को Whisper से स्थानीय रूप से ट्रांसक्राइब किया जाता है। 25 प्रोग्रामिंग भाषाएं tree-sitter AST के माध्यम से समर्थित हैं (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart)।
|
||||
|
||||
> Andrej Karpathy एक `/raw` फोल्डर रखते हैं जहां वह papers, tweets, स्क्रीनशॉट और नोट्स डालते हैं। graphify उस समस्या का जवाब है — रॉ फ़ाइलें पढ़ने की तुलना में प्रति क्वेरी **71.5x** कम tokens, सत्रों में स्थायी, ईमानदार कि क्या पाया गया बनाम अनुमान लगाया गया।
|
||||
|
||||
```
|
||||
/graphify . # किसी भी फोल्डर पर काम करता है — कोडबेस, नोट्स, papers, सब कुछ
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html इंटरेक्टिव ग्राफ — किसी भी ब्राउज़र में खोलें, नोड्स क्लिक करें, खोजें
|
||||
├── GRAPH_REPORT.md गॉड नोड्स, आश्चर्यजनक कनेक्शन, सुझाए गए प्रश्न
|
||||
├── graph.json स्थायी ग्राफ — हफ्तों बाद भी क्वेरी करें
|
||||
└── cache/ SHA256 कैश — पुनः चलाने पर केवल बदली हुई फ़ाइलें प्रोसेस होती हैं
|
||||
```
|
||||
|
||||
अनचाहे फोल्डर को बाहर करने के लिए `.graphifyignore` फ़ाइल जोड़ें:
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
`.gitignore` जैसी ही सिंटेक्स।
|
||||
|
||||
## यह कैसे काम करता है
|
||||
|
||||
graphify तीन चरणों में चलता है। पहले, एक निर्धारक AST पास कोड फ़ाइलों से संरचना निकालता है — बिना किसी LLM के। दूसरे, वीडियो और ऑडियो फ़ाइलों को faster-whisper से स्थानीय रूप से ट्रांसक्राइब किया जाता है। तीसरे, Claude सबएजेंट दस्तावेज़ों, papers, छवियों और ट्रांसक्रिप्ट पर समानांतर में चलते हैं। परिणामों को NetworkX ग्राफ में मर्ज किया जाता है, Leiden कम्युनिटी डिटेक्शन से क्लस्टर किया जाता है, और इंटरेक्टिव HTML, क्वेरी करने योग्य JSON और एक ऑडिट रिपोर्ट के रूप में निर्यात किया जाता है।
|
||||
|
||||
**क्लस्टरिंग ग्राफ-टोपोलॉजी आधारित है — कोई embeddings नहीं।** Claude द्वारा निकाले गए सिमेंटिक समानता किनारे पहले से ग्राफ में हैं, इसलिए वे कम्युनिटी डिटेक्शन को सीधे प्रभावित करते हैं।
|
||||
|
||||
प्रत्येक संबंध `EXTRACTED` (स्रोत में सीधे पाया गया), `INFERRED` (उचित अनुमान, कॉन्फिडेंस स्कोर के साथ) या `AMBIGUOUS` (समीक्षा के लिए चिह्नित) के रूप में टैग किया जाता है।
|
||||
|
||||
## इंस्टॉलेशन
|
||||
|
||||
**आवश्यकताएं:** Python 3.10+ और निम्न में से एक: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes या [Google Antigravity](https://antigravity.google)
|
||||
|
||||
```bash
|
||||
# अनुशंसित — Mac और Linux पर PATH सेटअप के बिना काम करता है
|
||||
uv tool install graphifyy && graphify install
|
||||
# या pipx के साथ
|
||||
pipx install graphifyy && graphify install
|
||||
# या सामान्य pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **आधिकारिक पैकेज:** PyPI पैकेज का नाम `graphifyy` है (`pip install graphifyy` से इंस्टॉल करें)। PyPI पर `graphify*` नाम वाले अन्य पैकेज इस प्रोजेक्ट से संबद्ध नहीं हैं। एकमात्र आधिकारिक रिपॉजिटरी [safishamsi/graphify](https://github.com/safishamsi/graphify) है।
|
||||
|
||||
### प्लेटफॉर्म समर्थन
|
||||
|
||||
| प्लेटफॉर्म | इंस्टॉल कमांड |
|
||||
|------------|---------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (स्वतः-पहचान) या `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
फिर अपना AI कोडिंग असिस्टेंट खोलें और टाइप करें:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
## उपयोग
|
||||
|
||||
```
|
||||
/graphify # वर्तमान डायरेक्टरी
|
||||
/graphify ./raw # विशिष्ट फोल्डर
|
||||
/graphify ./raw --update # केवल बदली हुई फ़ाइलें फिर से निकालें
|
||||
/graphify ./raw --directed # निर्देशित ग्राफ
|
||||
/graphify ./raw --no-viz # केवल रिपोर्ट + JSON
|
||||
/graphify ./raw --obsidian # Obsidian vault बनाएं
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # paper प्राप्त करें
|
||||
/graphify add <video-url> # वीडियो ट्रांसक्राइब करें
|
||||
/graphify query "attention और optimizer को क्या जोड़ता है?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify hook install # Git hooks इंस्टॉल करें
|
||||
graphify update ./src # कोड फ़ाइलें पुनः निकालें, LLM की जरूरत नहीं
|
||||
graphify watch ./src # स्वचालित ग्राफ अपडेट
|
||||
```
|
||||
|
||||
## आपको क्या मिलता है
|
||||
|
||||
**गॉड नोड्स** — सबसे अधिक डिग्री वाली अवधारणाएं (जिनसे सब कुछ गुजरता है)
|
||||
|
||||
**आश्चर्यजनक कनेक्शन** — कम्पोजिट स्कोर द्वारा रैंक किए गए। कोड-पेपर किनारे उच्च रैंक पाते हैं।
|
||||
|
||||
**सुझाए गए प्रश्न** — 4-5 प्रश्न जिन्हें ग्राफ विशेष रूप से अच्छी तरह उत्तर दे सकता है
|
||||
|
||||
**"क्यों"** — docstrings, inline comments, और design rationale को `rationale_for` नोड्स के रूप में निकाला जाता है।
|
||||
|
||||
**कॉन्फिडेंस स्कोर** — प्रत्येक INFERRED किनारे का `confidence_score` (0.0-1.0) होता है।
|
||||
|
||||
**टोकन बेंचमार्क** — प्रत्येक रन के बाद स्वचालित रूप से प्रिंट होता है। मिश्रित corpus पर: रॉ फ़ाइलों की तुलना में **71.5x** कम tokens।
|
||||
|
||||
## गोपनीयता
|
||||
|
||||
graphify दस्तावेज़ों, papers और छवियों के सिमेंटिक निष्कर्षण के लिए आपके AI असिस्टेंट की मॉडल API को फ़ाइल सामग्री भेजता है। कोड फ़ाइलें tree-sitter AST के माध्यम से स्थानीय रूप से प्रोसेस होती हैं। वीडियो और ऑडियो फ़ाइलें faster-whisper से स्थानीय रूप से ट्रांसक्राइब होती हैं। कोई टेलीमेट्री नहीं, कोई ट्रैकिंग नहीं।
|
||||
|
||||
## graphify पर बनाया — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) graphify के ऊपर एंटरप्राइज़ लेयर है। जहां graphify फ़ाइलों के एक फोल्डर को नॉलेज ग्राफ में बदलता है, Penpax वही ग्राफ आपके पूरे कार्य जीवन पर लागू करता है — निरंतर।
|
||||
|
||||
**फ्री ट्रायल जल्द लॉन्च होगा।** [वेटलिस्ट में शामिल हों →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
## Star इतिहास
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Képesség AI kódolási asszisztensekhez.** Írja be a `/graphify` parancsot a Claude Code-ban, Codexben, OpenCode-ban, Cursorban, Gemini CLI-ben, GitHub Copilot CLI-ben, VS Code Copilot Chatben, Aiderben, OpenClawban, Factory Droidban, Traeben, Hermesben, Kiroban vagy a Google Antigravityben — beolvassa a fájljait, tudásgráfot épít, és visszaadja azt a struktúrát, amelyről nem tudta, hogy létezik. Értse meg gyorsabban a kódbázist. Találja meg az architektúrális döntések mögött álló „miértet".
|
||||
|
||||
Teljesen multimodális. Adjon hozzá kódot, PDF-eket, markdownt, képernyőképeket, diagramokat, táblafotókat, más nyelvű képeket vagy video- és hangfájlokat — a graphify mindenből kinyeri a fogalmakat és kapcsolatokat, és egyetlen gráfba köti össze őket. A videókat a Whisper segítségével helyben írja át. 25 programozási nyelvet támogat tree-sitter AST-n keresztül.
|
||||
|
||||
> Andrej Karpathy fenntart egy `/raw` mappát, ahova cikkeket, tweeteket, képernyőképeket és jegyzeteket helyez el. A graphify erre a problémára adott válasz — **71,5x** kevesebb token lekérdezésenként a nyers fájlok olvasásához képest, munkamenetek között is megmarad.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interaktív gráf — nyissa meg bármely böngészőben
|
||||
├── GRAPH_REPORT.md isten-csúcspontok, meglepő kapcsolatok, javasolt kérdések
|
||||
├── graph.json állandó gráf — hetekkel később is lekérdezhető
|
||||
└── cache/ SHA256-gyorsítótár — ismételt futtatások csak a módosított fájlokat dolgozzák fel
|
||||
```
|
||||
|
||||
## Hogyan működik
|
||||
|
||||
A graphify három menetben dolgozik. Először egy determinisztikus AST-menet kinyeri a struktúrát a kódfájlokból LLM nélkül. Ezután a video- és hangfájlokat a faster-whisper segítségével helyben írja át. Végül a Claude alügynökök párhuzamosan futnak dokumentumokon, cikkeken, képeken és átiratokban. Az eredményeket egy NetworkX-gráfba olvasztja össze, Leiden-nel klaszterezik, és interaktív HTML-ként, lekérdezhető JSON-ként és auditjelentésként exportálja.
|
||||
|
||||
Minden kapcsolat `EXTRACTED`, `INFERRED` (megbízhatósági pontszámmal) vagy `AMBIGUOUS` feliratot kap.
|
||||
|
||||
## Telepítés
|
||||
|
||||
**Követelmények:** Python 3.10+ és az alábbiak egyike: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) és mások.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# vagy pipx-szel
|
||||
pipx install graphifyy && graphify install
|
||||
# vagy pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Hivatalos csomag:** A PyPI-csomag neve `graphifyy`. Az egyetlen hivatalos tároló a [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Használat
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "mi köti össze az Attentiont az optimalizálóval?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Mit kap
|
||||
|
||||
**Isten-csúcspontok** — a legmagasabb fokú fogalmak · **Meglepő kapcsolatok** — pontszám szerint rendezve · **Javasolt kérdések** · **A „miért"** — docstringek és tervezési indoklások csúcspontként kinyerve · **Token-benchmark** — **71,5x** kevesebb token vegyes korpuszon.
|
||||
|
||||
## Adatvédelem
|
||||
|
||||
A kódfájlokat helyben dolgozza fel tree-sitter AST-n keresztül. A videókat helyben írja át a faster-whisper. Nincs telemetria.
|
||||
|
||||
## A graphify-ra épülve — Penpax
|
||||
|
||||
A [**Penpax**](https://safishamsi.github.io/penpax.ai) a graphify feletti vállalati réteg. **Ingyenes próbaverzió hamarosan.** [Csatlakozzon a várólistához →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Keterampilan untuk asisten kode AI.** Ketik `/graphify` di Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, atau Google Antigravity — membaca file Anda, membangun graf pengetahuan, dan mengembalikan struktur yang tidak Anda ketahui ada. Pahami codebase lebih cepat. Temukan "mengapa" di balik keputusan arsitektur.
|
||||
|
||||
Sepenuhnya multimodal. Tambahkan kode, PDF, markdown, tangkapan layar, diagram, foto papan tulis, gambar dalam bahasa lain, atau file video dan audio — graphify mengekstrak konsep dan hubungan dari semuanya dan menghubungkannya dalam satu graf. Video ditranskrip secara lokal dengan Whisper. Mendukung 25 bahasa pemrograman melalui tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy memelihara folder `/raw` tempat ia menyimpan makalah, tweet, tangkapan layar, dan catatan. graphify adalah jawaban untuk masalah itu — **71,5x** lebih sedikit token per kueri dibandingkan membaca file mentah, persisten di antara sesi.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html graf interaktif — buka di browser mana saja
|
||||
├── GRAPH_REPORT.md node dewa, koneksi mengejutkan, pertanyaan yang disarankan
|
||||
├── graph.json graf persisten — dapat dikueri berminggu-minggu kemudian
|
||||
└── cache/ cache SHA256 — pengulangan hanya memproses file yang berubah
|
||||
```
|
||||
|
||||
## Cara Kerja
|
||||
|
||||
graphify bekerja dalam tiga tahap. Pertama, tahap AST deterministik mengekstrak struktur dari file kode tanpa LLM. Kemudian file video dan audio ditranskrip secara lokal dengan faster-whisper. Terakhir, sub-agen Claude berjalan secara paralel pada dokumen, makalah, gambar, dan transkripsi. Hasilnya digabungkan ke dalam graf NetworkX, dikelompokkan dengan Leiden, dan diekspor sebagai HTML interaktif, JSON yang dapat dikueri, dan laporan audit.
|
||||
|
||||
Setiap hubungan diberi label `EXTRACTED`, `INFERRED` (dengan skor kepercayaan), atau `AMBIGUOUS`.
|
||||
|
||||
## Instalasi
|
||||
|
||||
**Persyaratan:** Python 3.10+ dan salah satu dari: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) dan lainnya.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# atau dengan pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# atau pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Paket resmi:** Paket PyPI bernama `graphifyy`. Satu-satunya repositori resmi adalah [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Penggunaan
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "apa yang menghubungkan Attention dengan optimizer?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Apa yang Anda Dapatkan
|
||||
|
||||
**Node dewa** — konsep dengan derajat tertinggi · **Koneksi mengejutkan** — diurutkan berdasarkan skor · **Pertanyaan yang disarankan** · **"Mengapa"** — docstring dan alasan desain diekstrak sebagai node · **Benchmark token** — **71,5x** lebih sedikit token pada corpus campuran.
|
||||
|
||||
## Privasi
|
||||
|
||||
File kode diproses secara lokal melalui tree-sitter AST. Video ditranskrip secara lokal dengan faster-whisper. Tidak ada telemetri.
|
||||
|
||||
## Dibangun di atas graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) adalah lapisan enterprise di atas graphify. **Uji coba gratis segera hadir.** [Bergabunglah dengan daftar tunggu →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,78 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Una skill per assistenti di codice IA.** Scrivi `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro o Google Antigravity — legge i tuoi file, costruisce un grafo della conoscenza e ti restituisce struttura che non sapevi esistesse. Comprendi una codebase più velocemente. Trova il "perché" dietro le decisioni architetturali.
|
||||
|
||||
Completamente multimodale. Aggiungi codice, PDF, markdown, screenshot, diagrammi, foto di lavagne, immagini in altre lingue, o file video e audio — graphify estrae concetti e relazioni da tutto e li connette in un unico grafo. I video vengono trascritti localmente con Whisper. Supporta 25 linguaggi di programmazione via tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy mantiene una cartella `/raw` dove deposita paper, tweet, screenshot e note. graphify è la risposta a quel problema — **71,5x** meno token per query rispetto alla lettura dei file grezzi, persistente tra le sessioni.
|
||||
|
||||
```
|
||||
/graphify . # funziona con qualsiasi cartella
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html grafo interattivo — apri in qualsiasi browser
|
||||
├── GRAPH_REPORT.md nodi dio, connessioni sorprendenti, domande suggerite
|
||||
├── graph.json grafo persistente — interrogabile settimane dopo
|
||||
└── cache/ cache SHA256 — le riesecuzioni elaborano solo i file modificati
|
||||
```
|
||||
|
||||
## Come funziona
|
||||
|
||||
graphify esegue in tre passaggi. Prima, un passaggio AST deterministico estrae la struttura dai file di codice senza LLM. Poi, i file video e audio vengono trascritti localmente con faster-whisper. Infine, i subagenti Claude eseguono in parallelo su documenti, paper, immagini e trascrizioni. I risultati vengono uniti in un grafo NetworkX, raggruppati con Leiden e esportati come HTML interattivo, JSON interrogabile e report di audit.
|
||||
|
||||
Ogni relazione è etichettata `EXTRACTED`, `INFERRED` (con punteggio di confidenza) o `AMBIGUOUS`.
|
||||
|
||||
## Installazione
|
||||
|
||||
**Requisiti:** Python 3.10+ e uno tra: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Aider](https://aider.chat) e altri.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# oppure con pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# oppure pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Pacchetto ufficiale:** Il pacchetto PyPI si chiama `graphifyy`. L'unico repository ufficiale è [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Utilizzo
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update # solo file modificati
|
||||
/graphify ./raw --mode deep
|
||||
/graphify query "cosa connette Attention all'ottimizzatore?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Cosa ottieni
|
||||
|
||||
**Nodi dio** — concetti con il grado più alto · **Connessioni sorprendenti** — classificate per punteggio · **Domande suggerite** — 4-5 domande che il grafo è in grado di rispondere in modo unico · **Il "perché"** — docstring e rationale di design estratti come nodi · **Benchmark token** — **71,5x** meno token su corpus misto.
|
||||
|
||||
## Privacy
|
||||
|
||||
I file di codice vengono elaborati localmente via tree-sitter AST. I video vengono trascritti localmente con faster-whisper. Nessuna telemetria.
|
||||
|
||||
## Costruito su graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) è il livello enterprise su graphify. **Prova gratuita in arrivo.** [Unisciti alla lista d'attesa →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,238 @@
|
||||
# graphify
|
||||
|
||||
🇺🇸 [English](../../README.md) | 🇨🇳 [简体中文](README.zh-CN.md) | 🇯🇵 [日本語](README.ja-JP.md) | 🇰🇷 [한국어](README.ko-KR.md) | 🇩🇪 [Deutsch](README.de-DE.md) | 🇫🇷 [Français](README.fr-FR.md) | 🇪🇸 [Español](README.es-ES.md) | 🇮🇳 [हिन्दी](README.hi-IN.md) | 🇧🇷 [Português](README.pt-BR.md) | 🇷🇺 [Русский](README.ru-RU.md) | 🇸🇦 [العربية](README.ar-SA.md) | 🇮🇹 [Italiano](README.it-IT.md) | 🇵🇱 [Polski](README.pl-PL.md) | 🇳🇱 [Nederlands](README.nl-NL.md) | 🇹🇷 [Türkçe](README.tr-TR.md) | 🇺🇦 [Українська](README.uk-UA.md) | 🇻🇳 [Tiếng Việt](README.vi-VN.md) | 🇮🇩 [Bahasa Indonesia](README.id-ID.md) | 🇸🇪 [Svenska](README.sv-SE.md) | 🇬🇷 [Ελληνικά](README.el-GR.md) | 🇷🇴 [Română](README.ro-RO.md) | 🇨🇿 [Čeština](README.cs-CZ.md) | 🇫🇮 [Suomi](README.fi-FI.md) | 🇩🇰 [Dansk](README.da-DK.md) | 🇳🇴 [Norsk](README.no-NO.md) | 🇭🇺 [Magyar](README.hu-HU.md) | 🇹🇭 [ภาษาไทย](README.th-TH.md) | 🇺🇿 [Oʻzbekcha](README.uz-UZ.md) | 🇹🇼 [繁體中文](README.zh-TW.md)
|
||||
|
||||
[](https://github.com/safishamsi/graphify/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/graphifyy/)
|
||||
[](https://github.com/sponsors/safishamsi)
|
||||
|
||||
**AIコーディングアシスタント向けのスキル。** Claude Code、Codex、OpenCode、OpenClaw、Factory Droid で `/graphify` と入力するだけで、ファイルを読み込んでナレッジグラフを構築し、あなたが気づいていなかった構造を返します。コードベースをより速く理解し、アーキテクチャ上の意思決定の「なぜ」を見つけ出します。
|
||||
|
||||
完全にマルチモーダル対応。コード、PDF、Markdown、スクリーンショット、図、ホワイトボード写真、他言語の画像まで――graphify は Claude Vision を使ってそれらすべてから概念と関係性を抽出し、1 つのグラフに接続します。tree-sitter AST により 19 言語をサポート(Python、JS、TS、Go、Rust、Java、C、C++、Ruby、C#、Kotlin、Scala、PHP、Swift、Lua、Zig、PowerShell、Elixir、Objective-C)。
|
||||
|
||||
> Andrej Karpathy は論文、ツイート、スクリーンショット、メモを放り込む `/raw` フォルダを持っています。graphify はまさにその問題への答えです――生ファイルを読むのに比べて1クエリあたりのトークン数が 71.5 倍少なく、セッションをまたいで永続化され、見つけたものと推測したものを正直に区別します。
|
||||
|
||||
```
|
||||
/graphify . # どのフォルダでも動作 - コードベース、メモ、論文、なんでも
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html インタラクティブなグラフ - ノードをクリック、検索、コミュニティでフィルタ
|
||||
├── GRAPH_REPORT.md ゴッドノード、意外なつながり、推奨される質問
|
||||
├── graph.json 永続化されたグラフ - 数週間後でも再読み込みなしでクエリ可能
|
||||
└── cache/ SHA256 キャッシュ - 再実行時は変更されたファイルのみ処理
|
||||
```
|
||||
|
||||
グラフに含めたくないフォルダを除外するには `.graphifyignore` ファイルを追加します:
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
構文は `.gitignore` と同じです。パターンは graphify を実行したフォルダからの相対パスに対してマッチします。
|
||||
|
||||
## 仕組み
|
||||
|
||||
graphify は 2 パスで動作します。まず、決定論的な AST パスがコードファイルから構造(クラス、関数、インポート、コールグラフ、docstring、根拠コメント)を LLM なしで抽出します。次に、Claude サブエージェントがドキュメント、論文、画像に対して並列に実行され、概念、関係性、設計の根拠を抽出します。結果は NetworkX グラフにマージされ、Leiden コミュニティ検出でクラスタリングされ、インタラクティブ HTML、クエリ可能な JSON、平易な言葉の監査レポートとしてエクスポートされます。
|
||||
|
||||
**クラスタリングはグラフトポロジベース――埋め込みは使いません。** Leiden はエッジ密度によってコミュニティを見つけます。Claude が抽出する意味的類似性エッジ(`semantically_similar_to`、INFERRED とマーク)は既にグラフに含まれているため、コミュニティ検出に直接影響します。グラフ構造そのものが類似性シグナルであり――別途の埋め込みステップやベクターデータベースは不要です。
|
||||
|
||||
すべての関係は `EXTRACTED`(ソースから直接見つかった)、`INFERRED`(合理的な推論、信頼度スコア付き)、`AMBIGUOUS`(レビュー対象としてフラグ付け)のいずれかでタグ付けされます。何が見つかったもので何が推測されたものか、常に分かります。
|
||||
|
||||
## インストール
|
||||
|
||||
**必要なもの:** Python 3.10+ および以下のいずれか: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [OpenClaw](https://openclaw.ai), または [Factory Droid](https://factory.ai)
|
||||
|
||||
```bash
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> PyPI パッケージは `graphify` の名前が再取得されるまでの間、一時的に `graphifyy` となっています。CLI とスキルコマンドは依然として `graphify` です。
|
||||
|
||||
### プラットフォームサポート
|
||||
|
||||
| プラットフォーム | インストールコマンド |
|
||||
|----------|----------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install`(自動検出)または `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
|
||||
Codex ユーザーは並列抽出のために `~/.codex/config.toml` の `[features]` の下に `multi_agent = true` も必要です。Factory Droid は並列サブエージェントディスパッチに `Task` ツールを使用します。OpenClaw は逐次抽出を使用します(並列エージェントサポートはこのプラットフォームではまだ初期段階です)。
|
||||
|
||||
次に、AI コーディングアシスタントを開いて入力します:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
注意:Codex はスキル呼び出しに `/` ではなく `$` を使用するため、代わりに `$graphify .` と入力してください。
|
||||
|
||||
### アシスタントに常にグラフを使わせる(推奨)
|
||||
|
||||
グラフを構築した後、プロジェクトで一度だけ以下を実行します:
|
||||
|
||||
| プラットフォーム | コマンド |
|
||||
|----------|---------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| OpenClaw | `graphify claw install` |
|
||||
| Factory Droid | `graphify droid install` |
|
||||
|
||||
**Claude Code** は 2 つのことを行います:Claude にアーキテクチャの質問に答える前に `graphify-out/GRAPH_REPORT.md` を読むように指示する `CLAUDE.md` セクションを書き込み、すべての Glob と Grep 呼び出しの前に発火する **PreToolUse フック**(`settings.json`)をインストールします。ナレッジグラフが存在する場合、Claude は次のメッセージを見ます:_"graphify: Knowledge graph exists. Read GRAPH_REPORT.md for god nodes and community structure before searching raw files."_ ――これにより Claude はすべてのファイルを grep するのではなく、グラフを介してナビゲートします。
|
||||
|
||||
**Codex、OpenCode、OpenClaw、Factory Droid** は同じルールをプロジェクトルートの `AGENTS.md` に書き込みます。これらのプラットフォームは PreToolUse フックをサポートしていないため、AGENTS.md が常時有効のメカニズムとなります。
|
||||
|
||||
アンインストールは対応するアンインストールコマンドで行います(例:`graphify claude uninstall`)。
|
||||
|
||||
**常時有効 vs 明示的トリガー――何が違うのか?**
|
||||
|
||||
常時有効のフックは `GRAPH_REPORT.md` を表面化します――これはゴッドノード、コミュニティ、意外なつながりを 1 ページにまとめた要約です。アシスタントはファイル検索の前にこれを読み、キーワードマッチではなく構造に基づいてナビゲートします。これで日常的な質問のほとんどをカバーできます。
|
||||
|
||||
`/graphify query`、`/graphify path`、`/graphify explain` はさらに深く踏み込みます:生の `graph.json` をホップごとに辿り、ノード間の正確なパスをトレースし、エッジレベルの詳細(関係タイプ、信頼度スコア、ソース位置)を表面化します。一般的なオリエンテーションではなく、特定の質問をグラフから答えさせたいときに使います。
|
||||
|
||||
こう考えてください:常時有効のフックはアシスタントに地図を与え、`/graphify` コマンドはその地図を正確にナビゲートさせます。
|
||||
|
||||
<details>
|
||||
<summary>手動インストール(curl)</summary>
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/skills/graphify
|
||||
curl -fsSL https://raw.githubusercontent.com/safishamsi/graphify/v3/graphify/skill.md \
|
||||
> ~/.claude/skills/graphify/SKILL.md
|
||||
```
|
||||
|
||||
`~/.claude/CLAUDE.md` に追加します:
|
||||
|
||||
```
|
||||
- **graphify** (`~/.claude/skills/graphify/SKILL.md`) - any input to knowledge graph. Trigger: `/graphify`
|
||||
When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 使い方
|
||||
|
||||
```
|
||||
/graphify # カレントディレクトリで実行
|
||||
/graphify ./raw # 特定のフォルダで実行
|
||||
/graphify ./raw --mode deep # より積極的な INFERRED エッジ抽出
|
||||
/graphify ./raw --update # 変更されたファイルのみ再抽出し、既存グラフにマージ
|
||||
/graphify ./raw --cluster-only # 既存グラフのクラスタリングを再実行(再抽出なし)
|
||||
/graphify ./raw --no-viz # HTML をスキップ、レポート + JSON のみ生成
|
||||
/graphify ./raw --obsidian # Obsidian ボールトも生成(オプトイン)
|
||||
/graphify ./raw --obsidian --obsidian-dir ~/vaults/myproject # ボールトを特定のディレクトリに書き込み
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # 論文を取得、保存、グラフを更新
|
||||
/graphify add https://x.com/karpathy/status/... # ツイートを取得
|
||||
/graphify add https://... --author "Name" # 元の著者をタグ付け
|
||||
/graphify add https://... --contributor "Name" # コーパスに追加した人をタグ付け
|
||||
|
||||
/graphify query "アテンションとオプティマイザを結ぶものは?"
|
||||
/graphify query "アテンションとオプティマイザを結ぶものは?" --dfs # 特定のパスをトレース
|
||||
/graphify query "アテンションとオプティマイザを結ぶものは?" --budget 1500 # N トークンで上限設定
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
/graphify ./raw --watch # ファイル変更時にグラフを自動同期(コード:即時、ドキュメント:通知)
|
||||
/graphify ./raw --wiki # エージェントがクロール可能な wiki を構築(index.md + コミュニティごとの記事)
|
||||
/graphify ./raw --svg # graph.svg をエクスポート
|
||||
/graphify ./raw --graphml # graph.graphml をエクスポート(Gephi、yEd)
|
||||
/graphify ./raw --neo4j # Neo4j 用の cypher.txt を生成
|
||||
/graphify ./raw --neo4j-push bolt://localhost:7687 # 実行中の Neo4j インスタンスに直接プッシュ
|
||||
/graphify ./raw --mcp # MCP stdio サーバーを起動
|
||||
|
||||
# git フック - プラットフォーム非依存、コミット時とブランチ切り替え時にグラフを再構築
|
||||
graphify hook install
|
||||
graphify hook uninstall
|
||||
graphify hook status
|
||||
|
||||
# 常時有効のアシスタント指示 - プラットフォーム固有
|
||||
graphify claude install # CLAUDE.md + PreToolUse フック(Claude Code)
|
||||
graphify claude uninstall
|
||||
graphify codex install # AGENTS.md(Codex)
|
||||
graphify opencode install # AGENTS.md(OpenCode)
|
||||
graphify claw install # AGENTS.md(OpenClaw)
|
||||
graphify droid install # AGENTS.md(Factory Droid)
|
||||
|
||||
# ターミナルから直接グラフをクエリ(AI アシスタント不要)
|
||||
graphify query "アテンションとオプティマイザを結ぶものは?"
|
||||
graphify query "認証フローを表示" --dfs
|
||||
graphify query "CfgNode とは?" --budget 500
|
||||
graphify query "..." --graph path/to/graph.json
|
||||
```
|
||||
|
||||
あらゆるファイルタイプの組み合わせで動作します:
|
||||
|
||||
| タイプ | 拡張子 | 抽出方法 |
|
||||
|------|-----------|------------|
|
||||
| コード | `.py .ts .js .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .m .mm` | tree-sitter による AST + コールグラフ + docstring/コメントの根拠 |
|
||||
| ドキュメント | `.md .txt .rst` | Claude による概念 + 関係性 + 設計根拠 |
|
||||
| Office | `.docx .xlsx` | Markdown に変換した後 Claude で抽出(`pip install graphifyy[office]` が必要) |
|
||||
| 論文 | `.pdf` | 引用マイニング + 概念抽出 |
|
||||
| 画像 | `.png .jpg .webp .gif` | Claude Vision - スクリーンショット、図、任意の言語 |
|
||||
|
||||
## 得られるもの
|
||||
|
||||
**ゴッドノード** - 最高次数の概念(すべてが接続するもの)
|
||||
|
||||
**意外なつながり** - 複合スコアでランク付け。コード-論文のエッジはコード-コードよりも高くランクされます。各結果には平易な英語の理由が含まれます。
|
||||
|
||||
**推奨される質問** - グラフがユニークに答えられる 4〜5 の質問
|
||||
|
||||
**「なぜ」** - docstring、インラインコメント(`# NOTE:`、`# IMPORTANT:`、`# HACK:`、`# WHY:`)、ドキュメントからの設計根拠が `rationale_for` ノードとして抽出されます。コードが何をするかだけでなく――なぜそのように書かれたか。
|
||||
|
||||
**信頼度スコア** - すべての INFERRED エッジには `confidence_score`(0.0〜1.0)があります。何が推測されたかだけでなく、モデルがどれだけ確信していたかもわかります。EXTRACTED エッジは常に 1.0 です。
|
||||
|
||||
**意味的類似性エッジ** - 構造的接続のないクロスファイル概念リンク。互いを呼び出さずに同じ問題を解いている 2 つの関数、同じアルゴリズムを記述しているコード内のクラスと論文内の概念など。
|
||||
|
||||
**ハイパーエッジ** - ペアワイズエッジでは表現できない 3+ ノードを接続するグループ関係。共有プロトコルを実装するすべてのクラス、認証フロー内のすべての関数、論文セクションから 1 つのアイデアを形成するすべての概念など。
|
||||
|
||||
**トークンベンチマーク** - 実行ごとに自動的に出力されます。混合コーパス(Karpathy リポジトリ + 論文 + 画像)で、生ファイルを読むのに比べて 1 クエリあたり **71.5 倍** 少ないトークン。最初の実行で抽出とグラフ構築を行います(これにはトークンがかかります)。以降のクエリはすべて生ファイルではなくコンパクトなグラフを読みます――ここで節約が複利的に効いてきます。SHA256 キャッシュにより、再実行時は変更されたファイルのみ再処理されます。
|
||||
|
||||
**自動同期** (`--watch`) - バックグラウンドターミナルで実行し、コードベースが変更されるとグラフが自動的に更新されます。コードファイルの保存は即座の再構築をトリガーします(AST のみ、LLM なし)。ドキュメント/画像の変更は、LLM の再パスのために `--update` を実行するよう通知します。
|
||||
|
||||
**Git フック** (`graphify hook install`) - post-commit と post-checkout フックをインストールします。コミットごと、ブランチ切り替えごとにグラフが自動的に再構築されます。再構築が失敗した場合、フックは非ゼロコードで終了するため、git がエラーを表面化し、静かに続行することはありません。バックグラウンドプロセスは不要です。
|
||||
|
||||
**Wiki** (`--wiki`) - コミュニティごとおよびゴッドノードごとの Wikipedia スタイルの Markdown 記事と、`index.md` エントリポイント。任意のエージェントを `index.md` に向ければ、JSON をパースする代わりにファイルを読むことでナレッジベースをナビゲートできます。
|
||||
|
||||
## 実例
|
||||
|
||||
| コーパス | ファイル数 | 削減率 | 出力 |
|
||||
|--------|-------|-----------|--------|
|
||||
| Karpathy リポジトリ + 論文5本 + 画像4枚 | 52 | **71.5x** | [`worked/karpathy-repos/`](worked/karpathy-repos/) |
|
||||
| graphify ソース + Transformer 論文 | 4 | **5.4x** | [`worked/mixed-corpus/`](worked/mixed-corpus/) |
|
||||
| httpx(合成 Python ライブラリ) | 6 | ~1x | [`worked/httpx/`](worked/httpx/) |
|
||||
|
||||
トークン削減はコーパスサイズに応じてスケールします。6 ファイルはいずれにせよコンテキストウィンドウに収まるため、そこでのグラフの価値は圧縮ではなく構造的明瞭さです。52 ファイル(コード + 論文 + 画像)では 71 倍以上が得られます。各 `worked/` フォルダには生の入力ファイルと実際の出力(`GRAPH_REPORT.md`、`graph.json`)があり、自分で実行して数字を検証できます。
|
||||
|
||||
## プライバシー
|
||||
|
||||
graphify はドキュメント、論文、画像の意味的抽出のために、ファイル内容を AI コーディングアシスタントの基盤モデル API に送信します――Anthropic(Claude Code)、OpenAI(Codex)、またはプラットフォームが使用するプロバイダーです。コードファイルは tree-sitter AST を介してローカルで処理されます――コードに関してはファイル内容がマシンから出ることはありません。テレメトリ、利用追跡、分析は一切ありません。ネットワーク呼び出しは抽出中のプラットフォームのモデル API への呼び出しのみで、あなた自身の API キーを使用します。
|
||||
|
||||
## 技術スタック
|
||||
|
||||
NetworkX + Leiden(graspologic) + tree-sitter + vis.js。意味的抽出は Claude(Claude Code)、GPT-4(Codex)、またはプラットフォームが実行するモデルを介して行われます。Neo4j は不要、サーバーも不要、完全にローカルで実行されます。
|
||||
|
||||
## スター履歴
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
|
||||
<details>
|
||||
<summary>コントリビューション</summary>
|
||||
|
||||
**実例** は最も信頼を築くコントリビューションです。実際のコーパスで `/graphify` を実行し、出力を `worked/{slug}/` に保存し、グラフが正しく捉えたもの・間違えたものを評価する正直な `review.md` を書き、PR を提出してください。
|
||||
|
||||
**抽出バグ** - 入力ファイル、キャッシュエントリ(`graphify-out/cache/`)、何が見逃された/捏造されたかを添えて issue を開いてください。
|
||||
|
||||
モジュールの責任と言語の追加方法については [ARCHITECTURE.md](ARCHITECTURE.md) を参照してください。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,282 @@
|
||||
# graphify
|
||||
|
||||
🇺🇸 [English](../../README.md) | 🇨🇳 [简体中文](README.zh-CN.md) | 🇯🇵 [日本語](README.ja-JP.md) | 🇰🇷 [한국어](README.ko-KR.md) | 🇩🇪 [Deutsch](README.de-DE.md) | 🇫🇷 [Français](README.fr-FR.md) | 🇪🇸 [Español](README.es-ES.md) | 🇮🇳 [हिन्दी](README.hi-IN.md) | 🇧🇷 [Português](README.pt-BR.md) | 🇷🇺 [Русский](README.ru-RU.md) | 🇸🇦 [العربية](README.ar-SA.md) | 🇮🇹 [Italiano](README.it-IT.md) | 🇵🇱 [Polski](README.pl-PL.md) | 🇳🇱 [Nederlands](README.nl-NL.md) | 🇹🇷 [Türkçe](README.tr-TR.md) | 🇺🇦 [Українська](README.uk-UA.md) | 🇻🇳 [Tiếng Việt](README.vi-VN.md) | 🇮🇩 [Bahasa Indonesia](README.id-ID.md) | 🇸🇪 [Svenska](README.sv-SE.md) | 🇬🇷 [Ελληνικά](README.el-GR.md) | 🇷🇴 [Română](README.ro-RO.md) | 🇨🇿 [Čeština](README.cs-CZ.md) | 🇫🇮 [Suomi](README.fi-FI.md) | 🇩🇰 [Dansk](README.da-DK.md) | 🇳🇴 [Norsk](README.no-NO.md) | 🇭🇺 [Magyar](README.hu-HU.md) | 🇹🇭 [ภาษาไทย](README.th-TH.md) | 🇺🇿 [Oʻzbekcha](README.uz-UZ.md) | 🇹🇼 [繁體中文](README.zh-TW.md)
|
||||
|
||||
[](https://github.com/safishamsi/graphify/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/graphifyy/)
|
||||
[](https://github.com/sponsors/safishamsi)
|
||||
|
||||
**AI 코딩 어시스턴트를 위한 스킬.** Claude Code, Codex, OpenCode, OpenClaw, Factory Droid, 또는 Trae에서 `/graphify`를 입력하면 파일을 읽고 지식 그래프를 구축하여, 미처 몰랐던 구조를 보여줍니다. 코드베이스를 더 빠르게 이해하고, 아키텍처 결정의 "이유"를 찾아보세요.
|
||||
|
||||
완전한 멀티모달 지원. 코드, PDF, 마크다운, 스크린샷, 다이어그램, 화이트보드 사진, 심지어 다른 언어로 된 이미지까지 — graphify는 Claude Vision을 사용하여 이 모든 것에서 개념과 관계를 추출하고 하나의 그래프로 연결합니다. tree-sitter AST를 통해 20개 언어를 지원합니다(Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia).
|
||||
|
||||
> Andrej Karpathy는 논문, 트윗, 스크린샷, 메모를 모아두는 `/raw` 폴더를 관리합니다. graphify는 바로 그 문제에 대한 답입니다 — 원본 파일을 직접 읽는 것 대비 쿼리당 토큰 소비가 71.5배 적고, 세션 간에 영속적이며, 발견한 것과 추측한 것을 정직하게 구분합니다.
|
||||
|
||||
```
|
||||
/graphify . # 어떤 폴더든 동작 - 코드베이스, 노트, 논문, 무엇이든
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html 인터랙티브 그래프 - 노드 클릭, 검색, 커뮤니티별 필터
|
||||
├── GRAPH_REPORT.md 갓 노드, 의외의 연결, 추천 질문
|
||||
├── graph.json 영속 그래프 - 몇 주 후에도 재읽기 없이 쿼리 가능
|
||||
└── cache/ SHA256 캐시 - 재실행 시 변경된 파일만 처리
|
||||
```
|
||||
|
||||
그래프에 포함하지 않을 폴더를 제외하려면 `.graphifyignore` 파일을 추가하세요:
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
`.gitignore`와 동일한 문법입니다. 패턴은 graphify를 실행한 폴더 기준의 상대 경로에 대해 매칭됩니다.
|
||||
|
||||
## 동작 원리
|
||||
|
||||
graphify는 두 번의 패스로 실행됩니다. 첫 번째는 결정론적 AST 패스로, 코드 파일에서 구조(클래스, 함수, 임포트, 콜 그래프, docstring, 근거 주석)를 LLM 없이 추출합니다. 두 번째는 Claude 서브에이전트가 문서, 논문, 이미지에 대해 병렬로 실행되어 개념, 관계, 설계 근거를 추출합니다. 결과는 NetworkX 그래프로 병합되고, Leiden 커뮤니티 탐지로 클러스터링되며, 인터랙티브 HTML, 쿼리 가능한 JSON, 그리고 일반 언어 감사 보고서로 내보내집니다.
|
||||
|
||||
**클러스터링은 그래프 토폴로지 기반 — 임베딩을 사용하지 않습니다.** Leiden은 엣지 밀도를 기반으로 커뮤니티를 찾습니다. Claude가 추출하는 의미적 유사성 엣지(`semantically_similar_to`, INFERRED로 표시)는 이미 그래프에 포함되어 있으므로 커뮤니티 탐지에 직접 영향을 줍니다. 그래프 구조 자체가 유사성 신호이며 — 별도의 임베딩 단계나 벡터 데이터베이스가 필요하지 않습니다.
|
||||
|
||||
모든 관계는 `EXTRACTED`(소스에서 직접 발견), `INFERRED`(합리적 추론, 신뢰도 점수 포함), `AMBIGUOUS`(리뷰 필요 표시) 중 하나로 태깅됩니다. 무엇이 발견된 것이고 무엇이 추측된 것인지 항상 알 수 있습니다.
|
||||
|
||||
## 설치
|
||||
|
||||
**필수 요구사항:** Python 3.10+ 및 다음 중 하나: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), 또는 [Trae](https://trae.ai)
|
||||
|
||||
```bash
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> PyPI 패키지는 `graphify` 이름을 되찾는 동안 임시로 `graphifyy`로 명명되어 있습니다. CLI와 스킬 명령은 여전히 `graphify`입니다.
|
||||
|
||||
### 플랫폼 지원
|
||||
|
||||
| 플랫폼 | 설치 명령 |
|
||||
|--------|-----------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (자동 감지) 또는 `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
|
||||
Codex 사용자는 병렬 추출을 위해 `~/.codex/config.toml`의 `[features]` 아래에 `multi_agent = true`도 필요합니다. Factory Droid는 병렬 서브에이전트 디스패치에 `Task` 도구를 사용합니다. OpenClaw는 순차 추출을 사용합니다(해당 플랫폼의 병렬 에이전트 지원은 아직 초기 단계입니다). Trae는 병렬 서브에이전트 디스패치에 Agent 도구를 사용하며 PreToolUse 훅을 **지원하지 않습니다** — AGENTS.md가 상시 작동 메커니즘입니다.
|
||||
|
||||
그런 다음 AI 코딩 어시스턴트를 열고 입력하세요:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
참고: Codex는 스킬 호출에 `/` 대신 `$`를 사용하므로 `$graphify .`라고 입력하세요.
|
||||
|
||||
### 어시스턴트가 항상 그래프를 사용하도록 설정 (권장)
|
||||
|
||||
그래프를 빌드한 후, 프로젝트에서 한 번만 실행하세요:
|
||||
|
||||
| 플랫폼 | 명령 |
|
||||
|--------|------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| OpenClaw | `graphify claw install` |
|
||||
| Factory Droid | `graphify droid install` |
|
||||
| Trae | `graphify trae install` |
|
||||
| Trae CN | `graphify trae-cn install` |
|
||||
|
||||
**Claude Code**는 두 가지를 수행합니다: 아키텍처 질문에 답하기 전에 `graphify-out/GRAPH_REPORT.md`를 읽도록 Claude에게 지시하는 `CLAUDE.md` 섹션을 작성하고, 모든 Glob 및 Grep 호출 전에 실행되는 **PreToolUse 훅**(`settings.json`)을 설치합니다. 지식 그래프가 존재하면 Claude는 다음 메시지를 보게 됩니다: _"graphify: Knowledge graph exists. Read GRAPH_REPORT.md for god nodes and community structure before searching raw files."_ — 이를 통해 Claude는 모든 파일을 grep하는 대신 그래프를 통해 탐색합니다.
|
||||
|
||||
**Codex**는 `AGENTS.md`에 작성하고 Bash 도구 호출 전에 실행되는 **PreToolUse 훅**을 `.codex/hooks.json`에 설치합니다 — Claude Code와 동일한 상시 작동 메커니즘입니다.
|
||||
|
||||
**OpenCode, OpenClaw, Factory Droid, Trae**는 프로젝트 루트의 `AGENTS.md`에 동일한 규칙을 작성합니다. 이 플랫폼들은 PreToolUse 훅을 지원하지 않으므로 AGENTS.md가 상시 작동 메커니즘입니다.
|
||||
|
||||
제거는 대응하는 uninstall 명령으로 수행합니다(예: `graphify claude uninstall`).
|
||||
|
||||
**상시 작동 vs 명시적 트리거 — 차이점은?**
|
||||
|
||||
상시 작동 훅은 `GRAPH_REPORT.md`를 노출합니다 — 갓 노드, 커뮤니티, 의외의 연결을 한 페이지로 요약한 것입니다. 어시스턴트는 파일 검색 전에 이것을 읽으므로 키워드 매칭이 아닌 구조 기반으로 탐색합니다. 이것만으로 대부분의 일상적인 질문을 처리할 수 있습니다.
|
||||
|
||||
`/graphify query`, `/graphify path`, `/graphify explain`은 더 깊이 들어갑니다: 원시 `graph.json`을 홉 단위로 순회하고, 노드 간의 정확한 경로를 추적하며, 엣지 수준의 세부 정보(관계 유형, 신뢰도 점수, 소스 위치)를 보여줍니다. 일반적인 오리엔테이션이 아닌 그래프에서 특정 질문에 답하고 싶을 때 사용하세요.
|
||||
|
||||
이렇게 생각하면 됩니다: 상시 작동 훅은 어시스턴트에게 지도를 주고, `/graphify` 명령은 그 지도를 정확하게 탐색하게 합니다.
|
||||
|
||||
## `graph.json`을 LLM과 함께 사용하기
|
||||
|
||||
`graph.json`은 프롬프트에 한 번에 전부 붙여넣기 위한 것이 아닙니다. 유용한 워크플로우는 다음과 같습니다:
|
||||
|
||||
1. `graphify-out/GRAPH_REPORT.md`로 높은 수준의 개요를 파악합니다.
|
||||
2. `graphify query`를 사용하여 답하려는 특정 질문에 대한 더 작은 서브그래프를 가져옵니다.
|
||||
3. 전체 원시 코퍼스 대신 그 집중된 결과를 어시스턴트에게 제공합니다.
|
||||
|
||||
예를 들어, 프로젝트에서 graphify를 실행한 후:
|
||||
|
||||
```bash
|
||||
graphify query "show the auth flow" --graph graphify-out/graph.json
|
||||
graphify query "what connects DigestAuth to Response?" --graph graphify-out/graph.json
|
||||
```
|
||||
|
||||
출력에는 노드 레이블, 엣지 유형, 신뢰도 태그, 소스 파일, 소스 위치가 포함됩니다. 이는 LLM을 위한 좋은 중간 컨텍스트 블록이 됩니다:
|
||||
|
||||
```text
|
||||
이 그래프 쿼리 결과를 사용하여 질문에 답하세요. 추측보다 그래프 구조를 우선하고,
|
||||
가능한 경우 소스 파일을 인용하세요.
|
||||
```
|
||||
|
||||
어시스턴트가 도구 호출이나 MCP를 지원하는 경우, 텍스트를 붙여넣는 대신 그래프를 직접 사용하세요. graphify는 `graph.json`을 MCP 서버로 노출할 수 있습니다:
|
||||
|
||||
```bash
|
||||
python -m graphify.serve graphify-out/graph.json
|
||||
```
|
||||
|
||||
이를 통해 어시스턴트가 `query_graph`, `get_node`, `get_neighbors`, `shortest_path` 같은 반복 쿼리에 구조화된 그래프 접근을 할 수 있습니다.
|
||||
|
||||
<details>
|
||||
<summary>수동 설치 (curl)</summary>
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/skills/graphify
|
||||
curl -fsSL https://raw.githubusercontent.com/safishamsi/graphify/v3/graphify/skill.md \
|
||||
> ~/.claude/skills/graphify/SKILL.md
|
||||
```
|
||||
|
||||
`~/.claude/CLAUDE.md`에 추가:
|
||||
|
||||
```
|
||||
- **graphify** (`~/.claude/skills/graphify/SKILL.md`) - any input to knowledge graph. Trigger: `/graphify`
|
||||
When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 사용법
|
||||
|
||||
```
|
||||
/graphify # 현재 디렉토리에서 실행
|
||||
/graphify ./raw # 특정 폴더에서 실행
|
||||
/graphify ./raw --mode deep # 더 적극적인 INFERRED 엣지 추출
|
||||
/graphify ./raw --update # 변경된 파일만 재추출하여 기존 그래프에 병합
|
||||
/graphify ./raw --cluster-only # 기존 그래프의 클러스터링만 재실행, 재추출 없음
|
||||
/graphify ./raw --no-viz # HTML 건너뛰기, 보고서 + JSON만 생성
|
||||
/graphify ./raw --obsidian # Obsidian 볼트도 생성 (옵트인)
|
||||
/graphify ./raw --obsidian --obsidian-dir ~/vaults/myproject # 볼트를 특정 디렉토리에 생성
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # 논문 가져오기, 저장, 그래프 업데이트
|
||||
/graphify add https://x.com/karpathy/status/... # 트윗 가져오기
|
||||
/graphify add https://... --author "Name" # 원저자 태그
|
||||
/graphify add https://... --contributor "Name" # 코퍼스에 추가한 사람 태그
|
||||
|
||||
/graphify query "어텐션과 옵티마이저를 연결하는 것은?"
|
||||
/graphify query "어텐션과 옵티마이저를 연결하는 것은?" --dfs # 특정 경로 추적
|
||||
/graphify query "어텐션과 옵티마이저를 연결하는 것은?" --budget 1500 # N 토큰으로 제한
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
/graphify ./raw --watch # 파일 변경 시 그래프 자동 동기화 (코드: 즉시, 문서: 알림)
|
||||
/graphify ./raw --wiki # 에이전트가 크롤 가능한 위키 빌드 (index.md + 커뮤니티별 문서)
|
||||
/graphify ./raw --svg # graph.svg 내보내기
|
||||
/graphify ./raw --graphml # graph.graphml 내보내기 (Gephi, yEd)
|
||||
/graphify ./raw --neo4j # Neo4j용 cypher.txt 생성
|
||||
/graphify ./raw --neo4j-push bolt://localhost:7687 # 실행 중인 Neo4j 인스턴스에 직접 푸시
|
||||
/graphify ./raw --mcp # MCP stdio 서버 시작
|
||||
|
||||
# git 훅 - 플랫폼 무관, 커밋 및 브랜치 전환 시 그래프 재빌드
|
||||
graphify hook install
|
||||
graphify hook uninstall
|
||||
graphify hook status
|
||||
|
||||
# 상시 작동 어시스턴트 지시 - 플랫폼별
|
||||
graphify claude install # CLAUDE.md + PreToolUse 훅 (Claude Code)
|
||||
graphify claude uninstall
|
||||
graphify codex install # AGENTS.md (Codex)
|
||||
graphify opencode install # AGENTS.md (OpenCode)
|
||||
graphify claw install # AGENTS.md (OpenClaw)
|
||||
graphify droid install # AGENTS.md (Factory Droid)
|
||||
graphify trae install # AGENTS.md (Trae)
|
||||
graphify trae uninstall
|
||||
graphify trae-cn install # AGENTS.md (Trae CN)
|
||||
graphify trae-cn uninstall
|
||||
|
||||
# 터미널에서 직접 그래프 쿼리 (AI 어시스턴트 불필요)
|
||||
graphify query "어텐션과 옵티마이저를 연결하는 것은?"
|
||||
graphify query "인증 흐름 보기" --dfs
|
||||
graphify query "CfgNode이 뭐지?" --budget 500
|
||||
graphify query "..." --graph path/to/graph.json
|
||||
```
|
||||
|
||||
다양한 파일 유형의 조합과 함께 동작합니다:
|
||||
|
||||
| 유형 | 확장자 | 추출 방식 |
|
||||
|------|--------|-----------|
|
||||
| 코드 | `.py .ts .js .jsx .tsx .go .rs .java .c .cpp .rb .cs .kt .scala .php .swift .lua .zig .ps1 .ex .exs .m .mm .jl` | tree-sitter AST + 콜 그래프 + docstring/주석 근거 |
|
||||
| 문서 | `.md .txt .rst` | Claude를 통한 개념 + 관계 + 설계 근거 |
|
||||
| 오피스 | `.docx .xlsx` | 마크다운으로 변환 후 Claude를 통해 추출 (`pip install graphifyy[office]` 필요) |
|
||||
| 논문 | `.pdf` | 인용 마이닝 + 개념 추출 |
|
||||
| 이미지 | `.png .jpg .webp .gif` | Claude Vision - 스크린샷, 다이어그램, 모든 언어 |
|
||||
|
||||
## 결과물
|
||||
|
||||
**갓 노드** - 최고 차수의 개념 (모든 것이 연결되는 허브)
|
||||
|
||||
**의외의 연결** - 복합 점수로 순위 지정. 코드-논문 엣지는 코드-코드보다 높게 순위됩니다. 각 결과에는 쉬운 설명이 포함됩니다.
|
||||
|
||||
**추천 질문** - 그래프가 고유하게 답할 수 있는 4~5개의 질문
|
||||
|
||||
**"이유"** - docstring, 인라인 주석(`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`), 문서의 설계 근거가 `rationale_for` 노드로 추출됩니다. 코드가 무엇을 하는지뿐만 아니라 — 왜 그렇게 작성되었는지.
|
||||
|
||||
**신뢰도 점수** - 모든 INFERRED 엣지에는 `confidence_score`(0.0~1.0)가 있습니다. 무엇이 추측되었는지뿐 아니라 모델이 얼마나 확신했는지도 알 수 있습니다. EXTRACTED 엣지는 항상 1.0입니다.
|
||||
|
||||
**의미적 유사성 엣지** - 구조적 연결 없는 파일 간 개념 링크. 서로를 호출하지 않으면서 같은 문제를 해결하는 두 함수, 코드의 클래스와 같은 알고리즘을 설명하는 논문의 개념 등.
|
||||
|
||||
**하이퍼엣지** - 쌍별 엣지로는 표현할 수 없는 3개 이상 노드의 그룹 관계. 공유 프로토콜을 구현하는 모든 클래스, 인증 흐름의 모든 함수, 논문 섹션에서 하나의 아이디어를 구성하는 모든 개념 등.
|
||||
|
||||
**토큰 벤치마크** - 매 실행 후 자동으로 출력됩니다. 혼합 코퍼스(Karpathy 리포지토리 + 논문 + 이미지)에서: 원본 파일 대비 쿼리당 **71.5배** 적은 토큰. 첫 실행은 추출과 그래프 빌드를 수행합니다(토큰이 소비됩니다). 이후 모든 쿼리는 원본 파일 대신 압축된 그래프를 읽습니다 — 여기서 절약이 복리로 누적됩니다. SHA256 캐시로 재실행 시 변경된 파일만 재처리합니다.
|
||||
|
||||
**자동 동기화** (`--watch`) - 백그라운드 터미널에서 실행하면 코드베이스가 변경될 때 그래프가 자동으로 업데이트됩니다. 코드 파일 저장 시 즉시 재빌드가 트리거됩니다(AST만, LLM 없음). 문서/이미지 변경 시에는 LLM 재처리를 위해 `--update` 실행을 알려줍니다.
|
||||
|
||||
**Git 훅** (`graphify hook install`) - post-commit 및 post-checkout 훅을 설치합니다. 모든 커밋과 브랜치 전환 후 그래프가 자동으로 재빌드됩니다. 재빌드가 실패하면 훅이 0이 아닌 코드로 종료하여 git이 에러를 표시하고 조용히 계속 진행하지 않습니다. 백그라운드 프로세스가 필요 없습니다.
|
||||
|
||||
**위키** (`--wiki`) - 커뮤니티 및 갓 노드별 위키피디아 스타일 마크다운 문서와 `index.md` 진입점. 어떤 에이전트든 `index.md`를 가리키면 JSON을 파싱하는 대신 파일을 읽어서 지식 베이스를 탐색할 수 있습니다.
|
||||
|
||||
## 실전 예제
|
||||
|
||||
| 코퍼스 | 파일 수 | 축소율 | 결과 |
|
||||
|--------|---------|--------|------|
|
||||
| Karpathy 리포지토리 + 논문 5편 + 이미지 4장 | 52 | **71.5x** | [`worked/karpathy-repos/`](worked/karpathy-repos/) |
|
||||
| graphify 소스 + Transformer 논문 | 4 | **5.4x** | [`worked/mixed-corpus/`](worked/mixed-corpus/) |
|
||||
| httpx (합성 Python 라이브러리) | 6 | ~1x | [`worked/httpx/`](worked/httpx/) |
|
||||
|
||||
토큰 축소는 코퍼스 크기에 비례하여 확장됩니다. 6개 파일은 어차피 컨텍스트 윈도우에 들어가므로, 그래프의 가치는 압축이 아닌 구조적 명확성에 있습니다. 52개 파일(코드 + 논문 + 이미지)에서는 71배 이상을 달성합니다. 각 `worked/` 폴더에는 원본 입력 파일과 실제 출력(`GRAPH_REPORT.md`, `graph.json`)이 있어 직접 실행하여 수치를 검증할 수 있습니다.
|
||||
|
||||
## 개인정보 보호
|
||||
|
||||
graphify는 문서, 논문, 이미지의 의미적 추출을 위해 파일 내용을 AI 코딩 어시스턴트의 기반 모델 API로 전송합니다 — Anthropic(Claude Code), OpenAI(Codex), 또는 사용 중인 플랫폼의 제공자. 코드 파일은 tree-sitter AST를 통해 로컬에서 처리됩니다 — 코드의 경우 파일 내용이 사용자의 머신을 벗어나지 않습니다. 어떠한 텔레메트리, 사용 추적, 분석도 없습니다. 유일한 네트워크 호출은 추출 중 플랫폼 모델 API에 대한 것이며, 사용자 본인의 API 키를 사용합니다.
|
||||
|
||||
## 기술 스택
|
||||
|
||||
NetworkX + Leiden (graspologic) + tree-sitter + vis.js. 의미적 추출은 Claude(Claude Code), GPT-4(Codex), 또는 플랫폼이 실행하는 모델을 통해 수행됩니다. Neo4j 불필요, 서버 불필요, 완전히 로컬에서 실행됩니다.
|
||||
|
||||
## 다음 계획
|
||||
|
||||
graphify는 그래프 레이어입니다. 그 위에 [Penpax](https://safishamsi.github.io/penpax.ai)를 개발하고 있습니다 — 회의, 브라우저 기록, 파일, 이메일, 코드를 하나의 지속적으로 업데이트되는 지식 그래프로 연결하는 온디바이스 디지털 트윈입니다. 클라우드 없음, 데이터 학습 없음. [대기 목록에 등록하세요.](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
## 스타 히스토리
|
||||
|
||||
[](https://starchart.cc/safishamsi/graphify)
|
||||
|
||||
<details>
|
||||
<summary>기여하기</summary>
|
||||
|
||||
**실전 예제**는 가장 신뢰를 쌓는 기여 방식입니다. 실제 코퍼스에서 `/graphify`를 실행하고, 결과를 `worked/{slug}/`에 저장하고, 그래프가 맞게 파악한 것과 틀린 것을 평가하는 솔직한 `review.md`를 작성하여 PR을 제출하세요.
|
||||
|
||||
**추출 버그** - 입력 파일, 캐시 엔트리(`graphify-out/cache/`), 그리고 누락되거나 날조된 내용과 함께 이슈를 열어주세요.
|
||||
|
||||
모듈 책임과 언어 추가 방법은 [ARCHITECTURE.md](ARCHITECTURE.md)를 참조하세요.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Een vaardigheid voor AI-codeassistenten.** Typ `/graphify` in Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro of Google Antigravity — het leest je bestanden, bouwt een kennisgraaf en geeft je structuur terug die je niet wist dat er was. Begrijp een codebase sneller. Vind het "waarom" achter architecturale beslissingen.
|
||||
|
||||
Volledig multimodaal. Voeg code, PDF's, markdown, schermafbeeldingen, diagrammen, whiteboard-foto's, afbeeldingen in andere talen of video- en audiobestanden toe — graphify extraheert concepten en relaties uit alles en verbindt ze in één graaf. Video's worden lokaal getranscribeerd met Whisper. Ondersteunt 25 programmeertalen via tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy houdt een `/raw`-map bij waar hij papers, tweets, schermafbeeldingen en notities neerlegt. graphify is het antwoord op dat probleem — **71,5x** minder tokens per query versus het lezen van ruwe bestanden, persistent tussen sessies.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interactieve graaf — open in elke browser
|
||||
├── GRAPH_REPORT.md godknooppunten, verrassende verbindingen, voorgestelde vragen
|
||||
├── graph.json persistente graaf — weken later opvraagbaar
|
||||
└── cache/ SHA256-cache — herhaalde runs verwerken alleen gewijzigde bestanden
|
||||
```
|
||||
|
||||
## Hoe het werkt
|
||||
|
||||
graphify werkt in drie passes. Eerst extraheert een deterministische AST-pass structuur uit codebestanden zonder LLM. Vervolgens worden video- en audiobestanden lokaal getranscribeerd met faster-whisper. Ten slotte werken Claude-subagenten parallel over documenten, papers, afbeeldingen en transcripties. De resultaten worden samengevoegd in een NetworkX-graaf, geclusterd met Leiden en geëxporteerd als interactieve HTML, opvraagbare JSON en een auditrapport.
|
||||
|
||||
Elke relatie is gelabeld als `EXTRACTED`, `INFERRED` (met betrouwbaarheidsscore) of `AMBIGUOUS`.
|
||||
|
||||
## Installatie
|
||||
|
||||
**Vereisten:** Python 3.10+ en één van: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [Cursor](https://cursor.com), [Aider](https://aider.chat) en andere.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# of met pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# of pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Officieel pakket:** Het PyPI-pakket heet `graphifyy`. De enige officiële repository is [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Gebruik
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "wat verbindt Attention met de optimizer?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Wat je krijgt
|
||||
|
||||
**Godknooppunten** — concepten met de hoogste graad · **Verrassende verbindingen** — gerangschikt op score · **Voorgestelde vragen** · **Het "waarom"** — docstrings en ontwerprationale als knooppunten · **Tokenbenchmark** — **71,5x** minder tokens op gemengd corpus.
|
||||
|
||||
## Privacy
|
||||
|
||||
Codebestanden worden lokaal verwerkt via tree-sitter AST. Video's lokaal getranscribeerd met faster-whisper. Geen telemetrie.
|
||||
|
||||
## Gebouwd op graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) is de enterprise-laag boven op graphify. **Gratis proefversie binnenkort.** [Meld je aan voor de wachtlijst →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**En ferdighet for AI-kodeassistenter.** Skriv `/graphify` i Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro eller Google Antigravity — den leser filene dine, bygger en kunnskapsgraf og gir deg tilbake strukturen du ikke visste eksisterte. Forstå en kodebase raskere. Finn «hvorfor» bak arkitektoniske beslutninger.
|
||||
|
||||
Fullt multimodal. Legg til kode, PDF-er, markdown, skjermbilder, diagrammer, whiteboardbilder, bilder på andre språk eller video- og lydfiler — graphify ekstraherer begreper og relasjoner fra alt og kobler dem i én graf. Videoer transkriberes lokalt med Whisper. Støtter 25 programmeringsspråk via tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy opprettholder en `/raw`-mappe der han legger artikler, tweets, skjermbilder og notater. graphify er svaret på det problemet — **71,5x** færre tokens per spørring sammenlignet med å lese råfiler, vedvarende mellom sesjoner.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interaktiv graf — åpne i en hvilken som helst nettleser
|
||||
├── GRAPH_REPORT.md gudnoder, overraskende forbindelser, foreslåtte spørsmål
|
||||
├── graph.json vedvarende graf — forespørselbar uker senere
|
||||
└── cache/ SHA256-cache — gjentatte kjøringer behandler bare endrede filer
|
||||
```
|
||||
|
||||
## Hvordan det fungerer
|
||||
|
||||
graphify arbeider i tre gjennomganger. Først ekstraherer et deterministisk AST-gjennomgang struktur fra kodefiler uten LLM. Deretter transkriberes video- og lydfiler lokalt med faster-whisper. Til slutt kjører Claude-underagenter parallelt på dokumenter, artikler, bilder og transkripsjoner. Resultatene slås sammen i en NetworkX-graf, klynges med Leiden og eksporteres som interaktiv HTML, forespørselbar JSON og revisjonsrapport.
|
||||
|
||||
Hver relasjon er merket `EXTRACTED`, `INFERRED` (med konfidenspoeng) eller `AMBIGUOUS`.
|
||||
|
||||
## Installasjon
|
||||
|
||||
**Krav:** Python 3.10+ og én av: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) og andre.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# eller med pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# eller pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Offisiell pakke:** PyPI-pakken heter `graphifyy`. Det eneste offisielle depotet er [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Bruk
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "hva kobler Attention til optimizeren?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Hva du får
|
||||
|
||||
**Gudnoder** — begreper med høyest grad · **Overraskende forbindelser** — rangert etter poeng · **Foreslåtte spørsmål** · **«Hvorfor»** — docstrings og designbegrunnelse ekstrahert som noder · **Token-benchmark** — **71,5x** færre tokens på blandet korpus.
|
||||
|
||||
## Personvern
|
||||
|
||||
Kodefiler behandles lokalt via tree-sitter AST. Videoer transkriberes lokalt med faster-whisper. Ingen telemetri.
|
||||
|
||||
## Bygget på graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) er enterprise-laget oppå graphify. **Gratis prøveperiode kommer snart.** [Bli med på ventelisten →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,78 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Umiejętność dla asystenta kodowania AI.** Wpisz `/graphify` w Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro lub Google Antigravity — czyta Twoje pliki, buduje graf wiedzy i zwraca Ci strukturę, o której nie wiedziałeś, że istnieje. Rozumiej bazę kodu szybciej. Znajdź „dlaczego" za decyzjami architektonicznymi.
|
||||
|
||||
W pełni multimodalny. Dodaj kod, PDF, markdown, zrzuty ekranu, diagramy, zdjęcia tablic, obrazy w innych językach lub pliki wideo i audio — graphify wyodrębnia koncepcje i relacje ze wszystkiego i łączy je w jeden graf. Wideo są transkrybowane lokalnie za pomocą Whisper. Obsługuje 25 języków programowania przez tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy prowadzi folder `/raw`, gdzie wrzuca artykuły, tweety, zrzuty ekranu i notatki. graphify jest odpowiedzią na ten problem — **71,5x** mniej tokenów na zapytanie w porównaniu z czytaniem surowych plików, trwały między sesjami.
|
||||
|
||||
```
|
||||
/graphify . # działa na dowolnym folderze
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interaktywny graf — otwórz w dowolnej przeglądarce
|
||||
├── GRAPH_REPORT.md węzły boga, zaskakujące połączenia, sugerowane pytania
|
||||
├── graph.json trwały graf — zapytaj tygodnie później
|
||||
└── cache/ cache SHA256 — ponowne uruchomienia przetwarzają tylko zmienione pliki
|
||||
```
|
||||
|
||||
## Jak to działa
|
||||
|
||||
graphify działa w trzech przebiegach. Najpierw deterministyczny przebieg AST wyodrębnia strukturę z plików kodu bez LLM. Następnie pliki wideo i audio są transkrybowane lokalnie za pomocą faster-whisper. Na koniec subagenci Claude działają równolegle na dokumentach, artykułach, obrazach i transkrypcjach. Wyniki są łączone w graf NetworkX, grupowane za pomocą Leiden i eksportowane jako interaktywny HTML, JSON i raport audytu.
|
||||
|
||||
Każda relacja jest oznaczona `EXTRACTED`, `INFERRED` (z wynikiem pewności) lub `AMBIGUOUS`.
|
||||
|
||||
## Instalacja
|
||||
|
||||
**Wymagania:** Python 3.10+ i jedno z: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) i inne.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# lub z pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# lub pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Oficjalny pakiet:** Pakiet PyPI nazywa się `graphifyy`. Jedyne oficjalne repozytorium to [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Użycie
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update # tylko zmienione pliki
|
||||
/graphify ./raw --mode deep
|
||||
/graphify query "co łączy Attention z optymalizatorem?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Co otrzymujesz
|
||||
|
||||
**Węzły boga** — koncepcje o najwyższym stopniu · **Zaskakujące połączenia** — posortowane według wyniku · **Sugerowane pytania** — 4-5 pytań, na które graf jest wyjątkowo zdolny odpowiedzieć · **„Dlaczego"** — docstringi i uzasadnienia projektowe wyodrębnione jako węzły · **Benchmark tokenów** — **71,5x** mniej tokenów na mieszanym korpusie.
|
||||
|
||||
## Prywatność
|
||||
|
||||
Pliki kodu są przetwarzane lokalnie przez tree-sitter AST. Wideo transkrybowane lokalnie z faster-whisper. Brak telemetrii.
|
||||
|
||||
## Zbudowane na graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) to warstwa enterprise nad graphify. **Bezpłatna wersja próbna wkrótce.** [Dołącz do listy oczekujących →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,170 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
</p>
|
||||
|
||||
**Uma habilidade para assistentes de código IA.** Digite `/graphify` no Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro ou Google Antigravity — ele lê seus arquivos, constrói um grafo de conhecimento e devolve a você estrutura que você não sabia que existia. Entenda uma base de código mais rapidamente. Encontre o "porquê" por trás das decisões arquiteturais.
|
||||
|
||||
Totalmente multimodal. Adicione código, PDFs, markdown, capturas de tela, diagramas, fotos de quadros brancos, imagens em outros idiomas, ou arquivos de vídeo e áudio — graphify extrai conceitos e relações de tudo isso e os conecta em um único grafo. Vídeos são transcritos localmente com Whisper usando um prompt adaptado ao domínio derivado do seu corpus. 25 linguagens de programação suportadas via tree-sitter AST (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart).
|
||||
|
||||
> Andrej Karpathy mantém uma pasta `/raw` onde deposita papers, tweets, capturas de tela e notas. graphify é a resposta para esse problema — 71,5x menos tokens por consulta versus ler os arquivos brutos, persistente entre sessões, honesto sobre o que foi encontrado versus inferido.
|
||||
|
||||
```
|
||||
/graphify . # funciona em qualquer pasta — seu código, notas, papers, tudo
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html grafo interativo — abrir em qualquer navegador, clicar em nós, pesquisar
|
||||
├── GRAPH_REPORT.md nós deus, conexões surpreendentes, perguntas sugeridas
|
||||
├── graph.json grafo persistente — consultar semanas depois sem reler
|
||||
└── cache/ cache SHA256 — re-execuções processam apenas arquivos modificados
|
||||
```
|
||||
|
||||
Adicione um arquivo `.graphifyignore` para excluir pastas:
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
Mesma sintaxe do `.gitignore`.
|
||||
|
||||
## Como funciona
|
||||
|
||||
graphify executa em três passes. Primeiro, uma passagem AST determinística extrai estrutura de arquivos de código (classes, funções, importações, grafos de chamadas, docstrings, comentários de justificativa) sem LLM. Segundo, arquivos de vídeo e áudio são transcritos localmente com faster-whisper. Terceiro, subagentes Claude executam em paralelo sobre documentos, papers, imagens e transcrições para extrair conceitos, relações e justificativas de design. Os resultados são mesclados em um grafo NetworkX, agrupados com detecção de comunidades Leiden, e exportados como HTML interativo, JSON consultável e um relatório de auditoria em linguagem natural.
|
||||
|
||||
**O clustering é baseado em topologia de grafo — sem embeddings.** Leiden encontra comunidades por densidade de arestas. As arestas de similaridade semântica que Claude extrai (`semantically_similar_to`, marcadas INFERRED) já estão no grafo. A estrutura do grafo é o sinal de similaridade — nenhum passo de embedding separado ou banco de dados vetorial é necessário.
|
||||
|
||||
Cada relação é marcada como `EXTRACTED` (encontrada diretamente na fonte), `INFERRED` (inferência razoável com pontuação de confiança) ou `AMBIGUOUS` (marcada para revisão).
|
||||
|
||||
## Instalação
|
||||
|
||||
**Requisitos:** Python 3.10+ e um de: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes ou [Google Antigravity](https://antigravity.google)
|
||||
|
||||
```bash
|
||||
# Recomendado — funciona no Mac e Linux sem configurar o PATH
|
||||
uv tool install graphifyy && graphify install
|
||||
# ou com pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# ou pip simples
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Pacote oficial:** O pacote PyPI chama-se `graphifyy` (instalar com `pip install graphifyy`). Outros pacotes chamados `graphify*` no PyPI não são afiliados a este projeto. O único repositório oficial é [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
### Suporte a plataformas
|
||||
|
||||
| Plataforma | Comando de instalação |
|
||||
|------------|-----------------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (detecção automática) ou `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
Depois abra seu assistente de código IA e digite:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
Nota: Codex usa `$` em vez de `/` para habilidades, então digite `$graphify .`.
|
||||
|
||||
### Fazer o assistente sempre usar o grafo (recomendado)
|
||||
|
||||
Após construir um grafo, execute isso uma vez no seu projeto:
|
||||
|
||||
| Plataforma | Comando |
|
||||
|------------|---------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
## Uso
|
||||
|
||||
```
|
||||
/graphify # diretório atual
|
||||
/graphify ./raw # pasta específica
|
||||
/graphify ./raw --mode deep # extração de arestas INFERRED mais agressiva
|
||||
/graphify ./raw --update # re-extrair apenas arquivos modificados
|
||||
/graphify ./raw --directed # grafo dirigido
|
||||
/graphify ./raw --cluster-only # re-executar clustering no grafo existente
|
||||
/graphify ./raw --no-viz # sem HTML, apenas relatório + JSON
|
||||
/graphify ./raw --obsidian # gerar vault do Obsidian (opt-in)
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # buscar um paper
|
||||
/graphify add <video-url> # baixar áudio, transcrever, adicionar
|
||||
/graphify query "o que conecta Attention ao otimizador?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify hook install # instalar hooks do Git
|
||||
graphify update ./src # re-extrair arquivos de código, sem LLM
|
||||
graphify watch ./src # atualização automática do grafo
|
||||
```
|
||||
|
||||
## O que você obtém
|
||||
|
||||
**Nós deus** — conceitos com maior grau (por onde tudo passa)
|
||||
|
||||
**Conexões surpreendentes** — classificadas por pontuação composta. Arestas código-paper pontuam mais alto. Cada resultado inclui um porquê em linguagem natural.
|
||||
|
||||
**Perguntas sugeridas** — 4-5 perguntas que o grafo está em posição única de responder
|
||||
|
||||
**O "porquê"** — docstrings, comentários inline (`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`), e justificativas de design extraídas como nós `rationale_for`.
|
||||
|
||||
**Pontuações de confiança** — cada aresta INFERRED tem um `confidence_score` (0,0-1,0).
|
||||
|
||||
**Benchmark de tokens** — impresso automaticamente após cada execução. Em um corpus misto: **71,5x** menos tokens por consulta vs arquivos brutos.
|
||||
|
||||
**Sincronização automática** (`--watch`) — atualiza o grafo automaticamente quando o código muda.
|
||||
|
||||
**Hooks do Git** (`graphify hook install`) — instala hooks post-commit e post-checkout.
|
||||
|
||||
## Privacidade
|
||||
|
||||
graphify envia conteúdo de arquivos para a API do modelo do seu assistente IA para extração semântica de documentos, papers e imagens. Arquivos de código são processados localmente via tree-sitter AST. Arquivos de vídeo e áudio são transcritos localmente com faster-whisper. Sem telemetria, sem rastreamento de uso.
|
||||
|
||||
## Stack técnico
|
||||
|
||||
NetworkX + Leiden (graspologic) + tree-sitter + vis.js. Extração semântica via Claude, GPT-4 ou o modelo da sua plataforma. Transcrição de vídeo via faster-whisper + yt-dlp (opcional).
|
||||
|
||||
## Construído sobre graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) é a camada enterprise sobre o graphify. Onde o graphify transforma uma pasta de arquivos em um grafo de conhecimento, o Penpax aplica o mesmo grafo a toda a sua vida profissional — continuamente.
|
||||
|
||||
**Teste gratuito em breve.** [Entrar na lista de espera →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
## Histórico de estrelas
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**O abilitate pentru asistenții de cod AI.** Tastați `/graphify` în Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro sau Google Antigravity — citește fișierele dvs., construiește un graf de cunoștințe și vă returnează structura pe care nu știați că există. Înțelegeți mai rapid o bază de cod. Găsiți „de ce"-ul din spatele deciziilor arhitecturale.
|
||||
|
||||
Complet multimodal. Adăugați cod, PDF-uri, markdown, capturi de ecran, diagrame, fotografii cu tablă albă, imagini în alte limbi sau fișiere video și audio — graphify extrage concepte și relații din toate și le conectează într-un singur graf. Videoclipurile sunt transcrise local cu Whisper. Suportă 25 de limbaje de programare prin tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy menține un folder `/raw` unde depune lucrări, tweet-uri, capturi de ecran și note. graphify este răspunsul la această problemă — **71,5x** mai puțini token pe interogare față de citirea fișierelor brute, persistent între sesiuni.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html graf interactiv — deschideți în orice browser
|
||||
├── GRAPH_REPORT.md noduri-zeu, conexiuni surprinzătoare, întrebări sugerate
|
||||
├── graph.json graf persistent — interogabil săptămâni mai târziu
|
||||
└── cache/ cache SHA256 — rulările repetate procesează doar fișierele modificate
|
||||
```
|
||||
|
||||
## Cum funcționează
|
||||
|
||||
graphify lucrează în trei treceri. Mai întâi, o trecere AST deterministă extrage structura din fișierele de cod fără LLM. Apoi fișierele video și audio sunt transcrise local cu faster-whisper. În final, sub-agenții Claude rulează în paralel pe documente, lucrări, imagini și transcrieri. Rezultatele sunt îmbinate într-un graf NetworkX, grupate cu Leiden și exportate ca HTML interactiv, JSON interogabil și raport de audit.
|
||||
|
||||
Fiecare relație este etichetată `EXTRACTED`, `INFERRED` (cu scor de încredere) sau `AMBIGUOUS`.
|
||||
|
||||
## Instalare
|
||||
|
||||
**Cerințe:** Python 3.10+ și unul din: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) și altele.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# sau cu pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# sau pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Pachet oficial:** Pachetul PyPI se numește `graphifyy`. Singurul depozit oficial este [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Utilizare
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "ce conectează Attention cu optimizatorul?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Ce obțineți
|
||||
|
||||
**Noduri-zeu** — concepte cu cel mai mare grad · **Conexiuni surprinzătoare** — clasificate după scor · **Întrebări sugerate** · **„De ce"** — docstring-uri și raționale de design extrase ca noduri · **Benchmark token** — **71,5x** mai puțini token pe corpus mixt.
|
||||
|
||||
## Confidențialitate
|
||||
|
||||
Fișierele de cod sunt procesate local prin tree-sitter AST. Videoclipurile sunt transcrise local cu faster-whisper. Fără telemetrie.
|
||||
|
||||
## Construit pe graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) este stratul enterprise peste graphify. **Perioadă de probă gratuită în curând.** [Alăturați-vă listei de așteptare →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,170 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
</p>
|
||||
|
||||
**Навык для AI-ассистента по написанию кода.** Введите `/graphify` в Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro или Google Antigravity — он прочитает ваши файлы, построит граф знаний и вернёт вам структуру, о существовании которой вы не подозревали. Понимайте кодовую базу быстрее. Находите «почему» за архитектурными решениями.
|
||||
|
||||
Полностью мультимодальный. Добавляйте код, PDF, markdown, скриншоты, диаграммы, фотографии досок, изображения на других языках, видео и аудиофайлы — graphify извлекает концепции и связи из всего этого и объединяет их в один граф. Видео транскрибируются локально с Whisper, используя доменный промпт из вашего корпуса. Поддерживается 25 языков программирования через tree-sitter AST (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart).
|
||||
|
||||
> Андрей Карпати ведёт папку `/raw`, куда складывает статьи, твиты, скриншоты и заметки. graphify — ответ на эту проблему: в **71,5 раза** меньше токенов на запрос по сравнению с чтением сырых файлов, сохранение между сессиями, честность относительно того, что найдено, а что выведено.
|
||||
|
||||
```
|
||||
/graphify . # работает с любой папкой — код, заметки, статьи, всё что угодно
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html интерактивный граф — открыть в браузере, кликать по узлам, искать, фильтровать
|
||||
├── GRAPH_REPORT.md бог-узлы, неожиданные связи, предлагаемые вопросы
|
||||
├── graph.json постоянный граф — запрашивать через недели без повторного чтения
|
||||
└── cache/ SHA256-кэш — повторные запуски обрабатывают только изменённые файлы
|
||||
```
|
||||
|
||||
Добавьте файл `.graphifyignore` для исключения папок:
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
Синтаксис аналогичен `.gitignore`.
|
||||
|
||||
## Как это работает
|
||||
|
||||
graphify работает в три прохода. Сначала детерминированный AST-проход извлекает структуру из файлов кода (классы, функции, импорты, графы вызовов, docstrings, комментарии с обоснованием) — без LLM. Затем видео и аудиофайлы транскрибируются локально с faster-whisper. Наконец, Claude-субагенты запускаются параллельно над документами, статьями, изображениями и транскриптами для извлечения концепций, связей и обоснований дизайна. Результаты объединяются в граф NetworkX, кластеризуются с помощью Leiden-детекции сообществ и экспортируются как интерактивный HTML, запрашиваемый JSON и аудит-отчёт на естественном языке.
|
||||
|
||||
**Кластеризация основана на топологии графа — без эмбеддингов.** Leiden находит сообщества по плотности рёбер. Рёбра семантического сходства, извлечённые Claude (`semantically_similar_to`, помечены как INFERRED), уже в графе. Структура графа — это сигнал сходства. Отдельный шаг с эмбеддингами или векторная база данных не нужны.
|
||||
|
||||
Каждая связь помечена как `EXTRACTED` (найдена непосредственно в источнике), `INFERRED` (обоснованный вывод с оценкой уверенности) или `AMBIGUOUS` (помечена для проверки).
|
||||
|
||||
## Установка
|
||||
|
||||
**Требования:** Python 3.10+ и одно из: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes или [Google Antigravity](https://antigravity.google)
|
||||
|
||||
```bash
|
||||
# Рекомендуется — работает на Mac и Linux без настройки PATH
|
||||
uv tool install graphifyy && graphify install
|
||||
# или с pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# или обычный pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Официальный пакет:** Пакет PyPI называется `graphifyy` (установить через `pip install graphifyy`). Другие пакеты с именем `graphify*` на PyPI не связаны с этим проектом. Единственный официальный репозиторий — [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
### Поддержка платформ
|
||||
|
||||
| Платформа | Команда установки |
|
||||
|-----------|-------------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (авто-определение) или `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
Затем откройте AI-ассистент и введите:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
Примечание: Codex использует `$` вместо `/` для навыков, поэтому вводите `$graphify .`.
|
||||
|
||||
### Заставить ассистента всегда использовать граф (рекомендуется)
|
||||
|
||||
После построения графа выполните это один раз в вашем проекте:
|
||||
|
||||
| Платформа | Команда |
|
||||
|-----------|---------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
## Использование
|
||||
|
||||
```
|
||||
/graphify # текущая директория
|
||||
/graphify ./raw # конкретная папка
|
||||
/graphify ./raw --mode deep # более агрессивное извлечение INFERRED-рёбер
|
||||
/graphify ./raw --update # повторно извлечь только изменённые файлы
|
||||
/graphify ./raw --directed # направленный граф
|
||||
/graphify ./raw --cluster-only # перезапустить кластеризацию на существующем графе
|
||||
/graphify ./raw --no-viz # без HTML, только отчёт + JSON
|
||||
/graphify ./raw --obsidian # создать Obsidian vault (opt-in)
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # получить статью
|
||||
/graphify add <video-url> # скачать аудио, транскрибировать, добавить
|
||||
/graphify query "что связывает Attention с оптимизатором?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify hook install # установить Git-хуки
|
||||
graphify update ./src # повторно извлечь файлы кода, без LLM
|
||||
graphify watch ./src # автоматическое обновление графа
|
||||
```
|
||||
|
||||
## Что вы получаете
|
||||
|
||||
**Бог-узлы** — концепции с наибольшей степенью (через которые проходит всё)
|
||||
|
||||
**Неожиданные связи** — отсортированы по составному баллу. Рёбра код-статья получают более высокий рейтинг. Каждый результат содержит объяснение «почему» на естественном языке.
|
||||
|
||||
**Предлагаемые вопросы** — 4-5 вопросов, на которые граф уникально способен ответить
|
||||
|
||||
**«Почему»** — docstrings, встроенные комментарии (`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`), и обоснования дизайна из документов извлекаются как узлы `rationale_for`.
|
||||
|
||||
**Оценки уверенности** — каждое INFERRED-ребро имеет `confidence_score` (0,0-1,0).
|
||||
|
||||
**Бенчмарк токенов** — выводится автоматически после каждого запуска. На смешанном корпусе: **71,5-кратное** сокращение токенов на запрос vs сырые файлы.
|
||||
|
||||
**Авто-синхронизация** (`--watch`) — обновляет граф автоматически при изменении кода.
|
||||
|
||||
**Git-хуки** (`graphify hook install`) — устанавливает post-commit и post-checkout хуки.
|
||||
|
||||
## Конфиденциальность
|
||||
|
||||
graphify отправляет содержимое файлов в API модели вашего AI-ассистента для семантического извлечения из документов, статей и изображений. Файлы кода обрабатываются локально через tree-sitter AST. Видео и аудиофайлы транскрибируются локально с faster-whisper. Никакой телеметрии, никакого отслеживания использования.
|
||||
|
||||
## Технологический стек
|
||||
|
||||
NetworkX + Leiden (graspologic) + tree-sitter + vis.js. Семантическое извлечение через Claude, GPT-4 или модель вашей платформы. Транскрипция видео через faster-whisper + yt-dlp (опционально).
|
||||
|
||||
## Построено на graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) — корпоративный слой поверх graphify. Там, где graphify превращает папку с файлами в граф знаний, Penpax применяет тот же граф ко всей вашей рабочей жизни — непрерывно.
|
||||
|
||||
**Бесплатный пробный период скоро.** [Вступить в список ожидания →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
## История звёзд
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**En färdighet för AI-kodassistenter.** Skriv `/graphify` i Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro eller Google Antigravity — den läser dina filer, bygger ett kunskapsgrafer och ger tillbaka strukturen du inte visste fanns. Förstå en kodbas snabbare. Hitta "varför" bakom arkitekturella beslut.
|
||||
|
||||
Helt multimodal. Lägg till kod, PDF:er, markdown, skärmdumpar, diagram, whiteboardfoton, bilder på andra språk eller video- och ljudfiler — graphify extraherar begrepp och relationer från allt och kopplar samman dem i ett enda graf. Videor transkriberas lokalt med Whisper. Stödjer 25 programmeringsspråk via tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy håller en `/raw`-mapp där han lägger papper, tweets, skärmdumpar och anteckningar. graphify är svaret på det problemet — **71,5x** färre tokens per fråga jämfört med att läsa råfiler, beständigt mellan sessioner.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interaktivt diagram — öppna i valfri webbläsare
|
||||
├── GRAPH_REPORT.md gudnoder, överraskande kopplingar, föreslagna frågor
|
||||
├── graph.json beständigt diagram — kan frågas veckor senare
|
||||
└── cache/ SHA256-cache — upprepade körningar behandlar bara ändrade filer
|
||||
```
|
||||
|
||||
## Hur det fungerar
|
||||
|
||||
graphify arbetar i tre pass. Först extraherar ett deterministiskt AST-pass struktur från kodfiler utan LLM. Sedan transkriberas video- och ljudfiler lokalt med faster-whisper. Slutligen kör Claude-subagenter parallellt på dokument, papper, bilder och transkriptioner. Resultaten slås samman i ett NetworkX-diagram, klustras med Leiden och exporteras som interaktiv HTML, frågebar JSON och revisionsrapport.
|
||||
|
||||
Varje relation är märkt `EXTRACTED`, `INFERRED` (med konfidenspoäng) eller `AMBIGUOUS`.
|
||||
|
||||
## Installation
|
||||
|
||||
**Krav:** Python 3.10+ och ett av: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) med flera.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# eller med pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# eller pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Officiellt paket:** PyPI-paketet heter `graphifyy`. Det enda officiella förrådet är [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Användning
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "vad kopplar Attention till optimizern?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Vad du får
|
||||
|
||||
**Gudnoder** — begrepp med högst grad · **Överraskande kopplingar** — rangordnade efter poäng · **Föreslagna frågor** · **"Varför"** — docsträngar och designmotivering extraherade som noder · **Token-benchmark** — **71,5x** färre tokens på blandat korpus.
|
||||
|
||||
## Integritet
|
||||
|
||||
Kodfiler behandlas lokalt via tree-sitter AST. Videor transkriberas lokalt med faster-whisper. Ingen telemetri.
|
||||
|
||||
## Byggt på graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) är enterprise-lagret ovanpå graphify. **Gratis provperiod kommer snart.** [Gå med i väntelistan →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**ทักษะสำหรับผู้ช่วยเขียนโค้ด AI** พิมพ์ `/graphify` ใน Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro หรือ Google Antigravity — มันจะอ่านไฟล์ของคุณ สร้างกราฟความรู้ และส่งคืนโครงสร้างที่คุณไม่รู้ว่ามีอยู่ ทำความเข้าใจ codebase ได้เร็วขึ้น ค้นหา "ทำไม" เบื้องหลังการตัดสินใจด้านสถาปัตยกรรม
|
||||
|
||||
มัลติโมดัลอย่างสมบูรณ์ เพิ่มโค้ด, PDF, markdown, ภาพหน้าจอ, ไดอะแกรม, ภาพถ่ายกระดานไวท์บอร์ด, รูปภาพในภาษาอื่น หรือไฟล์วิดีโอและเสียง — graphify ดึงแนวคิดและความสัมพันธ์จากทุกอย่างและเชื่อมต่อกันในกราฟเดียว วิดีโอถูกถอดเสียงในเครื่องด้วย Whisper รองรับ 25 ภาษาการเขียนโปรแกรมผ่าน tree-sitter AST
|
||||
|
||||
> Andrej Karpathy รักษาโฟลเดอร์ `/raw` ที่เขาวางงานวิจัย, ทวีต, ภาพหน้าจอ และบันทึก graphify คือคำตอบสำหรับปัญหานั้น — **71.5 เท่า** โทเค็นน้อยลงต่อการสืบค้นเมื่อเทียบกับการอ่านไฟล์ดิบ, ยั่งยืนระหว่างเซสชัน
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html กราฟแบบโต้ตอบ — เปิดในเบราว์เซอร์ใดก็ได้
|
||||
├── GRAPH_REPORT.md โหนดพระเจ้า, การเชื่อมต่อที่น่าประหลาดใจ, คำถามที่แนะนำ
|
||||
├── graph.json กราฟถาวร — สามารถสืบค้นได้หลายสัปดาห์ต่อมา
|
||||
└── cache/ SHA256-cache — การรันซ้ำประมวลผลเฉพาะไฟล์ที่เปลี่ยนแปลง
|
||||
```
|
||||
|
||||
## วิธีการทำงาน
|
||||
|
||||
graphify ทำงานใน 3 รอบ ก่อนอื่น AST pass แบบ deterministic ดึงโครงสร้างจากไฟล์โค้ดโดยไม่ต้องใช้ LLM จากนั้นไฟล์วิดีโอและเสียงถูกถอดเสียงในเครื่องด้วย faster-whisper สุดท้าย Claude sub-agent ทำงานแบบขนานกันบนเอกสาร, งานวิจัย, รูปภาพ และบทถอดเสียง ผลลัพธ์ถูกรวมเข้ากับกราฟ NetworkX, จัดกลุ่มด้วย Leiden และส่งออกเป็น HTML แบบโต้ตอบ, JSON ที่สืบค้นได้ และรายงานการตรวจสอบ
|
||||
|
||||
ความสัมพันธ์แต่ละอย่างถูกติดป้าย `EXTRACTED`, `INFERRED` (พร้อมคะแนนความเชื่อมั่น) หรือ `AMBIGUOUS`
|
||||
|
||||
## การติดตั้ง
|
||||
|
||||
**ข้อกำหนด:** Python 3.10+ และหนึ่งใน: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) และอื่นๆ
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# หรือกับ pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# หรือ pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **แพ็กเกจอย่างเป็นทางการ:** แพ็กเกจ PyPI ชื่อ `graphifyy` repository อย่างเป็นทางการเดียวคือ [safishamsi/graphify](https://github.com/safishamsi/graphify)
|
||||
|
||||
## การใช้งาน
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "อะไรเชื่อม Attention กับ optimizer?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## สิ่งที่คุณได้รับ
|
||||
|
||||
**โหนดพระเจ้า** — แนวคิดที่มีระดับสูงสุด · **การเชื่อมต่อที่น่าประหลาดใจ** — จัดอันดับตามคะแนน · **คำถามที่แนะนำ** · **"ทำไม"** — docstring และเหตุผลการออกแบบที่ดึงออกมาเป็นโหนด · **เกณฑ์มาตรฐานโทเค็น** — **71.5 เท่า** โทเค็นน้อยลงบน corpus ผสม
|
||||
|
||||
## ความเป็นส่วนตัว
|
||||
|
||||
ไฟล์โค้ดถูกประมวลผลในเครื่องผ่าน tree-sitter AST วิดีโอถูกถอดเสียงในเครื่องด้วย faster-whisper ไม่มีการส่งข้อมูลวัดผล
|
||||
|
||||
## สร้างบน graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) คือชั้น enterprise เหนือ graphify **ทดลองใช้ฟรีเร็วๆ นี้** [เข้าร่วมรายชื่อรอ →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Yapay zeka kod asistanları için bir beceri.** Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro veya Google Antigravity'de `/graphify` yazın — dosyalarınızı okur, bir bilgi grafiği oluşturur ve farkında olmadığınız yapıyı size geri verir. Kod tabanını daha hızlı anlayın. Mimari kararların arkasındaki "neden"i bulun.
|
||||
|
||||
Tamamen çok modlu. Kod, PDF, markdown, ekran görüntüleri, diyagramlar, beyaz tahta fotoğrafları, başka dillerdeki görüntüler veya video ve ses dosyaları ekleyin — graphify her şeyden kavramları ve ilişkileri çıkarır ve bunları tek bir grafikte birleştirir. Videolar Whisper ile yerel olarak transkribe edilir. tree-sitter AST aracılığıyla 25 programlama dilini destekler.
|
||||
|
||||
> Andrej Karpathy, makaleleri, tweetleri, ekran görüntülerini ve notları bıraktığı bir `/raw` klasörü tutar. graphify bu soruna yanıttır — ham dosyaları okumaya kıyasla sorgu başına **71,5x** daha az token, oturumlar arasında kalıcı.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html etkileşimli grafik — herhangi bir tarayıcıda açın
|
||||
├── GRAPH_REPORT.md tanrı düğümleri, şaşırtıcı bağlantılar, önerilen sorular
|
||||
├── graph.json kalıcı grafik — haftalar sonra sorgulanabilir
|
||||
└── cache/ SHA256 önbelleği — tekrarlanan çalışmalar yalnızca değiştirilen dosyaları işler
|
||||
```
|
||||
|
||||
## Nasıl çalışır
|
||||
|
||||
graphify üç geçişte çalışır. Önce deterministik bir AST geçişi, LLM olmadan kod dosyalarından yapı çıkarır. Ardından video ve ses dosyaları faster-whisper ile yerel olarak transkribe edilir. Son olarak Claude alt ajanları belgeler, makaleler, görüntüler ve transkriptler üzerinde paralel olarak çalışır. Sonuçlar bir NetworkX grafiğinde birleştirilir, Leiden ile kümelenir ve etkileşimli HTML, sorgulanabilir JSON ve denetim raporu olarak dışa aktarılır.
|
||||
|
||||
Her ilişki `EXTRACTED`, `INFERRED` (güven puanıyla) veya `AMBIGUOUS` olarak etiketlenir.
|
||||
|
||||
## Kurulum
|
||||
|
||||
**Gereksinimler:** Python 3.10+ ve şunlardan biri: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) ve diğerleri.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# veya pipx ile
|
||||
pipx install graphifyy && graphify install
|
||||
# veya pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Resmi paket:** PyPI paketi `graphifyy` olarak adlandırılır. Tek resmi depo [safishamsi/graphify](https://github.com/safishamsi/graphify)'dir.
|
||||
|
||||
## Kullanım
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "Attention'ı optimizer'a ne bağlıyor?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Ne elde edersiniz
|
||||
|
||||
**Tanrı düğümleri** — en yüksek dereceli kavramlar · **Şaşırtıcı bağlantılar** — puana göre sıralanmış · **Önerilen sorular** · **"Neden"** — doküman dizileri ve tasarım gerekçeleri düğümler olarak çıkarılır · **Token kıyaslaması** — karma derlemede **71,5x** daha az token.
|
||||
|
||||
## Gizlilik
|
||||
|
||||
Kod dosyaları tree-sitter AST aracılığıyla yerel olarak işlenir. Videolar faster-whisper ile yerel olarak transkribe edilir. Telemetri yok.
|
||||
|
||||
## graphify üzerine inşa edildi — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai), graphify üzerindeki kurumsal katmandır. **Ücretsiz deneme yakında.** [Bekleme listesine katılın →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,592 @@
|
||||
<p align="center">
|
||||
<a href="https://graphify.com"><img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.ycombinator.com/companies/graphify"><img src="https://img.shields.io/badge/Y%20Combinator-S26-F0652F?style=flat&logo=ycombinator&logoColor=white" alt="YC S26"/></a>
|
||||
<a href="https://safishamsi.gumroad.com/l/qetvlo"><img src="https://img.shields.io/badge/Book-The%20Memory%20Layer-2ea44f?style=flat&logo=gitbook&logoColor=white" alt="The Memory Layer"/></a>
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v8" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://clickpy.clickhouse.com/dashboard/graphifyy"><img src="https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fsql-clickhouse.clickhouse.com%2F%3Fquery%3DSELECT%2520concat%2528toString%2528round%2528sum%2528count%2529%2F1000%2529%2529%2C%2520%2527k%2527%2529%2520AS%2520c%2520FROM%2520pypi.pypi_downloads%2520WHERE%2520project%253D%2527graphifyy%2527%2520FORMAT%2520JSON%26user%3Ddemo&query=%24.data%5B0%5D.c&label=downloads&color=blue" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
<a href="https://x.com/graphifyy"><img src="https://img.shields.io/badge/X-graphifyy-000000?logo=x&logoColor=white" alt="X"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#safishamsi/graphify&Date">
|
||||
<img src="https://api.star-history.com/svg?repos=safishamsi/graphify&type=Date" alt="Star History Chart" width="370"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Введіть `/graphify` у своєму ШІ-асистенті для кодингу, і він нанесе весь ваш проект — код, документи, PDF, зображення, відео — на граф знань, який можна запитувати замість того, щоб шукати по файлах.
|
||||
|
||||
Працює в Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kimi Code, Kiro, Pi та Google Antigravity.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
Це все. Ви отримуєте три файли:
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html відкрийте в будь-якому браузері — клікайте по вузлах, фільтруйте, шукайте
|
||||
├── GRAPH_REPORT.md основне: ключові концепції, неочікувані зв’язки, запропоновані запитання
|
||||
└── graph.json повний граф — запитуйте його будь-коли без повторного перечитування ваших файлів
|
||||
```
|
||||
|
||||
Для читабельної сторінки архітектури з діаграмами викликів Mermaid виконайте:
|
||||
|
||||
```bash
|
||||
graphify export callflow-html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Вимоги
|
||||
|
||||
| Вимога | Мінімум | Перевірка | Встановлення |
|
||||
|---|---|---|---|
|
||||
| Python | 3.10+ | `python --version` | [python.org](https://www.python.org/downloads/) |
|
||||
| uv *(рекомендовано)* | будь-яка | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
|
||||
| pipx *(альтернатива)* | будь-яка | `pipx --version` | `pip install pipx` |
|
||||
|
||||
**Швидке встановлення на macOS (Homebrew):**
|
||||
```bash
|
||||
brew install python@3.12 uv
|
||||
```
|
||||
|
||||
**Швидке встановлення на Windows:**
|
||||
```powershell
|
||||
winget install astral-sh.uv
|
||||
```
|
||||
|
||||
**Ubuntu/Debian:**
|
||||
```bash
|
||||
sudo apt install python3.12 python3-pip pipx
|
||||
# або встановити uv:
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Встановлення
|
||||
|
||||
> **Офіційний пакет:** Пакет PyPI — `graphifyy` (подвійна y). Інші пакети `graphify*` на PyPI не є афілійованими. Команда CLI залишається `graphify`.
|
||||
|
||||
**Крок 1 — встановити пакет:**
|
||||
|
||||
```bash
|
||||
# Рекомендовано (uv автоматично додає graphify до PATH):
|
||||
uv tool install graphifyy
|
||||
|
||||
# Альтернативи:
|
||||
pipx install graphifyy
|
||||
pip install graphifyy
|
||||
```
|
||||
|
||||
**Крок 2 — зареєструвати навичку у вашому ШІ-асистенті:**
|
||||
|
||||
```bash
|
||||
graphify install
|
||||
```
|
||||
|
||||
Це все. Відкрийте асистента і введіть `/graphify .`
|
||||
|
||||
Щоб встановити навичку в поточний репозиторій замість профілю користувача, додайте `--project`:
|
||||
|
||||
```bash
|
||||
graphify install --project
|
||||
graphify install --project --platform codex
|
||||
```
|
||||
|
||||
Встановлення на рівні проєкту записуються в поточну директорію, наприклад .claude/skills/graphify/SKILL.md або .agents/skills/graphify/SKILL.md, і виводять підказку git add для файлів, які можна закомітити. Команди для окремих платформ, що підтримують інсталяції на рівні проєкту, приймають той самий прапорець, наприклад graphify claude install --project або graphify codex install --project.
|
||||
|
||||
> **Примітка для PowerShell:** Використовуйте `graphify .` замість `/graphify .` — ведучий слеш є роздільником шляху в PowerShell.
|
||||
|
||||
> **`graphify: command not found`?** Використовуйте `uv tool install graphifyy` або `pipx install graphifyy` — обидва автоматично додають CLI до PATH. При використанні звичайного `pip` додайте `~/.local/bin` (Linux) або `~/Library/Python/3.x/bin` (Mac) до вашого PATH, або запустіть `python -m graphify`.
|
||||
|
||||
### Оберіть платформу
|
||||
|
||||
| Платформа | Команда встановлення |
|
||||
|----------|----------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kimi Code | `graphify install --platform kimi` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Pi coding agent | `graphify install --platform pi` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
> Користувачам Codex: також додайте `multi_agent = true` під `[features]` у `~/.codex/config.toml`.
|
||||
> Codex використовує `$graphify` замість `/graphify`.
|
||||
|
||||
### Додаткові пакети (опціонально)
|
||||
|
||||
Встановіть лише те, що потрібно:
|
||||
|
||||
| Пакет | Що додає | Встановлення |
|
||||
|---|---|---|
|
||||
| `pdf` | Вилучення PDF | `pip install "graphifyy[pdf]"` |
|
||||
| `office` | Підтримка `.docx` та `.xlsx` | `pip install "graphifyy[office]"` |
|
||||
| `google` | Рендеринг Google Sheets | `pip install "graphifyy[google]"` |
|
||||
| `video` | Транскрипція відео/аудіо (faster-whisper + yt-dlp) | `pip install "graphifyy[video]"` |
|
||||
| `mcp` | MCP stdio-сервер | `pip install "graphifyy[mcp]"` |
|
||||
| `neo4j` | Підтримка надсилання до Neo4j | `pip install "graphifyy[neo4j]"` |
|
||||
| `svg` | Експорт графу в SVG | `pip install "graphifyy[svg]"` |
|
||||
| `leiden` | Виявлення спільнот Leiden (лише Python < 3.13) | `pip install "graphifyy[leiden]"` |
|
||||
| `ollama` | Локальний вивід Ollama | `pip install "graphifyy[ollama]"` |
|
||||
| `openai` | OpenAI / OpenAI-сумісні API | `pip install "graphifyy[openai]"` |
|
||||
| `gemini` | Google Gemini API | `pip install "graphifyy[gemini]"` |
|
||||
| `bedrock` | AWS Bedrock (використовує IAM, без API-ключа) | `pip install "graphifyy[bedrock]"` |
|
||||
| `sql` | Вилучення SQL схем | `pip install "graphifyy[sql]"` |
|
||||
| `all` | Все вищезазначене | `pip install "graphifyy[all]"` |
|
||||
|
||||
---
|
||||
|
||||
## Змусьте асистента завжди використовувати граф
|
||||
|
||||
Виконайте один раз у своєму проекті після побудови графу:
|
||||
|
||||
| Платформа | Команда |
|
||||
|----------|---------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| GitHub Copilot CLI | `graphify copilot install` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify aider install` |
|
||||
| OpenClaw | `graphify claw install` |
|
||||
| Factory Droid | `graphify droid install` |
|
||||
| Trae | `graphify trae install` |
|
||||
| Trae CN | `graphify trae-cn install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Hermes | `graphify hermes install` |
|
||||
| Kimi Code | `graphify install --platform kimi` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Pi coding agent | `graphify pi install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
Це записує невеликий конфігураційний файл, який каже асистенту звертатися до графу знань для питань про кодову базу — надаючи перевагу локалізованим запитам на кшталт `graphify query "<питання>"` замість читання повного звіту або пошуку по сирих файлах. На платформах, що підтримують хуки з корисним навантаженням (Claude Code, Gemini CLI), хук спрацьовує автоматично перед пошуковими викликами інструментів і спрямовує асистента до графу. На інших (Codex, OpenCode, Cursor тощо) постійні файли інструкцій (`AGENTS.md`, `.cursor/rules/` тощо) забезпечують таке саме керівництво. `GRAPH_REPORT.md` все ще доступний для загального огляду архітектури.
|
||||
|
||||
Щоб видалити graphify з усіх платформ одразу: `graphify uninstall` (додайте `--purge`, щоб також видалити `graphify-out/`). Або скористайтеся командою для конкретної платформи (напр. `graphify claude uninstall`).
|
||||
|
||||
---
|
||||
|
||||
## Що є у звіті
|
||||
|
||||
- **Вузли-боги** — найбільш пов'язані концепції у вашому проекті. Через них проходить все.
|
||||
- **Несподівані зв'язки** — зв'язки між речами з різних файлів або модулів. Відсортовані за ступенем несподіваності.
|
||||
- **«Чому»** — рядкові коментарі (`# NOTE:`, `# WHY:`, `# HACK:`), рядки документації та обґрунтування дизайну з документів витягуються як окремі вузли, пов'язані з кодом, який вони пояснюють.
|
||||
- **Запропоновані питання** — 4–5 питань, на які граф унікально здатний відповісти.
|
||||
- **Теги впевненості** — кожен виведений зв'язок позначений як `EXTRACTED`, `INFERRED` або `AMBIGUOUS`. Ви завжди знаєте, що знайдено, а що виведено.
|
||||
|
||||
---
|
||||
|
||||
## Які файли підтримуються
|
||||
|
||||
| Тип | Розширення |
|
||||
|------|-----------|
|
||||
| Код (31 мова) | `.py .ts .js .jsx .tsx .mjs .go .rs .java .c .cpp .h .hpp .rb .cs .kt .scala .php .swift .lua .luau .zig .ps1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json` |
|
||||
| Документи | `.md .mdx .qmd .html .txt .rst .yaml .yml` |
|
||||
| Office | `.docx .xlsx` (потрібен `pip install graphifyy[office]`) |
|
||||
| Google Workspace | `.gdoc .gsheet .gslides` (опціонально; потрібна автентифікація `gws` та `--google-workspace`; Sheets потребує `pip install graphifyy[google]`) |
|
||||
| PDF | `.pdf` |
|
||||
| Зображення | `.png .jpg .webp .gif` |
|
||||
| Відео / Аудіо | `.mp4 .mov .mp3 .wav` та інші (потрібен `pip install graphifyy[video]`) |
|
||||
| YouTube / URL | будь-який URL відео (потрібен `pip install graphifyy[video]`) |
|
||||
|
||||
Код витягується локально без API-викликів (AST через tree-sitter). Все інше обробляється через API моделі вашого ШІ-асистента.
|
||||
|
||||
Файли `.gdoc`, `.gsheet` та `.gslides` з Google Drive for desktop — це ярлики-посилання, а не вміст документів. Щоб включити нативні Google Docs, Sheets та Slides у безголове витягування, встановіть та автентифікуйте [`gws` CLI](https://github.com/googleworkspace/cli), потім запустіть:
|
||||
|
||||
```bash
|
||||
pip install "graphifyy[google]" # потрібен для рендерингу таблиць Google Sheets
|
||||
gws auth login -s drive
|
||||
graphify extract ./docs --google-workspace
|
||||
```
|
||||
|
||||
Також можна встановити `GRAPHIFY_GOOGLE_WORKSPACE=1`. Graphify експортує ярлики в `graphify-out/converted/` як Markdown-сайдкари, а потім витягує ці файли.
|
||||
|
||||
---
|
||||
|
||||
## Часті команди
|
||||
|
||||
```bash
|
||||
/graphify . # побудувати граф для поточної папки
|
||||
/graphify ./docs --update # повторно витягнути лише змінені файли
|
||||
/graphify . --cluster-only # перезапустити кластеризацію без повторного витягування
|
||||
/graphify . --cluster-only --resolution 1.5 # більш дрібні спільноти
|
||||
/graphify . --cluster-only --exclude-hubs 99 # виключити утилітарні суперхаби з рейтингів “god-node” вузлів-богів
|
||||
/graphify . --no-viz # пропустити HTML, лише звіт + JSON
|
||||
/graphify . --wiki # побудувати markdown-вікі з графу
|
||||
graphify export callflow-html # Mermaid архітектура/flow-викликів HTML (автоматично регенерується на кожен git-коміт, якщо встановлений hook)
|
||||
|
||||
/graphify query "що пов'язує auth з базою даних?"
|
||||
/graphify path "UserService" "DatabasePool"
|
||||
/graphify explain "RateLimiter"
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # завантажити статтю і додати її
|
||||
/graphify add <youtube-url> # транскрибувати і додати відео
|
||||
|
||||
graphify hook install # автоматичне перебудування при git-коміті
|
||||
graphify merge-graphs a.json b.json # об'єднати два графи
|
||||
|
||||
graphify prs # дашборд PR: стан CI, статус рев’ю, мапінг worktree
|
||||
graphify prs 42 # детальний огляд PR #42 з впливом на граф
|
||||
graphify prs --triage # ШІ оцінює вашу чергу рев’ю (використовує будь-який налаштований бекенд)
|
||||
graphify prs --conflicts # PR-и, що ділять спільні графові спільноти — ризик порядку злиття
|
||||
```
|
||||
|
||||
Дивіться [повний довідник команд](#повний-довідник-команд) нижче.
|
||||
|
||||
---
|
||||
|
||||
## Ігнорування файлів
|
||||
|
||||
Створіть `.graphifyignore` у кореневій директорії проекту — той самий синтаксис, що й `.gitignore`, включно з запереченням `!`:
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
|
||||
# індексувати лише src/, ігнорувати все інше
|
||||
*
|
||||
!src/
|
||||
!src/**
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Налаштування для команди
|
||||
|
||||
`graphify-out/` призначений для коміту в git, щоб кожен у команді починав із картою.
|
||||
|
||||
**Рекомендовані доповнення до `.gitignore`:**
|
||||
```
|
||||
graphify-out/manifest.json # базується на mtime, ламається після git clone
|
||||
graphify-out/cost.json # лише локальний
|
||||
# graphify-out/cache/ # опціонально: комітьте для швидкості, пропустіть для меншого репо
|
||||
```
|
||||
|
||||
**Робочий процес:**
|
||||
1. Одна людина запускає `/graphify .` і комітить `graphify-out/`.
|
||||
2. Усі виконують pull — їхній асистент одразу читає граф.
|
||||
3. Запустіть `graphify hook install` для автоматичного перебудування після кожного коміту (лише AST, без витрат API). Це також налаштовує git merge driver, щоб `graph.json` ніколи не залишався з маркерами конфліктів — два розробники, що комітять одночасно, отримають автоматично об'єднані графи.
|
||||
4. Коли документи або статті змінюються, запустіть `/graphify --update`, щоб оновити ці вузли.
|
||||
|
||||
---
|
||||
|
||||
## Використання графу напряму
|
||||
|
||||
```bash
|
||||
# запит до графу з терміналу
|
||||
graphify query "покажи потік автентифікації"
|
||||
graphify query "що пов'язує DigestAuth з Response?" --graph graphify-out/graph.json
|
||||
|
||||
# відкрити граф як MCP-сервер (для повторного доступу через інструменти)
|
||||
python -m graphify.serve graphify-out/graph.json
|
||||
|
||||
# зареєструвати в Kimi Code:
|
||||
kimi mcp add --transport stdio graphify -- python -m graphify.serve graphify-out/graph.json
|
||||
```
|
||||
|
||||
MCP-сервер надає асистенту структурований доступ: `query_graph`, `get_node`, `get_neighbors`, `shortest_path`, `list_prs`, `get_pr_impact`, `triage_prs`.
|
||||
|
||||
> **Примітка для WSL / Linux:** Ubuntu постачає `python3`, а не `python`. Використовуйте venv, щоб уникнути конфліктів:
|
||||
> ```bash
|
||||
> python3 -m venv .venv && .venv/bin/pip install "graphifyy[mcp]"
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## Змінні середовища
|
||||
|
||||
Потрібні лише для **headless / CI витягування** (`graphify extract`). При запуску через навичку `/graphify` у вашому IDE API моделі надається сесією IDE — додаткових ключів не потрібно.
|
||||
|
||||
| Змінна | Використання | Коли потрібна |
|
||||
|---|---|---|
|
||||
| `ANTHROPIC_API_KEY` | Backend Claude (Anthropic) | `--backend claude` |
|
||||
| `ANTHROPIC_BASE_URL` | URL Anthropic-сумісного endpoint (LiteLLM proxy, шлюзи, ...) | `--backend claude` (типово: `https://api.anthropic.com`) |
|
||||
| `ANTHROPIC_MODEL` | Назва моделі для backend Claude — для власних endpoint використовуйте назву/псевдонім моделі вашого сервера | `--backend claude` (типово: `claude-sonnet-4-6`) |
|
||||
| `GEMINI_API_KEY` або `GOOGLE_API_KEY` | Backend Google Gemini | `--backend gemini` |
|
||||
| `OPENAI_API_KEY` | OpenAI або OpenAI-сумісні API | `--backend openai` (локальні сервери приймають будь-яке непорожнє значення) |
|
||||
| `OPENAI_BASE_URL` | URL OpenAI-сумісного сервера (llama.cpp, vLLM, LM Studio, ...) | `--backend openai` (типово: `https://api.openai.com/v1`) |
|
||||
| `OPENAI_MODEL` | Назва моделі для backend OpenAI — для self-hosted серверів використовуйте назву/псевдонім моделі, яку надає ваш сервер (див. його endpoint `/v1/models`), напр. `LFM2.5-8B-A1B-UD-Q4_K_XL` для llama.cpp | `--backend openai` (типово: `gpt-4.1-mini`) |
|
||||
| `DEEPSEEK_API_KEY` | Backend DeepSeek | `--backend deepseek` |
|
||||
| `MOONSHOT_API_KEY` | Backend Kimi Code | `--backend kimi` |
|
||||
| `OLLAMA_BASE_URL` | URL локального виводу Ollama | `--backend ollama` (типово: `http://localhost:11434`) |
|
||||
| `OLLAMA_MODEL` | Назва моделі Ollama | `--backend ollama` (типово: автовизначення) |
|
||||
| `GRAPHIFY_OLLAMA_NUM_CTX` | Перевизначити розмір KV-кеш вікна Ollama | опціонально — автоматично за замовчуванням |
|
||||
| `GRAPHIFY_OLLAMA_KEEP_ALIVE` | Хвилини утримання моделі Ollama завантаженою | опціонально — встановіть `0` для вивантаження після кожного шматка |
|
||||
| `AWS_*` / `~/.aws/credentials` | AWS Bedrock — стандартний ланцюг облікових даних | `--backend bedrock` (без API-ключа, використовує IAM) |
|
||||
| `GRAPHIFY_MAX_WORKERS` | Кількість потоків паралелізму AST | опціонально — також прапор `--max-workers` |
|
||||
| `GRAPHIFY_MAX_OUTPUT_TOKENS` | Підвищити ліміт виводу для щільних корпусів | опціонально — напр. `32768` для великих файлів |
|
||||
| `GRAPHIFY_API_TIMEOUT` | HTTP тайм-аут у секундах (типово: 600) | опціонально — також прапор `--api-timeout` |
|
||||
| `GRAPHIFY_FORCE` | Примусове перебудування графу навіть із меншою кількістю вузлів | опціонально — також прапор `--force` |
|
||||
| `GRAPHIFY_GOOGLE_WORKSPACE` | Автоввімкнення експорту Google Workspace | опціонально — встановіть в `1` |
|
||||
| `GRAPHIFY_TRIAGE_BACKEND` | Backend для `graphify prs --triage` | опціонально — автовизначення з наявних ключів |
|
||||
| `GRAPHIFY_TRIAGE_MODEL` | Перевизначення моделі для triage | опціонально — напр. `claude-opus-4-7` |
|
||||
|
||||
---
|
||||
|
||||
## Конфіденційність
|
||||
|
||||
- **Файли коду** — обробляються локально через tree-sitter. Нічого не покидає ваш комп'ютер.
|
||||
- **Відео / аудіо** — транскрибуються локально за допомогою faster-whisper. Нічого не покидає ваш комп'ютер.
|
||||
- **Документи, PDF, зображення** — надсилаються до вашого ШІ-асистента для семантичного витягування (через навичку `/graphify`, використовуючи модель, що запущена у вашому IDE). Безголове `graphify extract` потребує `GEMINI_API_KEY` / `GOOGLE_API_KEY` (Gemini), `MOONSHOT_API_KEY` (Kimi), `ANTHROPIC_API_KEY` (Claude), `OPENAI_API_KEY` (OpenAI), `DEEPSEEK_API_KEY` (DeepSeek), запущеного екземпляра Ollama (`OLLAMA_BASE_URL`), AWS-облікових даних через стандартний ланцюг провайдерів (Bedrock — без API-ключа, використовує IAM) або бінарного файлу `claude` CLI (Claude Code — без API-ключа, використовує вашу підписку Claude). Прапор `--dedup-llm` використовує той самий ключ.
|
||||
- Без телеметрії, без відстеження використання, без аналітики.
|
||||
|
||||
---
|
||||
|
||||
## Вирішення проблем
|
||||
|
||||
**`graphify: command not found` після `pip install graphifyy`**
|
||||
pip встановлює скрипти в директорію bin для користувача, яка може не бути в PATH. Виправлення:
|
||||
- macOS: додайте `~/Library/Python/3.x/bin` до PATH у `~/.zshrc`
|
||||
- Linux: додайте `~/.local/bin` до PATH у `~/.bashrc`
|
||||
- Або використовуйте `uv tool install graphifyy` / `pipx install graphifyy` — обидва автоматично керують PATH.
|
||||
|
||||
**`python -m graphify` працює, але команда `graphify` — ні**
|
||||
PATH вашої оболонки не включає директорію скриптів Python. Використовуйте `uv` або `pipx` замість звичайного `pip`.
|
||||
|
||||
**`/graphify .` викликає "path not recognized" в PowerShell**
|
||||
PowerShell трактує ведучий `/` як роздільник шляху. Використовуйте `graphify .` (без слеша) на Windows.
|
||||
|
||||
**Граф має менше вузлів після `--update` або перебудови**
|
||||
Якщо рефакторинг видалив файли, старі вузли залишаються. Передайте `--force` (або встановіть `GRAPHIFY_FORCE=1`), щоб перезаписати навіть якщо перебудова має менше вузлів.
|
||||
|
||||
**Граф має дублікати вузлів для однієї сутності (фантомні дублікати)**
|
||||
Це трапляється, коли семантичне та AST-витягування не погодилось щодо формату ID вузла. Запустіть повне повторне витягування для очищення:
|
||||
```bash
|
||||
graphify extract . --force
|
||||
```
|
||||
|
||||
**Ollama вичерпує VRAM / перевищено вікно контексту**
|
||||
KV-кеш вікно автоматично розраховується, але може бути завеликим для вашого GPU. Зменшіть його:
|
||||
```bash
|
||||
GRAPHIFY_OLLAMA_NUM_CTX=8192 graphify extract ./docs --backend ollama --token-budget 4000
|
||||
```
|
||||
|
||||
**HTML графу занадто великий для відкриття в браузері (>5000 вузлів)**
|
||||
Пропустіть генерацію HTML і використовуйте JSON напряму:
|
||||
```bash
|
||||
graphify cluster-only ./my-project --no-viz
|
||||
graphify query "..."
|
||||
```
|
||||
|
||||
**`graph.json` має маркери конфліктів після одночасного коміту двох розробників**
|
||||
Запустіть `graphify hook install` — це налаштовує git merge driver, який автоматично об'єднує `graph.json`, щоб конфліктів ніколи не виникало.
|
||||
|
||||
**Вилучення повертає порожні вузли/ребра для документів або PDF**
|
||||
Документи та PDF потребують LLM-виклику. Перевірте, що API-ключ встановлено і backend правильний:
|
||||
```bash
|
||||
ANTHROPIC_API_KEY=sk-... graphify extract ./docs --backend claude
|
||||
```
|
||||
|
||||
**Попередження про невідповідність версій навички у вашому IDE**
|
||||
Встановлена версія graphify відрізняється від файлу навички. Оновіть:
|
||||
```bash
|
||||
uv tool upgrade graphifyy
|
||||
graphify install # перезаписує файл навички
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Повний довідник команд
|
||||
|
||||
```
|
||||
/graphify # запустити в поточному каталозі
|
||||
/graphify ./raw # запустити у конкретній папці
|
||||
/graphify ./raw --mode deep # більш агресивне витягування зв'язків
|
||||
/graphify ./raw --update # повторно витягнути лише змінені файли
|
||||
/graphify ./raw --directed # зберегти напрямок ребер
|
||||
/graphify ./raw --cluster-only # повторна кластеризація існуючого графу
|
||||
/graphify ./raw --no-viz # пропустити HTML-візуалізацію
|
||||
/graphify ./raw --obsidian # згенерувати сховище Obsidian
|
||||
/graphify ./raw --wiki # побудувати markdown-вікі для обходу агентами
|
||||
/graphify ./raw --svg # експортувати graph.svg
|
||||
/graphify ./raw --graphml # експортувати для Gephi / yEd
|
||||
/graphify ./raw --neo4j # згенерувати cypher.txt для Neo4j
|
||||
/graphify ./raw --neo4j-push bolt://localhost:7687
|
||||
/graphify ./raw --watch # автосинхронізація при зміні файлів
|
||||
/graphify ./raw --mcp # запустити MCP stdio-сервер
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762
|
||||
/graphify add <video-url>
|
||||
/graphify add https://... --author "Name" --contributor "Name"
|
||||
|
||||
/graphify query "що пов'язує attention з optimizer?"
|
||||
/graphify query "..." --dfs --budget 1500
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify uninstall # видалити з усіх платформ одразу
|
||||
graphify uninstall --purge # також видалити graphify-out/
|
||||
graphify uninstall --project --platform codex # видалити лише файли проектного встановлення
|
||||
|
||||
graphify hook install # хуки post-commit + post-checkout
|
||||
graphify hook uninstall
|
||||
graphify hook status
|
||||
|
||||
graphify claude install / uninstall
|
||||
graphify codex install / uninstall
|
||||
graphify opencode install
|
||||
graphify cursor install / uninstall
|
||||
graphify gemini install / uninstall
|
||||
graphify copilot install / uninstall
|
||||
graphify aider install / uninstall
|
||||
graphify claw install / uninstall
|
||||
graphify droid install / uninstall
|
||||
graphify trae install / uninstall
|
||||
graphify trae-cn install / uninstall
|
||||
graphify hermes install / uninstall
|
||||
graphify kiro install / uninstall
|
||||
graphify antigravity install / uninstall
|
||||
|
||||
graphify extract ./docs # headless LLM-витягування для CI (без IDE)
|
||||
graphify extract ./docs --backend gemini # явний backend: gemini, kimi, claude, openai, deepseek, ollama, bedrock або claude-cli
|
||||
graphify extract ./docs --backend gemini --model gemini-3.1-pro-preview
|
||||
graphify extract ./docs --backend ollama # локальний Ollama (встановіть OLLAMA_BASE_URL / OLLAMA_MODEL) — без API-ключа для loopback
|
||||
OPENAI_BASE_URL=http://localhost:8080/v1 OPENAI_MODEL=my-model graphify extract ./docs --backend openai # будь-який OpenAI-сумісний сервер (llama.cpp, vLLM, LM Studio)
|
||||
ANTHROPIC_BASE_URL=http://localhost:4000 ANTHROPIC_MODEL=my-model graphify extract ./docs --backend claude # будь-який Anthropic-сумісний endpoint (LiteLLM proxy, шлюзи)
|
||||
GRAPHIFY_OLLAMA_NUM_CTX=32768 graphify extract ./docs --backend ollama # перевизначити KV-кеш вікно (автоматично за замовчуванням)
|
||||
GRAPHIFY_OLLAMA_KEEP_ALIVE=0 graphify extract ./docs --backend ollama # вивантажити модель після кожного шматка (економить VRAM на малих GPU)
|
||||
graphify extract ./docs --backend bedrock # AWS Bedrock через IAM — без API-ключа, використовує ланцюг облікових даних AWS
|
||||
graphify extract ./docs --backend claude-cli # маршрутизація через Claude Code CLI — без API-ключа, використовує вашу підписку Claude
|
||||
graphify extract ./docs --max-workers 16 # паралелізм AST (також GRAPHIFY_MAX_WORKERS)
|
||||
graphify extract ./docs --token-budget 30000 # менші семантичні шматки для локальних/малих моделей
|
||||
graphify extract ./docs --max-concurrency 2 # менше паралельних LLM-викликів (корисно для локального виводу)
|
||||
graphify extract ./docs --api-timeout 900 # довший HTTP тайм-аут для повільних локальних моделей (типово 600с)
|
||||
graphify extract ./docs --google-workspace # експортувати .gdoc/.gsheet/.gslides через gws перед витягуванням
|
||||
graphify extract ./docs --no-cluster # лише сире витягування, пропустити кластеризацію
|
||||
graphify extract ./docs --force # перезаписати graph.json навіть якщо новий граф має менше вузлів (використовуйте після рефакторингу або для очищення фантомних дублікатів)
|
||||
graphify extract ./docs --dedup-llm # LLM-арбітр для неоднозначних пар сутностей (використовує той самий API-ключ)
|
||||
graphify extract ./docs --global --as myrepo # витягнути і зареєструвати в крос-проектний глобальний граф
|
||||
GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # підвищити ліміт виводу для щільних корпусів
|
||||
|
||||
graphify export callflow-html # graphify-out/<project>-callflow.html
|
||||
graphify export callflow-html --max-sections 8 # обмежити кількість згенерованих секцій архітектури
|
||||
graphify export callflow-html --output docs/arch.html
|
||||
graphify export callflow-html ./some-repo/graphify-out
|
||||
|
||||
graphify global add graphify-out/graph.json myrepo # зареєструвати граф проекту в ~/.graphify/global.json
|
||||
graphify global remove myrepo # видалити проект з глобального графу
|
||||
graphify global list # показати всі зареєстровані репо + кількість вузлів/ребер
|
||||
graphify global path # вивести шлях до файлу глобального графу
|
||||
|
||||
graphify prs # дашборд PR: CI, рев’ю, worktree, вплив на граф
|
||||
graphify prs 42 # детальний огляд PR #42
|
||||
graphify prs --triage # AI ранжування пріоритизації (автоматично визначає бекенд з середовища)
|
||||
graphify prs --worktrees # worktree → гілка → PR зіставлення
|
||||
graphify prs --conflicts # PR-и, що ділять спільні графові спільноти (ризик порядку злиття)
|
||||
graphify prs --base main # фільтр PR-ів за цільовою базовою гілкою
|
||||
graphify prs --repo owner/repo # запустити для іншого GitHub-репо
|
||||
GRAPHIFY_TRIAGE_BACKEND=kimi graphify prs --triage # використовувати конкретний backend для triage
|
||||
|
||||
graphify clone https://github.com/karpathy/nanoGPT
|
||||
graphify merge-graphs a.json b.json --out merged.json
|
||||
graphify --version # вивести встановлену версію
|
||||
graphify watch ./src
|
||||
graphify check-update ./src
|
||||
graphify update ./src
|
||||
graphify update ./src --no-cluster # пропустити рекластеризацію, записати лише сирий AST граф
|
||||
graphify update ./src --force # перезаписати навіть якщо новий граф має менше вузлів
|
||||
graphify cluster-only ./my-project
|
||||
graphify cluster-only ./my-project --graph path/to/graph.json # власне розташування графу
|
||||
graphify cluster-only ./my-project --resolution 1.5 # більше, менших спільнот
|
||||
graphify cluster-only ./my-project --exclude-hubs 99 # виключити вузли p99 ступеня з розбиття
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Дізнатися більше
|
||||
|
||||
- [Як це працює](../how-it-works.md) — пайплайн витягування, виявлення спільнот, оцінка впевненості, бенчмарки
|
||||
- [ARCHITECTURE.md](../../ARCHITECTURE.md) — опис модулів, як додати мову
|
||||
- [Опціональні інтеграції](../docker-mcp-sqlite.md) — Docker MCP Toolkit + SQLite
|
||||
|
||||
---
|
||||
|
||||
## Побудовано на graphify — Penpax
|
||||
|
||||
[**Penpax**](https://graphify.com) — це завжди активний шар поверх graphify, він застосовує той самий графовий підхід до всього робочого життя: зустрічей, історії браузера, email-ів, файлів і коду, постійно оновлюючись у фоновому режимі.
|
||||
|
||||
Створений для людей, чия робота розкидана по сотнях розмов і документів, які неможливо повністю відтворити. Без хмари, повністю на пристрої.
|
||||
|
||||
**Безкоштовна пробна версія незабаром.** [Приєднайтесь до списку очікування →](https://graphify.com)
|
||||
|
||||
---
|
||||
|
||||
<details>
|
||||
<summary>Участь у розробці</summary>
|
||||
|
||||
### Налаштування розробки
|
||||
|
||||
Клонуйте репо і встановіть у редагованому режимі:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/safishamsi/graphify.git
|
||||
cd graphify
|
||||
git checkout v8 # гілка активної розробки
|
||||
|
||||
# Створіть віртуальне середовище (потрібен Python 3.10+):
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||
|
||||
# Встановіть у редагованому режимі з усіма опціональними пакетами:
|
||||
pip install -e ".[all]"
|
||||
```
|
||||
|
||||
Перевірте редаговане встановлення:
|
||||
```bash
|
||||
graphify --version
|
||||
python -c "import graphify; print(graphify.__file__)"
|
||||
```
|
||||
|
||||
### Запуск тестів
|
||||
|
||||
```bash
|
||||
pip install pytest
|
||||
pytest tests/ -q # запустити весь набір тестів
|
||||
pytest tests/test_extract.py -q # один модуль
|
||||
pytest tests/ -q -k "python" # фільтрація за назвою
|
||||
```
|
||||
|
||||
> Примітка для macOS: набір тестів включає обидва файли `sample.f90` та `sample.F90`. Вони конфліктують на файлових системах HFS+ / APFS без урахування регістру. Запускайте на Linux або в Docker-контейнері, якщо потрібно тестувати обидва варіанти Fortran одночасно.
|
||||
|
||||
### Робочий процес з git
|
||||
|
||||
- Активна розробка відбувається в гілці `v8`.
|
||||
- Стиль комітів: `fix: <опис>` / `feat: <опис>` / `docs: <опис>`
|
||||
- Перед відкриттям PR запустіть `pytest tests/ -q` і переконайтесь, що він проходить.
|
||||
- Додайте файл-фікстуру до `tests/fixtures/` і тести до `tests/test_languages.py` для будь-якого нового екстрактора мови.
|
||||
|
||||
### Що варто додати
|
||||
|
||||
Найкорисніший внесок — це **опрацьовані приклади**. Запустіть `/graphify` на реальному корпусі, збережіть результат у `worked/{slug}/`, напишіть чесний `review.md` про те, що граф зробив правильно і неправильно, і відкрийте PR.
|
||||
|
||||
**Помилки витягування** — відкрийте issue з вхідним файлом, записом кешу (`graphify-out/cache/`) і тим, що було пропущено або неправильно.
|
||||
|
||||
Дивіться [ARCHITECTURE.md](../../ARCHITECTURE.md) щодо відповідальностей модулів і того, як додати мову.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,170 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
<a href="https://www.linkedin.com/company/graphify-labs"><img src="https://img.shields.io/badge/LinkedIn-Graphify%20Labs-0077B5?logo=linkedin" alt="LinkedIn"/></a>
|
||||
</p>
|
||||
|
||||
**Sun'iy intellektga asoslangan kod yordamchilari uchun ko'nikma.** Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro yoki Google Antigravity da `/graphify` deb yozing — u sizning fayllaringizni o'qiydi, bilim grafini quradi va siz bilmagan tuzilmani sizga qaytaradi. Kod bazasini tezroq tushuning. Arxitektura qarorlari ortidagi "nima uchun" savoliga javob toping.
|
||||
|
||||
To'liq multimodal. Kod, PDF, markdown, ekran tasvirlari, diagrammalar, doska suratlari, boshqa tillardagi tasvirlar, video va audio fayllarni qo'shing — graphify ularning barchasidan tushuncha va aloqalarni chiqarib, bitta grafga birlashtiradi. Videolar Whisper yordamida mahalliy ravishda transkripsiya qilinadi, sizning korpusingizdan olingan domen-maxsus so'rov bilan. tree-sitter AST orqali 25 ta dasturlash tilini qo'llab-quvvatlaydi (Python, JS, TS, Go, Rust, Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Lua, Zig, PowerShell, Elixir, Objective-C, Julia, Verilog, SystemVerilog, Vue, Svelte, Dart).
|
||||
|
||||
> Andrej Karpati maqolalar, tvitlar, ekran tasvirlari va eslatmalarni saqlaydigan `/raw` papkasini yuritadi. graphify ushbu muammoga javob — xom fayllarni o'qishga nisbatan har bir so'rov uchun **71,5 marta** kamroq token, sessiyalar orasida saqlanadi, topilgan va xulosa qilinganlar haqida halol.
|
||||
|
||||
```
|
||||
/graphify . # istalgan papka bilan ishlaydi — kod, eslatmalar, maqolalar, hammasi
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html interaktiv graf — brauzerda oching, tugunlarni bosing, qidiring, filtrlang
|
||||
├── GRAPH_REPORT.md god-tugunlar, kutilmagan aloqalar, taklif qilingan savollar
|
||||
├── graph.json doimiy graf — haftalardan keyin qayta o'qimasdan so'rov qiling
|
||||
└── cache/ SHA256-kesh — qayta ishga tushirish faqat o'zgargan fayllarni qayta ishlaydi
|
||||
```
|
||||
|
||||
Papkalarni istisno qilish uchun `.graphifyignore` faylini qo'shing:
|
||||
|
||||
```
|
||||
# .graphifyignore
|
||||
vendor/
|
||||
node_modules/
|
||||
dist/
|
||||
*.generated.py
|
||||
```
|
||||
|
||||
Sintaksisi `.gitignore` ga o'xshash.
|
||||
|
||||
## Qanday ishlaydi
|
||||
|
||||
graphify uch bosqichda ishlaydi. Birinchi navbatda, deterministik AST bosqichi kod fayllaridan tuzilmani chiqaradi (klasslar, funksiyalar, importlar, chaqiruv graflari, docstring lar, sabab izohlari) — LLM ishtirokisiz. Keyin video va audio fayllar faster-whisper yordamida mahalliy ravishda transkripsiya qilinadi. Nihoyat, Claude subagentlari hujjatlar, maqolalar, tasvirlar va transkriptlar ustida parallel ishlab, tushunchalar, aloqalar va dizayn asoslarini chiqarib oladi. Natijalar NetworkX grafiga birlashtiriladi, Leiden hamjamiyat aniqlash algoritmi bilan klasterlanadi va interaktiv HTML, so'rov qilinadigan JSON hamda tabiiy tildagi audit hisoboti sifatida eksport qilinadi.
|
||||
|
||||
**Klasterlash graf topologiyasiga asoslangan — embedding ishlatilmaydi.** Leiden hamjamiyatlarni qirralar zichligi bo'yicha topadi. Claude tomonidan chiqarilgan semantik o'xshashlik qirralari (`semantically_similar_to`, INFERRED deb belgilangan) allaqachon grafda. Graf tuzilmasining o'zi o'xshashlik signali. Alohida embedding bosqichi yoki vektor ma'lumotlar bazasi shart emas.
|
||||
|
||||
Har bir aloqa `EXTRACTED` (manbada to'g'ridan-to'g'ri topilgan), `INFERRED` (ishonch bahosi bilan asoslangan xulosa) yoki `AMBIGUOUS` (tekshirish uchun belgilangan) deb teglanadi.
|
||||
|
||||
## O'rnatish
|
||||
|
||||
**Talablar:** Python 3.10+ va quyidagilardan biri: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/copilot-cli), [VS Code Copilot Chat](https://code.visualstudio.com/docs/copilot/overview), [Aider](https://aider.chat), [OpenClaw](https://openclaw.ai), [Factory Droid](https://factory.ai), [Trae](https://trae.ai), [Kiro](https://kiro.dev), Hermes yoki [Google Antigravity](https://antigravity.google)
|
||||
|
||||
```bash
|
||||
# Tavsiya etiladi — Mac va Linux da PATH ni sozlashsiz ishlaydi
|
||||
uv tool install graphifyy && graphify install
|
||||
# yoki pipx bilan
|
||||
pipx install graphifyy && graphify install
|
||||
# yoki oddiy pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Rasmiy paket:** PyPI dagi paket nomi `graphifyy` (`pip install graphifyy` orqali o'rnatiladi). PyPI dagi boshqa `graphify*` nomli paketlar bu loyiha bilan bog'liq emas. Yagona rasmiy repozitoriy — [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
### Platforma qo'llab-quvvatlash
|
||||
|
||||
| Platforma | O'rnatish buyrug'i |
|
||||
|-----------|--------------------|
|
||||
| Claude Code (Linux/Mac) | `graphify install` |
|
||||
| Claude Code (Windows) | `graphify install` (avto-aniqlash) yoki `graphify install --platform windows` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| GitHub Copilot CLI | `graphify install --platform copilot` |
|
||||
| VS Code Copilot Chat | `graphify vscode install` |
|
||||
| Aider | `graphify install --platform aider` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
| Gemini CLI | `graphify install --platform gemini` |
|
||||
| Hermes | `graphify install --platform hermes` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
Keyin AI yordamchingizni oching va kiriting:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
Eslatma: Codex ko'nikmalar uchun `/` o'rniga `$` ishlatadi, shuning uchun `$graphify .` deb kiriting.
|
||||
|
||||
### Yordamchini har doim grafdan foydalanishga majbur qilish (tavsiya etiladi)
|
||||
|
||||
Graf qurilgandan so'ng, loyihangizda buni bir marta bajaring:
|
||||
|
||||
| Platforma | Buyruq |
|
||||
|-----------|--------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| Cursor | `graphify cursor install` |
|
||||
| Gemini CLI | `graphify gemini install` |
|
||||
| Kiro IDE/CLI | `graphify kiro install` |
|
||||
| Google Antigravity | `graphify antigravity install` |
|
||||
|
||||
## Foydalanish
|
||||
|
||||
```
|
||||
/graphify # joriy katalog
|
||||
/graphify ./raw # ma'lum bir papka
|
||||
/graphify ./raw --mode deep # INFERRED qirralarini agressivroq chiqarish
|
||||
/graphify ./raw --update # faqat o'zgargan fayllarni qayta chiqarish
|
||||
/graphify ./raw --directed # yo'naltirilgan graf
|
||||
/graphify ./raw --cluster-only # mavjud grafda klasterlashni qayta ishga tushirish
|
||||
/graphify ./raw --no-viz # HTML siz, faqat hisobot + JSON
|
||||
/graphify ./raw --obsidian # Obsidian vault yaratish (opsional)
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # maqolani olish
|
||||
/graphify add <video-url> # audio yuklab olish, transkripsiya qilish va qo'shish
|
||||
/graphify query "Attention ni optimallashtiruvchi bilan nima bog'laydi?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
graphify hook install # Git ilgaklarini o'rnatish
|
||||
graphify update ./src # kod fayllarini qayta chiqarish, LLM siz
|
||||
graphify watch ./src # grafni avtomatik yangilash
|
||||
```
|
||||
|
||||
## Nima olasiz
|
||||
|
||||
**God-tugunlar** — eng yuqori darajadagi tushunchalar (hamma narsa ular orqali o'tadi)
|
||||
|
||||
**Kutilmagan aloqalar** — qo'shma ball bo'yicha tartiblangan. Kod-maqola qirralari yuqoriroq baholanadi. Har bir natijada tabiiy tildagi "nima uchun" tushuntirishi bor.
|
||||
|
||||
**Taklif qilingan savollar** — graf alohida javob bera oladigan 4-5 ta savol
|
||||
|
||||
**"Nima uchun"** — docstring lar, ichki izohlar (`# NOTE:`, `# IMPORTANT:`, `# HACK:`, `# WHY:`) va hujjatlardagi dizayn asoslari `rationale_for` tugunlari sifatida chiqariladi.
|
||||
|
||||
**Ishonch ballari** — har bir INFERRED qirrasida `confidence_score` (0,0-1,0) mavjud.
|
||||
|
||||
**Token benchmark** — har bir ishga tushirishdan keyin avtomatik chiqariladi. Aralash korpusda: so'rov uchun **71,5 marta** kamroq token, xom fayllarga nisbatan.
|
||||
|
||||
**Avto-sinxronlash** (`--watch`) — kod o'zgarganida grafni avtomatik yangilaydi.
|
||||
|
||||
**Git ilgaklari** (`graphify hook install`) — post-commit va post-checkout ilgaklarini o'rnatadi.
|
||||
|
||||
## Maxfiylik
|
||||
|
||||
graphify hujjatlar, maqolalar va tasvirlardan semantik chiqarish uchun fayl mazmunini AI yordamchingizning model API siga yuboradi. Kod fayllari tree-sitter AST orqali mahalliy ravishda qayta ishlanadi. Video va audio fayllar faster-whisper bilan mahalliy ravishda transkripsiya qilinadi. Hech qanday telemetriya, foydalanishni kuzatish yo'q.
|
||||
|
||||
## Texnologiyalar to'plami
|
||||
|
||||
NetworkX + Leiden (graspologic) + tree-sitter + vis.js. Semantik chiqarish Claude, GPT-4 yoki sizning platformangiz modeli orqali. Video transkripsiyasi faster-whisper + yt-dlp orqali (opsional).
|
||||
|
||||
## graphify ustida qurilgan — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) — graphify ustidagi korporativ qatlam. graphify fayllar papkasini bilim grafiga aylantirganidek, Penpax o'sha yondashuvni butun ish hayotingizga uzluksiz qo'llaydi.
|
||||
|
||||
**Tez orada bepul sinov muddati.** [Kutish ro'yxatiga qo'shiling →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
## Yulduzlar tarixi
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**Kỹ năng dành cho trợ lý lập trình AI.** Gõ `/graphify` trong Claude Code, Codex, OpenCode, Cursor, Gemini CLI, GitHub Copilot CLI, VS Code Copilot Chat, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro hoặc Google Antigravity — nó đọc các tệp của bạn, xây dựng đồ thị kiến thức và trả lại cho bạn cấu trúc mà bạn không biết là tồn tại. Hiểu codebase nhanh hơn. Tìm ra "tại sao" đằng sau các quyết định kiến trúc.
|
||||
|
||||
Hoàn toàn đa phương thức. Thêm code, PDF, markdown, ảnh chụp màn hình, sơ đồ, ảnh bảng trắng, hình ảnh bằng ngôn ngữ khác hoặc tệp video và âm thanh — graphify trích xuất các khái niệm và mối quan hệ từ tất cả mọi thứ và kết nối chúng trong một đồ thị duy nhất. Video được phiên âm cục bộ bằng Whisper. Hỗ trợ 25 ngôn ngữ lập trình qua tree-sitter AST.
|
||||
|
||||
> Andrej Karpathy duy trì một thư mục `/raw` nơi anh ấy đặt các bài báo, tweet, ảnh chụp màn hình và ghi chú. graphify là câu trả lời cho vấn đề đó — **71,5x** ít token hơn trên mỗi truy vấn so với đọc các tệp thô, liên tục giữa các phiên.
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html đồ thị tương tác — mở trong bất kỳ trình duyệt nào
|
||||
├── GRAPH_REPORT.md nút thần, kết nối bất ngờ, câu hỏi được đề xuất
|
||||
├── graph.json đồ thị liên tục — có thể truy vấn sau nhiều tuần
|
||||
└── cache/ bộ nhớ đệm SHA256 — các lần chạy lại chỉ xử lý các tệp đã thay đổi
|
||||
```
|
||||
|
||||
## Cách hoạt động
|
||||
|
||||
graphify hoạt động theo ba lần duyệt. Đầu tiên, một lần duyệt AST xác định trích xuất cấu trúc từ các tệp code mà không cần LLM. Sau đó, các tệp video và âm thanh được phiên âm cục bộ bằng faster-whisper. Cuối cùng, các sub-agent Claude chạy song song trên các tài liệu, bài báo, hình ảnh và bản phiên âm. Kết quả được hợp nhất vào đồ thị NetworkX, phân cụm với Leiden và xuất dưới dạng HTML tương tác, JSON có thể truy vấn và báo cáo kiểm tra.
|
||||
|
||||
Mỗi mối quan hệ được gắn nhãn `EXTRACTED`, `INFERRED` (với điểm tin cậy) hoặc `AMBIGUOUS`.
|
||||
|
||||
## Cài đặt
|
||||
|
||||
**Yêu cầu:** Python 3.10+ và một trong: [Claude Code](https://claude.ai/code), [Codex](https://openai.com/codex), [OpenCode](https://opencode.ai), [Cursor](https://cursor.com) và các công cụ khác.
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# hoặc với pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# hoặc pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **Gói chính thức:** Gói PyPI có tên là `graphifyy`. Kho lưu trữ chính thức duy nhất là [safishamsi/graphify](https://github.com/safishamsi/graphify).
|
||||
|
||||
## Sử dụng
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "điều gì kết nối Attention với optimizer?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## Những gì bạn nhận được
|
||||
|
||||
**Nút thần** — các khái niệm có bậc cao nhất · **Kết nối bất ngờ** — được xếp hạng theo điểm · **Câu hỏi được đề xuất** · **"Tại sao"** — docstring và lý do thiết kế được trích xuất dưới dạng nút · **Benchmark token** — **71,5x** ít token hơn trên corpus hỗn hợp.
|
||||
|
||||
## Quyền riêng tư
|
||||
|
||||
Các tệp code được xử lý cục bộ qua tree-sitter AST. Video được phiên âm cục bộ với faster-whisper. Không có telemetry.
|
||||
|
||||
## Được xây dựng trên graphify — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) là lớp doanh nghiệp trên graphify. **Dùng thử miễn phí sắp ra mắt.** [Tham gia danh sách chờ →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,226 @@
|
||||
# graphify
|
||||
|
||||
🇺🇸 [English](../../README.md) | 🇨🇳 [简体中文](README.zh-CN.md) | 🇯🇵 [日本語](README.ja-JP.md) | 🇰🇷 [한국어](README.ko-KR.md) | 🇩🇪 [Deutsch](README.de-DE.md) | 🇫🇷 [Français](README.fr-FR.md) | 🇪🇸 [Español](README.es-ES.md) | 🇮🇳 [हिन्दी](README.hi-IN.md) | 🇧🇷 [Português](README.pt-BR.md) | 🇷🇺 [Русский](README.ru-RU.md) | 🇸🇦 [العربية](README.ar-SA.md) | 🇮🇹 [Italiano](README.it-IT.md) | 🇵🇱 [Polski](README.pl-PL.md) | 🇳🇱 [Nederlands](README.nl-NL.md) | 🇹🇷 [Türkçe](README.tr-TR.md) | 🇺🇦 [Українська](README.uk-UA.md) | 🇻🇳 [Tiếng Việt](README.vi-VN.md) | 🇮🇩 [Bahasa Indonesia](README.id-ID.md) | 🇸🇪 [Svenska](README.sv-SE.md) | 🇬🇷 [Ελληνικά](README.el-GR.md) | 🇷🇴 [Română](README.ro-RO.md) | 🇨🇿 [Čeština](README.cs-CZ.md) | 🇫🇮 [Suomi](README.fi-FI.md) | 🇩🇰 [Dansk](README.da-DK.md) | 🇳🇴 [Norsk](README.no-NO.md) | 🇭🇺 [Magyar](README.hu-HU.md) | 🇹🇭 [ภาษาไทย](README.th-TH.md) | 🇺🇿 [Oʻzbekcha](README.uz-UZ.md) | 🇹🇼 [繁體中文](README.zh-TW.md)
|
||||
|
||||
[](https://github.com/safishamsi/graphify/actions/workflows/ci.yml)
|
||||
[](https://pypi.org/project/graphifyy/)
|
||||
|
||||
**一个面向 AI 编码助手的技能。** 在 Claude Code、CodeBuddy、Codex、OpenCode、OpenClaw、Factory Droid 或 Trae 中输入 `/graphify`,它会读取你的文件、构建知识图谱,并把原本不明显的结构关系还给你。更快理解代码库,找到架构决策背后的"为什么"。
|
||||
|
||||
完全多模态。你可以直接丢进去代码、PDF、Markdown、截图、流程图、白板照片,甚至其他语言的图片 —— graphify 会用 Claude vision 从这些内容中提取概念和关系,并把它们连接到同一张图里。
|
||||
|
||||
> Andrej Karpathy 会维护一个 `/raw` 文件夹,把论文、推文、截图和笔记都丢进去。graphify 就是在解决这类问题 —— 相比直接读取原始文件,每次查询的 token 消耗可降低 **71.5 倍**,结果还能跨会话持久保存,并且会明确区分哪些内容是实际发现的,哪些只是合理推断。
|
||||
|
||||
```
|
||||
/graphify . # 可用于任意目录:代码库、笔记、论文都可以
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html 可交互图谱:可点节点、搜索、按社区过滤
|
||||
├── GRAPH_REPORT.md God nodes、意外连接、建议提问
|
||||
├── graph.json 持久化图谱:数周后仍可查询,无需重新读原始文件
|
||||
└── cache/ SHA256 缓存:重复运行时只处理变更过的文件
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
graphify 分两轮执行。第一轮是确定性的 AST 提取,对代码文件做结构分析(类、函数、导入、调用图、docstring、解释性注释),这一轮不需要 LLM。第二轮会并行调用 Claude 子代理处理文档、论文和图片,从中提取概念、关系和设计动机。最后把两边结果合并到一个 NetworkX 图里,用 Leiden 社区发现算法做聚类,并导出成可交互 HTML、可查询 JSON,以及一份人类可读的审计报告。
|
||||
|
||||
**聚类是基于图拓扑完成的,不依赖 embeddings。** Leiden 按边密度发现社区。Claude 抽取出的语义相似边(`semantically_similar_to`,标记为 `INFERRED`)本来就存在于图中,所以会直接影响社区划分。图结构本身就是相似性信号,不需要额外的 embedding 步骤,也不需要向量数据库。
|
||||
|
||||
每条关系都会被标记为 `EXTRACTED`(直接在源材料中找到)、`INFERRED`(合理推断,并附带置信度分数)或 `AMBIGUOUS`(有歧义,需要复核)。所以你始终知道哪些是实际发现的,哪些是模型猜出来的。
|
||||
|
||||
## 安装
|
||||
|
||||
**要求:** Python 3.10+,并且使用以下平台之一:[Claude Code](https://claude.ai/code)、[CodeBuddy](https://codebuddy.ai)、[Codex](https://openai.com/codex)、[OpenCode](https://opencode.ai)、[OpenClaw](https://openclaw.ai)、[Factory Droid](https://factory.ai) 或 [Trae](https://trae.ai)
|
||||
|
||||
```bash
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> PyPI 包当前暂时叫 `graphifyy`,因为 `graphify` 这个名字还在回收中。CLI 命令和 skill 命令仍然都是 `graphify`。
|
||||
|
||||
### 平台支持
|
||||
|
||||
| 平台 | 安装命令 |
|
||||
|------|----------|
|
||||
| Claude Code | `graphify install` |
|
||||
| CodeBuddy | `graphify install --platform codebuddy` |
|
||||
| Codex | `graphify install --platform codex` |
|
||||
| OpenCode | `graphify install --platform opencode` |
|
||||
| OpenClaw | `graphify install --platform claw` |
|
||||
| Factory Droid | `graphify install --platform droid` |
|
||||
| Trae | `graphify install --platform trae` |
|
||||
| Trae CN | `graphify install --platform trae-cn` |
|
||||
|
||||
Codex 用户还需要在 `~/.codex/config.toml` 的 `[features]` 下打开 `multi_agent = true`,这样才能并行提取。CodeBuddy 使用与 Claude Code 相同的 Agent 工具和 PreToolUse hook 机制。OpenClaw 目前的并行 agent 支持还比较早期,所以使用顺序提取。Trae 使用 Agent 工具进行并行子代理调度,**不支持** PreToolUse hook,因此 AGENTS.md 是其常驻机制。
|
||||
|
||||
然后打开你的 AI 编码助手,输入:
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
### 让助手始终优先使用图谱(推荐)
|
||||
|
||||
图构建完成后,在项目里运行一次:
|
||||
|
||||
| 平台 | 命令 |
|
||||
|------|------|
|
||||
| Claude Code | `graphify claude install` |
|
||||
| CodeBuddy | `graphify codebuddy install` |
|
||||
| Codex | `graphify codex install` |
|
||||
| OpenCode | `graphify opencode install` |
|
||||
| OpenClaw | `graphify claw install` |
|
||||
| Factory Droid | `graphify droid install` |
|
||||
| Trae | `graphify trae install` |
|
||||
| Trae CN | `graphify trae-cn install` |
|
||||
|
||||
**Claude Code** 会做两件事:
|
||||
1. 在 `CLAUDE.md` 中写入一段规则,告诉 Claude 在回答架构问题前先读 `graphify-out/GRAPH_REPORT.md`
|
||||
2. 安装一个 **PreToolUse hook**(写入 `settings.json`),在每次 `Glob` 和 `Grep` 前触发
|
||||
|
||||
如果知识图谱存在,Claude 会先看到:_"graphify: Knowledge graph exists. Read graphify-out/GRAPH_REPORT.md for god nodes and community structure before searching raw files."_ —— 这样 Claude 会优先按图谱导航,而不是一上来就 grep 整个项目。
|
||||
|
||||
**CodeBuddy** 与 Claude Code 相同,也会做两件事:在 `CODEBUDDY.md` 中写入规则,并安装 **PreToolUse hook**(写入 `.codebuddy/settings.json`),在每次 `Glob` 和 `Grep` 前触发。
|
||||
|
||||
**Codex、OpenCode、OpenClaw、Factory Droid、Trae** 会把同样的规则写进项目根目录的 `AGENTS.md`。这些平台没有 PreToolUse hook,所以 `AGENTS.md` 是它们的常驻机制。
|
||||
|
||||
卸载时使用对应平台的 uninstall 命令即可(例如 `graphify claude uninstall`)。
|
||||
|
||||
**常驻模式和显式触发有什么区别?**
|
||||
|
||||
常驻 hook 会优先暴露 `GRAPH_REPORT.md` —— 这是一页式总结,包含 god nodes、社区结构和意外连接。你的助手在搜索文件前会先读它,因此会按结构导航,而不是按关键字乱搜。这已经能覆盖大部分日常问题。
|
||||
|
||||
`/graphify query`、`/graphify path` 和 `/graphify explain` 会更深入:它们会逐跳遍历底层 `graph.json`,追踪节点之间的精确路径,并展示边级别细节(关系类型、置信度、源位置)。当你想从图谱里精确回答某个问题,而不仅仅是获得整体感知时,就该用这些命令。
|
||||
|
||||
可以这样理解:常驻 hook 是先给助手一张地图,`/graphify` 这几个命令则是让它沿着地图精确导航。
|
||||
|
||||
<details>
|
||||
<summary>手动安装(curl)</summary>
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.claude/skills/graphify
|
||||
curl -fsSL https://raw.githubusercontent.com/safishamsi/graphify/v3/graphify/skill.md \
|
||||
> ~/.claude/skills/graphify/SKILL.md
|
||||
```
|
||||
|
||||
把下面内容加到 `~/.claude/CLAUDE.md`:
|
||||
|
||||
```
|
||||
- **graphify** (`~/.claude/skills/graphify/SKILL.md`) - any input to knowledge graph. Trigger: `/graphify`
|
||||
When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 用法
|
||||
|
||||
```
|
||||
/graphify # 对当前目录运行
|
||||
/graphify ./raw # 对指定目录运行
|
||||
/graphify ./raw --mode deep # 更激进地抽取 INFERRED 边
|
||||
/graphify ./raw --update # 只重新提取变更文件,并合并到已有图谱
|
||||
/graphify ./raw --cluster-only # 只重新聚类已有图谱,不重新提取
|
||||
/graphify ./raw --no-viz # 跳过 HTML,只生成 report + JSON
|
||||
/graphify ./raw --obsidian # 额外生成 Obsidian vault(可选)
|
||||
|
||||
/graphify add https://arxiv.org/abs/1706.03762 # 拉取论文、保存并更新图谱
|
||||
/graphify add https://x.com/karpathy/status/... # 拉取推文
|
||||
/graphify add https://... --author "Name" # 标记原作者
|
||||
/graphify add https://... --contributor "Name" # 标记是谁把它加入语料库的
|
||||
|
||||
/graphify query "what connects attention to the optimizer?"
|
||||
/graphify query "what connects attention to the optimizer?" --dfs # 追踪一条具体路径
|
||||
/graphify query "what connects attention to the optimizer?" --budget 1500 # 把预算限制在 N tokens
|
||||
/graphify path "DigestAuth" "Response"
|
||||
/graphify explain "SwinTransformer"
|
||||
|
||||
/graphify ./raw --watch # 文件变更时自动同步图谱(代码:立即更新;文档:提醒你)
|
||||
/graphify ./raw --wiki # 构建可供 agent 抓取的 wiki(index.md + 每个 community 一篇文章)
|
||||
/graphify ./raw --svg # 导出 graph.svg
|
||||
/graphify ./raw --graphml # 导出 graph.graphml(Gephi、yEd)
|
||||
/graphify ./raw --neo4j # 生成给 Neo4j 用的 cypher.txt
|
||||
/graphify ./raw --neo4j-push bolt://localhost:7687 # 直接推送到运行中的 Neo4j
|
||||
/graphify ./raw --mcp # 启动 MCP stdio server
|
||||
|
||||
# git hooks - 跨平台,在 commit 和切分支后重建图谱
|
||||
graphify hook install
|
||||
graphify hook uninstall
|
||||
graphify hook status
|
||||
|
||||
# 常驻助手规则 - 按平台区分
|
||||
graphify claude install # CLAUDE.md + PreToolUse hook(Claude Code)
|
||||
graphify claude uninstall
|
||||
graphify codex install # AGENTS.md(Codex)
|
||||
graphify opencode install # AGENTS.md(OpenCode)
|
||||
graphify claw install # AGENTS.md(OpenClaw)
|
||||
graphify droid install # AGENTS.md(Factory Droid)
|
||||
graphify trae install # AGENTS.md(Trae)
|
||||
graphify trae uninstall
|
||||
graphify trae-cn install # AGENTS.md(Trae CN)
|
||||
graphify trae-cn uninstall
|
||||
```
|
||||
|
||||
支持混合文件类型:
|
||||
|
||||
| 类型 | 扩展名 | 提取方式 |
|
||||
|------|--------|----------|
|
||||
| 代码 | `.py .ts .js .go .rs .java .c .cpp .rb .cs .kt .scala .php` | tree-sitter AST + 调用图 + docstring / 注释中的 rationale |
|
||||
| 文档 | `.md .txt .rst` | 通过 Claude 提取概念、关系和设计动机 |
|
||||
| 论文 | `.pdf` | 引文挖掘 + 概念提取 |
|
||||
| 图片 | `.png .jpg .webp .gif` | Claude vision —— 截图、图表、任意语言都可以 |
|
||||
|
||||
## 你会得到什么
|
||||
|
||||
**God nodes** —— 度最高的概念节点(整个系统最容易汇聚到的地方)
|
||||
|
||||
**意外连接** —— 按综合得分排序。代码-论文之间的边会比代码-代码边权重更高。每条结果都会附带一段人话解释。
|
||||
|
||||
**建议提问** —— 图谱特别擅长回答的 4 到 5 个问题。
|
||||
|
||||
**“为什么”** —— docstring、行内注释(`# NOTE:`、`# IMPORTANT:`、`# HACK:`、`# WHY:`)以及文档里的设计动机都会被抽取成 `rationale_for` 节点。不只是知道代码“做了什么”,还能知道“为什么要这么写”。
|
||||
|
||||
**置信度分数** —— 每条 `INFERRED` 边都有 `confidence_score`(0.0-1.0)。你不只知道哪些是猜出来的,还知道模型对这个猜测有多有把握。`EXTRACTED` 边恒为 1.0。
|
||||
|
||||
**语义相似边** —— 跨文件的概念连接,即使结构上没有直接依赖也能建立关联。比如两个函数做的是同一类问题但彼此没有调用,或者某个代码类和某篇论文里的算法概念本质相同。
|
||||
|
||||
**超边(Hyperedges)** —— 用来表达 3 个以上节点的群组关系,这是普通两两边表达不出来的。比如:一组类共同实现一个协议、认证链路里的一组函数、同一篇论文某一节里的多个概念共同组成一个想法。
|
||||
|
||||
**Token 基准** —— 每次运行后都会自动打印。对混合语料(Karpathy 的仓库 + 论文 + 图片),每次查询的 token 消耗可以比直接读原文件少 **71.5 倍**。第一次运行需要先提取并建图,这一步会花 token;后续查询直接读取压缩后的图谱,节省会越来越明显。SHA256 缓存保证重复运行时只重新处理变更文件。
|
||||
|
||||
**自动同步**(`--watch`)—— 在后台终端里跑着,代码库一变化,图谱就会跟着更新。代码文件保存会立刻触发重建(只走 AST,不用 LLM);文档/图片变更则会提醒你跑 `--update` 进行 LLM 再提取。
|
||||
|
||||
**Git hooks**(`graphify hook install`)—— 安装 `post-commit` 和 `post-checkout` hook。每次 commit 后、每次切分支后都会自动重建图谱,不需要额外开一个后台进程。
|
||||
|
||||
**Wiki**(`--wiki`)—— 为每个 community 和 god node 生成类似维基百科的 Markdown 文章,并提供 `index.md` 作为入口。任何 agent 只要读 `index.md`,就能通过普通文件导航整个知识库,而不必直接解析 JSON。
|
||||
|
||||
## Worked examples
|
||||
|
||||
| 语料 | 文件数 | 压缩比 | 输出 |
|
||||
|------|--------|--------|------|
|
||||
| Karpathy 的仓库 + 5 篇论文 + 4 张图片 | 52 | **71.5x** | [`worked/karpathy-repos/`](worked/karpathy-repos/) |
|
||||
| graphify 源码 + Transformer 论文 | 4 | **5.4x** | [`worked/mixed-corpus/`](worked/mixed-corpus/) |
|
||||
| httpx(合成 Python 库) | 6 | ~1x | [`worked/httpx/`](worked/httpx/) |
|
||||
|
||||
Token 压缩效果会随着语料规模增大而更明显。6 个文件本来就塞得进上下文窗口,所以 graphify 在这种场景里的价值更多是结构清晰度,而不是 token 压缩。到了 52 个文件(代码 + 论文 + 图片)这种规模,就能做到 71x+。每个 `worked/` 目录里都带了原始输入和真实输出(`GRAPH_REPORT.md`、`graph.json`),你可以自己跑一遍核对数字。
|
||||
|
||||
## 隐私
|
||||
|
||||
graphify 会把文档、论文和图片的内容发送给你所用 AI 编码助手背后的模型 API 来做语义提取 —— 可能是 Anthropic(Claude Code)、OpenAI(Codex),或者你当前平台使用的其他提供方。代码文件则完全在本地通过 tree-sitter AST 处理,不会把代码内容发出去。项目本身没有任何遥测、使用跟踪或分析。唯一的网络请求就是语义提取阶段调用你平台自己的模型 API,使用的也是你自己的 API key。
|
||||
|
||||
## 技术栈
|
||||
|
||||
NetworkX + Leiden(graspologic)+ tree-sitter + vis.js。语义提取由 Claude(Claude Code)、GPT-4(Codex)或你当前平台所运行的模型完成。不需要 Neo4j,不需要 server,整体是纯本地运行。
|
||||
|
||||
<details>
|
||||
<summary>贡献</summary>
|
||||
|
||||
**Worked examples** 是最能建立信任的贡献方式。对一个真实语料跑 `/graphify`,把输出保存到 `worked/{slug}/`,再写一份诚实的 `review.md`,评价图谱哪些地方做得对、哪些地方做得不对,然后提交 PR。
|
||||
|
||||
**提取 bug** —— 提 issue 时请附上输入文件、对应的缓存项(`graphify-out/cache/`)以及它漏提取或瞎编了什么。
|
||||
|
||||
模块职责和新增语言的方法见 [ARCHITECTURE.md](ARCHITECTURE.md)。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,77 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/safishamsi/graphify/v4/docs/logo-text.svg" width="260" height="64" alt="Graphify"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
🇺🇸 <a href="../../README.md">English</a> | 🇨🇳 <a href="README.zh-CN.md">简体中文</a> | 🇯🇵 <a href="README.ja-JP.md">日本語</a> | 🇰🇷 <a href="README.ko-KR.md">한국어</a> | 🇩🇪 <a href="README.de-DE.md">Deutsch</a> | 🇫🇷 <a href="README.fr-FR.md">Français</a> | 🇪🇸 <a href="README.es-ES.md">Español</a> | 🇮🇳 <a href="README.hi-IN.md">हिन्दी</a> | 🇧🇷 <a href="README.pt-BR.md">Português</a> | 🇷🇺 <a href="README.ru-RU.md">Русский</a> | 🇸🇦 <a href="README.ar-SA.md">العربية</a> | 🇮🇹 <a href="README.it-IT.md">Italiano</a> | 🇵🇱 <a href="README.pl-PL.md">Polski</a> | 🇳🇱 <a href="README.nl-NL.md">Nederlands</a> | 🇹🇷 <a href="README.tr-TR.md">Türkçe</a> | 🇺🇦 <a href="README.uk-UA.md">Українська</a> | 🇻🇳 <a href="README.vi-VN.md">Tiếng Việt</a> | 🇮🇩 <a href="README.id-ID.md">Bahasa Indonesia</a> | 🇸🇪 <a href="README.sv-SE.md">Svenska</a> | 🇬🇷 <a href="README.el-GR.md">Ελληνικά</a> | 🇷🇴 <a href="README.ro-RO.md">Română</a> | 🇨🇿 <a href="README.cs-CZ.md">Čeština</a> | 🇫🇮 <a href="README.fi-FI.md">Suomi</a> | 🇩🇰 <a href="README.da-DK.md">Dansk</a> | 🇳🇴 <a href="README.no-NO.md">Norsk</a> | 🇭🇺 <a href="README.hu-HU.md">Magyar</a> | 🇹🇭 <a href="README.th-TH.md">ภาษาไทย</a> | 🇺🇿 <a href="README.uz-UZ.md">Oʻzbekcha</a> | 🇹🇼 <a href="README.zh-TW.md">繁體中文</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/safishamsi/graphify/actions/workflows/ci.yml"><img src="https://github.com/safishamsi/graphify/actions/workflows/ci.yml/badge.svg?branch=v4" alt="CI"/></a>
|
||||
<a href="https://pypi.org/project/graphifyy/"><img src="https://img.shields.io/pypi/v/graphifyy" alt="PyPI"/></a>
|
||||
<a href="https://pepy.tech/project/graphifyy"><img src="https://static.pepy.tech/badge/graphifyy" alt="Downloads"/></a>
|
||||
<a href="https://github.com/sponsors/safishamsi"><img src="https://img.shields.io/badge/sponsor-safishamsi-ea4aaa?logo=github-sponsors" alt="Sponsor"/></a>
|
||||
</p>
|
||||
|
||||
**AI 程式碼助手的技能。** 在 Claude Code、Codex、OpenCode、Cursor、Gemini CLI、GitHub Copilot CLI、VS Code Copilot Chat、Aider、OpenClaw、Factory Droid、Trae、Hermes、Kiro 或 Google Antigravity 中輸入 `/graphify` — 它會讀取您的檔案、建立知識圖譜,並返回您不知道存在的結構。更快理解程式碼庫。找到架構決策背後的「為什麼」。
|
||||
|
||||
完全多模態。添加程式碼、PDF、Markdown、截圖、圖表、白板照片、其他語言的圖片或視訊和音訊檔案 — graphify 從所有內容中提取概念和關係,並將它們連接成單一圖譜。視訊使用 Whisper 在本地轉錄。透過 tree-sitter AST 支援 25 種程式語言。
|
||||
|
||||
> Andrej Karpathy 維護一個 `/raw` 資料夾,在那裡他放置論文、推文、截圖和筆記。graphify 是這個問題的答案 — 每次查詢比讀取原始檔案少 **71.5 倍** 的 token,在會話之間持久存在。
|
||||
|
||||
```
|
||||
/graphify .
|
||||
```
|
||||
|
||||
```
|
||||
graphify-out/
|
||||
├── graph.html 互動式圖譜 — 在任何瀏覽器中開啟
|
||||
├── GRAPH_REPORT.md 神級節點、令人驚訝的連接、建議問題
|
||||
├── graph.json 持久圖譜 — 幾週後仍可查詢
|
||||
└── cache/ SHA256 快取 — 重複執行只處理已變更的檔案
|
||||
```
|
||||
|
||||
## 運作原理
|
||||
|
||||
graphify 分三個階段工作。首先,確定性 AST 遍歷在不使用 LLM 的情況下從程式碼檔案中提取結構。然後使用 faster-whisper 在本地轉錄視訊和音訊檔案。最後,Claude 子代理並行處理文件、論文、圖片和轉錄文字。結果被合併到 NetworkX 圖譜中,使用 Leiden 進行聚類,並匯出為互動式 HTML、可查詢 JSON 和審計報告。
|
||||
|
||||
每個關係都標記為 `EXTRACTED`、`INFERRED`(帶有置信度分數)或 `AMBIGUOUS`。
|
||||
|
||||
## 安裝
|
||||
|
||||
**需求:** Python 3.10+ 以及以下之一:[Claude Code](https://claude.ai/code)、[Codex](https://openai.com/codex)、[OpenCode](https://opencode.ai)、[Cursor](https://cursor.com) 等。
|
||||
|
||||
```bash
|
||||
uv tool install graphifyy && graphify install
|
||||
# 或使用 pipx
|
||||
pipx install graphifyy && graphify install
|
||||
# 或 pip
|
||||
pip install graphifyy && graphify install
|
||||
```
|
||||
|
||||
> **官方套件:** PyPI 套件名稱為 `graphifyy`。唯一的官方儲存庫是 [safishamsi/graphify](https://github.com/safishamsi/graphify)。
|
||||
|
||||
## 使用方式
|
||||
|
||||
```
|
||||
/graphify .
|
||||
/graphify ./raw --update
|
||||
/graphify query "什麼將 Attention 與 optimizer 連接起來?"
|
||||
/graphify path "DigestAuth" "Response"
|
||||
graphify hook install
|
||||
graphify update ./src
|
||||
```
|
||||
|
||||
## 您會得到什麼
|
||||
|
||||
**神級節點** — 度數最高的概念 · **令人驚訝的連接** — 按分數排名 · **建議問題** · **「為什麼」** — 提取為節點的文件字串和設計理由 · **Token 基準測試** — 在混合語料庫上少 **71.5 倍** 的 token。
|
||||
|
||||
## 隱私
|
||||
|
||||
程式碼檔案透過 tree-sitter AST 在本地處理。視訊使用 faster-whisper 在本地轉錄。無遙測。
|
||||
|
||||
## 基於 graphify 構建 — Penpax
|
||||
|
||||
[**Penpax**](https://safishamsi.github.io/penpax.ai) 是 graphify 之上的企業層。**免費試用即將推出。** [加入等待名單 →](https://safishamsi.github.io/penpax.ai)
|
||||
|
||||
[](https://star-history.com/#safishamsi/graphify&Date)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""graphify - extract · build · cluster · analyze · report."""
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
# Lazy imports so `graphify install` works before heavy deps are in place.
|
||||
_map = {
|
||||
"extract": ("graphify.extract", "extract"),
|
||||
"collect_files": ("graphify.extract", "collect_files"),
|
||||
"build_from_json": ("graphify.build", "build_from_json"),
|
||||
"cluster": ("graphify.cluster", "cluster"),
|
||||
"score_all": ("graphify.cluster", "score_all"),
|
||||
"cohesion_score": ("graphify.cluster", "cohesion_score"),
|
||||
"god_nodes": ("graphify.analyze", "god_nodes"),
|
||||
"surprising_connections": ("graphify.analyze", "surprising_connections"),
|
||||
"suggest_questions": ("graphify.analyze", "suggest_questions"),
|
||||
"generate": ("graphify.report", "generate"),
|
||||
"to_json": ("graphify.export", "to_json"),
|
||||
"to_html": ("graphify.export", "to_html"),
|
||||
"to_svg": ("graphify.export", "to_svg"),
|
||||
"to_canvas": ("graphify.export", "to_canvas"),
|
||||
"to_wiki": ("graphify.wiki", "to_wiki"),
|
||||
"reflect": ("graphify.reflect", "reflect"),
|
||||
"save_query_result": ("graphify.ingest", "save_query_result"),
|
||||
}
|
||||
if name in _map:
|
||||
import importlib
|
||||
mod_name, attr = _map[name]
|
||||
mod = importlib.import_module(mod_name)
|
||||
return getattr(mod, attr)
|
||||
raise AttributeError(f"module 'graphify' has no attribute {name!r}")
|
||||
@@ -0,0 +1,673 @@
|
||||
"""graphify CLI - `graphify install` sets up the Claude Code skill."""
|
||||
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
|
||||
__version__ = _pkg_version("graphifyy")
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
|
||||
# Output directory — override with GRAPHIFY_OUT env var for worktrees or shared-output setups.
|
||||
# Accepts a relative name ("graphify-out-feature") or an absolute path ("/shared/graphify-out").
|
||||
# Defined once in graphify.paths so the security/callflow path guards honour the
|
||||
# same override (#1423).
|
||||
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
|
||||
|
||||
# Install/uninstall subsystem moved to graphify/install.py; re-exported here so
|
||||
# `from graphify.__main__ import <name>` keeps working unchanged.
|
||||
from graphify.install import ( # noqa: E402,F401
|
||||
dispatch_install_cli,
|
||||
_agents_install,
|
||||
_agents_platform_install,
|
||||
_agents_platform_uninstall,
|
||||
_agents_uninstall,
|
||||
_always_on,
|
||||
_amp_install,
|
||||
_amp_legacy_cleanup,
|
||||
_amp_uninstall,
|
||||
_antigravity_finalize,
|
||||
_antigravity_install,
|
||||
_antigravity_uninstall,
|
||||
_canonical_platform,
|
||||
_claude_pretooluse_hooks,
|
||||
_copy_skill_file,
|
||||
_cursor_install,
|
||||
_cursor_uninstall,
|
||||
_devin_rules_install,
|
||||
_devin_rules_uninstall,
|
||||
_gemini_hook,
|
||||
_install_claude_hook,
|
||||
_install_codebuddy_hook,
|
||||
_install_codex_hook,
|
||||
_install_gemini_hook,
|
||||
_install_kilo_plugin,
|
||||
_install_opencode_plugin,
|
||||
_install_skill_references,
|
||||
_kilo_config_path,
|
||||
_kilo_config_write_path,
|
||||
_kilo_install,
|
||||
_kilo_uninstall,
|
||||
_kilo_uninstall_global,
|
||||
_kiro_install,
|
||||
_kiro_uninstall,
|
||||
_load_json_like,
|
||||
_packaged_skill_refs_dir,
|
||||
_platform_skill_destination,
|
||||
_print_banner,
|
||||
_print_install_usage,
|
||||
_print_project_git_add_hint,
|
||||
_project_install,
|
||||
_project_scope_root,
|
||||
_project_uninstall,
|
||||
_project_uninstall_all,
|
||||
_refresh_all_version_stamps,
|
||||
_remove_claude_skill_registration,
|
||||
_remove_skill_file,
|
||||
_replace_or_append_section,
|
||||
_resolve_graphify_exe,
|
||||
_skill_registration,
|
||||
_strip_graphify_hook,
|
||||
_strip_graphify_md_section,
|
||||
_strip_json_comments,
|
||||
_uninstall_claude_hook,
|
||||
_uninstall_codebuddy_hook,
|
||||
_uninstall_codex_hook,
|
||||
_uninstall_gemini_hook,
|
||||
_uninstall_kilo_plugin,
|
||||
_uninstall_opencode_plugin,
|
||||
claude_install,
|
||||
claude_uninstall,
|
||||
codebuddy_install,
|
||||
codebuddy_uninstall,
|
||||
gemini_install,
|
||||
gemini_uninstall,
|
||||
install,
|
||||
uninstall_all,
|
||||
vscode_install,
|
||||
vscode_uninstall,
|
||||
_PLATFORM_ALIASES,
|
||||
_CLAUDE_MD_MARKER,
|
||||
_CODEBUDDY_MD_MARKER,
|
||||
_AGENTS_MD_MARKER,
|
||||
_GEMINI_MD_MARKER,
|
||||
_VSCODE_INSTRUCTIONS_MARKER,
|
||||
_ANTIGRAVITY_RULES_PATH,
|
||||
_ANTIGRAVITY_WORKFLOW_PATH,
|
||||
_ANTIGRAVITY_WORKFLOW,
|
||||
_CURSOR_RULE_PATH,
|
||||
_CURSOR_RULE,
|
||||
_DEVIN_RULES_PATH,
|
||||
_DEVIN_RULES,
|
||||
_KILO_PLUGIN_JS,
|
||||
_KILO_PLUGIN_PATH,
|
||||
_KILO_CONFIG_JSON_PATH,
|
||||
_KILO_CONFIG_JSONC_PATH,
|
||||
_OPENCODE_PLUGIN_JS,
|
||||
_OPENCODE_PLUGIN_PATH,
|
||||
_OPENCODE_CONFIG_PATH,
|
||||
_PLATFORM_CONFIG,
|
||||
)
|
||||
from graphify.cli import ( # noqa: E402,F401
|
||||
dispatch_command,
|
||||
_StageTimer,
|
||||
_clone_repo,
|
||||
_default_graph_path,
|
||||
_enforce_graph_size_cap_or_exit,
|
||||
_run_hook_guard,
|
||||
_SEARCH_NUDGE,
|
||||
_READ_NUDGE,
|
||||
_HOOK_SOURCE_EXTS,
|
||||
_GEMINI_NUDGE_TEXT,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
_ALWAYS_ON_ALIASES = {
|
||||
"_CLAUDE_MD_SECTION": "claude-md",
|
||||
"_AGENTS_MD_SECTION": "agents-md",
|
||||
"_GEMINI_MD_SECTION": "gemini-md",
|
||||
"_VSCODE_INSTRUCTIONS_SECTION": "vscode-instructions",
|
||||
"_ANTIGRAVITY_RULES": "antigravity-rules",
|
||||
"_KIRO_STEERING": "kiro-steering",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> str:
|
||||
# PEP 562: lazily resolve the legacy always-on section constants for external
|
||||
# importers (e.g. the install-string tests). In-module code calls _always_on()
|
||||
# directly; nothing is read at import time, so a missing block can no longer
|
||||
# brick the CLI on `import graphify.__main__` (#1121 follow-up).
|
||||
base = _ALWAYS_ON_ALIASES.get(name)
|
||||
if base is not None:
|
||||
return _always_on(base)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _check_skill_version(skill_dst: Path) -> None:
|
||||
"""Warn if the installed skill is from an older graphify version."""
|
||||
version_file = skill_dst.parent / ".graphify_version"
|
||||
try:
|
||||
if not version_file.exists():
|
||||
return
|
||||
except OSError:
|
||||
return
|
||||
try:
|
||||
skill_exists = skill_dst.exists()
|
||||
except OSError:
|
||||
return
|
||||
if not skill_exists:
|
||||
print(" warning: skill dir exists but SKILL.md is missing. Run 'graphify install' to repair.")
|
||||
return
|
||||
# A progressive SKILL.md links to its references/ sidecar. If the body points
|
||||
# at references/ but the dir is gone (manual delete, partial upgrade), the
|
||||
# on-demand fragments won't load — flag it for repair.
|
||||
try:
|
||||
body = skill_dst.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
body = ""
|
||||
if "references/" in body and not (skill_dst.parent / "references").exists():
|
||||
print(" warning: skill references/ sidecar is missing. Run 'graphify install' to repair.", file=sys.stderr)
|
||||
try:
|
||||
installed = version_file.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return
|
||||
if installed != __version__:
|
||||
if _version_tuple(installed) > _version_tuple(__version__):
|
||||
# The skill on disk is NEWER than the running package. `graphify install`
|
||||
# writes the package's OWN (older) bundled skill and re-stamps the version,
|
||||
# so following the old "run install" advice would silently DOWNGRADE the
|
||||
# skill. The real fix is to upgrade the package (#1568). Common for a stale
|
||||
# `uv tool` CLI, or a contributor whose dev checkout stamped a newer skill.
|
||||
print(
|
||||
f" warning: skill is from graphify {installed}, but the package is "
|
||||
f"{__version__} (older). Upgrade the package "
|
||||
f"(e.g. 'uv tool upgrade graphifyy' or 'pip install -U graphifyy'); "
|
||||
f"running 'graphify install' would downgrade the skill.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(f" warning: skill is from graphify {installed}, package is {__version__}. Run 'graphify install' to update.", file=sys.stderr)
|
||||
|
||||
|
||||
def _version_tuple(version: str) -> tuple[int, ...]:
|
||||
"""Parse a version string into a comparable integer tuple (``0.9.2`` -> ``(0, 9, 2)``).
|
||||
|
||||
Reads the leading digits of each dot-segment, so pre/post-release suffixes
|
||||
(``1.0.0rc1``) compare by their numeric core. A non-numeric or empty segment
|
||||
becomes 0, so a malformed stamp degrades to a conservative comparison rather
|
||||
than raising.
|
||||
"""
|
||||
parts: list[int] = []
|
||||
for segment in str(version).split("."):
|
||||
digits = ""
|
||||
for ch in segment:
|
||||
if ch.isdigit():
|
||||
digits += ch
|
||||
else:
|
||||
break
|
||||
parts.append(int(digits) if digits else 0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# PreToolUse nudge payloads, emitted verbatim by the shell-agnostic
|
||||
# `graphify hook-guard` subcommand (see _run_hook_guard). The previous hooks
|
||||
# inlined POSIX bash (case/esac, [ -f ], single-quoted echo) which Windows
|
||||
# cmd.exe/PowerShell cannot parse, so on Windows the hook failed and the nudge
|
||||
# silently vanished — users had to invoke /graphify by hand (#522). Moving the
|
||||
# logic into a Python subcommand invoked via an absolute exe path makes the hook
|
||||
# parse identically under sh, cmd.exe and PowerShell. Claude Code accepts
|
||||
# additionalContext on PreToolUse (Codex Desktop does not — that path stays a
|
||||
# no-op via `hook-check`). Compact separators keep the payload byte-for-byte the
|
||||
# same JSON the old `echo` emitted.
|
||||
|
||||
|
||||
# Source/doc extensions the Read|Glob guard nudges on (verbatim from the old hook).
|
||||
# The trailing-extension test (real final path segment, then its last '.') means
|
||||
# '.json' never false-matches '.js', and framework files like '.astro' are kept.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# The always-on instruction blocks are packaged markdown under graphify/always_on/,
|
||||
# generated by tools/skillgen and guarded by `skillgen --check`. Reading them at
|
||||
# load keeps the install-string / issue-#580 contract byte-for-byte while letting
|
||||
# a human edit one fragment instead of a triple-quoted literal here.
|
||||
|
||||
|
||||
|
||||
# AGENTS.md section for Codex, OpenCode, and OpenClaw.
|
||||
# All three platforms read AGENTS.md in the project root for persistent instructions.
|
||||
|
||||
|
||||
|
||||
|
||||
# Gemini CLI BeforeTool hook nudge text. The hook always returns
|
||||
# {"decision":"allow"} (never blocks a tool) and appends this as additionalContext
|
||||
# when a graph exists. Emitted by `graphify hook-guard gemini`. The old hook was a
|
||||
# `python -c "..."` one-liner that depended on a bare `python` on PATH (often
|
||||
# `python`/`py` or absent on Windows) and embedded backticks + escaped quotes that
|
||||
# Windows PowerShell mangles (#522 follow-up); the subcommand form has no such
|
||||
# dependency and parses under every shell.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_KIRO_STEERING_MARKER = "graphify: A knowledge graph of this project"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
_CODEX_HOOK = {
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
# Use the graphify CLI itself so the hook is shell-agnostic:
|
||||
# no [ -f ] bash syntax, no python3 vs python Conda issue,
|
||||
# no JSON escaping inside PowerShell strings. Works on
|
||||
# Windows (PowerShell/cmd.exe), macOS, and Linux.
|
||||
"command": "graphify hook-check",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
if _stream is not None and hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
pass
|
||||
# Check all known skill install locations for a stale version stamp.
|
||||
# Skip during install/uninstall (hook writes trigger a fresh check anyway).
|
||||
# Skip during hook-check — it runs on every editor tool use and must be silent.
|
||||
# Deduplicate paths so platforms sharing the same install dir don't warn twice.
|
||||
_silent_cmds = {"install", "uninstall", "hook-check", "hook-guard"}
|
||||
if not any(arg in _silent_cmds for arg in sys.argv):
|
||||
# Resolve each platform's real user-scope destination so per-platform
|
||||
# overrides (gemini, opencode, devin, antigravity, amp) check the dir
|
||||
# they actually install into, not the bare cfg['skill_dst'].
|
||||
for skill_dst in {_platform_skill_destination(name) for name in _PLATFORM_CONFIG}:
|
||||
_check_skill_version(skill_dst)
|
||||
|
||||
if len(sys.argv) >= 2 and sys.argv[1] in ("-v", "--version", "version"):
|
||||
print(f"graphify {__version__}")
|
||||
return
|
||||
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "-?"):
|
||||
print("Usage: graphify <command>")
|
||||
print()
|
||||
print("Commands:")
|
||||
print(" install [--platform P] copy skill to platform config dir (claude|windows|codebuddy|codex|opencode|aider|amp|agents|claw|droid|trae|trae-cn|gemini|cursor|antigravity|hermes|kiro|pi|devin)")
|
||||
print(" uninstall remove graphify from all detected platforms in one shot")
|
||||
print(" --purge also delete graphify-out/ directory")
|
||||
print(" path \"A\" \"B\" shortest path between two nodes in graph.json")
|
||||
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
|
||||
print(" explain \"X\" plain-language explanation of a node and its neighbors")
|
||||
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
|
||||
print(" diagnose multigraph report same-endpoint edge collapse risk in graph.json")
|
||||
print(" --graph <path> path to graph/extraction JSON")
|
||||
print(" (default graphify-out/graph.json)")
|
||||
print(" --json emit machine-readable JSON")
|
||||
print(" --max-examples N max same-endpoint examples to print (default 5)")
|
||||
print(" --directed force directed post-build simulation")
|
||||
print(" --undirected force undirected post-build simulation")
|
||||
print(" (default follows JSON directed flag;")
|
||||
print(" raw extraction with no flag defaults directed)")
|
||||
print(" --extract-path PATH extractor source for suppression scan")
|
||||
print(" clone <github-url> clone a GitHub repo locally and print its path for /graphify")
|
||||
print(" merge-driver <base> <current> <other> git merge driver: union-merge two graph.json files (set up via hook install)")
|
||||
print(" merge-graphs <g1> <g2> merge two or more graph.json files into one cross-repo graph")
|
||||
print(" --out <path> output path (default: graphify-out/merged-graph.json)")
|
||||
print(" --branch <branch> checkout a specific branch (default: repo default)")
|
||||
print(" --out <dir> clone to a custom directory (default: ~/.graphify/repos/<owner>/<repo>)")
|
||||
print(" add <url> fetch a URL and save it to ./raw, then update the graph")
|
||||
print(" --author \"Name\" tag the author of the content")
|
||||
print(" --contributor \"Name\" tag who added it to the corpus")
|
||||
print(" --dir <path> target directory (default: ./raw)")
|
||||
print(" watch <path> watch a folder and rebuild the graph on code changes")
|
||||
print(" update <path> re-extract code files and update the graph (no LLM needed)")
|
||||
print(" --force overwrite graph.json even if the rebuild has fewer nodes")
|
||||
print(" (also: GRAPHIFY_FORCE=1 env var; use after refactors that delete code)")
|
||||
print(" --no-cluster skip clustering, write raw extraction only")
|
||||
print(" cluster-only <path> rerun clustering on an existing graph.json and regenerate report")
|
||||
print(" --no-viz skip graph.html generation (useful for >5000 node graphs / CI)")
|
||||
print(" --graph <path> path to graph.json (default <path>/graphify-out/graph.json)")
|
||||
print(" --no-label keep 'Community N' placeholders (skip LLM community naming)")
|
||||
print(" --backend=<name> backend to use for community naming (default: auto-detect)")
|
||||
print(" --model=<name> model to use for community naming")
|
||||
print(" --max-concurrency=N parallel community-labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)")
|
||||
print(" --batch-size=N communities per labeling LLM call (default 100)")
|
||||
print(" label <path> (re)name communities with the configured LLM backend, regenerate report")
|
||||
print(" --missing-only keep existing labels and only name missing/placeholder communities")
|
||||
print(" --backend=<name> backend to use (default: auto-detect from API keys)")
|
||||
print(" --model=<name> model to use for community naming")
|
||||
print(" --max-concurrency=N parallel labeling LLM calls (default 4; forced to 1 for ollama/claude-cli)")
|
||||
print(" --batch-size=N communities per labeling LLM call (default 100)")
|
||||
print(" query \"<question>\" BFS traversal of graph.json for a question")
|
||||
print(" --dfs use depth-first instead of breadth-first")
|
||||
print(" --context C explicit edge-context filter (repeatable)")
|
||||
print(" --budget N cap output at N tokens (default 2000)")
|
||||
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
|
||||
print(" affected \"X\" reverse traversal to find nodes impacted by X")
|
||||
print(" --relation R edge relation to traverse in reverse (repeatable)")
|
||||
print(" --depth N reverse traversal depth (default 2)")
|
||||
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
|
||||
print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop")
|
||||
print(" --question Q the question asked")
|
||||
print(" --answer A the answer to save")
|
||||
print(
|
||||
" --type T query type: query|path_query|explain (default: query)"
|
||||
)
|
||||
print(" --nodes N1 N2 ... source node labels cited in the answer")
|
||||
print(" --outcome O work-memory signal: useful|dead_end|corrected")
|
||||
print(" --correction TEXT what the right answer was (pairs with --outcome corrected)")
|
||||
print(" --memory-dir DIR memory directory (default: graphify-out/memory)")
|
||||
print(" reflect aggregate graphify-out/memory/ outcomes into a deterministic lessons doc")
|
||||
print(" --memory-dir DIR memory directory (default: graphify-out/memory)")
|
||||
print(" --out FILE output path (default: graphify-out/reflections/LESSONS.md)")
|
||||
print(" --graph PATH graph.json, for community grouping + dropping stale nodes (optional)")
|
||||
print(" --analysis PATH .graphify_analysis.json (optional, auto-detected next to --graph)")
|
||||
print(" --labels PATH .graphify_labels.json (optional, auto-detected next to --graph)")
|
||||
print(" --half-life-days N signal weight halves every N days (default 30)")
|
||||
print(" --min-corroboration N distinct useful results to prefer a node (default 2)")
|
||||
print(" check-update <path> check needs_update flag and notify if semantic re-extraction is pending (cron-safe)")
|
||||
print(" tree emit a D3 v7 collapsible-tree HTML for graph.json")
|
||||
print(" --graph PATH path to graph.json (default graphify-out/graph.json)")
|
||||
print(" --output HTML output path (default graphify-out/GRAPH_TREE.html)")
|
||||
print(" --root PATH filesystem root for the hierarchy")
|
||||
print(" --max-children N cap children per node (default 200)")
|
||||
print(" --top-k-edges N per-symbol outbound edges in inspector (default 12)")
|
||||
print(" --label NAME project label in header")
|
||||
print(" extract <path> headless full extraction (AST + semantic LLM) for CI/scripts")
|
||||
print(" --backend B gemini|kimi|claude|openai|deepseek|ollama (default: whichever API key is set)")
|
||||
print(" openai also reaches self-hosted OpenAI-compatible servers (llama.cpp,")
|
||||
print(" vLLM, LM Studio): set OPENAI_BASE_URL (e.g. http://localhost:8080/v1)")
|
||||
print(" and OPENAI_MODEL to the model name your server serves")
|
||||
print(" claude also reaches custom Anthropic-compatible endpoints (LiteLLM")
|
||||
print(" proxy, gateways): set ANTHROPIC_BASE_URL and ANTHROPIC_MODEL")
|
||||
print(" --model M override backend default model")
|
||||
print(" --mode deep aggressive INFERRED-edge semantic extraction")
|
||||
print(" --max-workers N AST extraction subprocess count (default: cpu_count)")
|
||||
print(" --token-budget N per-chunk token cap for semantic extraction (default: 60000)")
|
||||
print(" --max-concurrency N parallel semantic chunks in flight (default: 4; set 1 for local LLMs)")
|
||||
print(" --api-timeout S per-request timeout in seconds for the LLM client (default: 600)")
|
||||
print(" --out DIR output dir (default: <path>); writes <DIR>/graphify-out/")
|
||||
print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction")
|
||||
print(" --no-cluster skip clustering, write raw extraction only")
|
||||
print(" --code-only index code (local AST, no API key) and skip doc/paper/image files")
|
||||
print(" --postgres DSN extract schema from a live PostgreSQL database")
|
||||
print(" maps tables, views, functions + FK relationships;")
|
||||
print(" column-level detail is not represented in the graph")
|
||||
print(" --cargo extract crate→crate deps from Cargo.toml")
|
||||
print(" --global also merge the resulting graph into the global graph")
|
||||
print(" --as <tag> repo tag for --global (default: target directory name)")
|
||||
print(" global add <graph.json> add/update a project graph in the global graph (~/.graphify/global-graph.json)")
|
||||
print(" --as <tag> repo tag (default: parent directory name)")
|
||||
print(" global remove <tag> remove a repo's nodes from the global graph")
|
||||
print(" global list list repos in the global graph")
|
||||
print(" global path print path to the global graph file")
|
||||
print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach")
|
||||
print(" export callflow-html emit Mermaid-based architecture/call-flow HTML")
|
||||
print(" hook install install post-commit/post-checkout git hooks (all platforms)")
|
||||
print(" hook uninstall remove git hooks")
|
||||
print(" hook status check if git hooks are installed")
|
||||
print(
|
||||
" gemini install write GEMINI.md section + BeforeTool hook (Gemini CLI)"
|
||||
)
|
||||
print(" gemini uninstall remove GEMINI.md section + BeforeTool hook")
|
||||
print(" cursor install write .cursor/rules/graphify.mdc (Cursor)")
|
||||
print(" cursor uninstall remove .cursor/rules/graphify.mdc")
|
||||
print(" claude install write graphify section to CLAUDE.md + PreToolUse hook (Claude Code)")
|
||||
print(" claude uninstall remove graphify section from CLAUDE.md + PreToolUse hook")
|
||||
print(" codebuddy install write graphify section to CODEBUDDY.md + PreToolUse hook (CodeBuddy)")
|
||||
print(" codebuddy uninstall remove graphify section from CODEBUDDY.md + PreToolUse hook")
|
||||
print(" codex install write graphify section to AGENTS.md (Codex)")
|
||||
print(" codex uninstall remove graphify section from AGENTS.md")
|
||||
print(
|
||||
" opencode install write graphify section to AGENTS.md + tool.execute.before plugin (OpenCode)"
|
||||
)
|
||||
print(
|
||||
" opencode uninstall remove graphify section from AGENTS.md + plugin"
|
||||
)
|
||||
print(
|
||||
" kilo install install native Kilo skill + command + AGENTS.md + .kilo plugin"
|
||||
)
|
||||
print(
|
||||
" kilo uninstall remove native Kilo skill + command + AGENTS.md + .kilo plugin"
|
||||
)
|
||||
print(" aider install write graphify section to AGENTS.md (Aider)")
|
||||
print(" aider uninstall remove graphify section from AGENTS.md")
|
||||
print(
|
||||
" copilot install copy graphify skill to ~/.copilot/skills (GitHub Copilot CLI)"
|
||||
)
|
||||
print(" copilot uninstall remove graphify skill from ~/.copilot/skills")
|
||||
print(
|
||||
" vscode install configure VS Code Copilot Chat (skill + .github/copilot-instructions.md)"
|
||||
)
|
||||
print(" vscode uninstall remove VS Code Copilot Chat configuration")
|
||||
print(
|
||||
" claw install write graphify section to AGENTS.md (OpenClaw)"
|
||||
)
|
||||
print(" claw uninstall remove graphify section from AGENTS.md")
|
||||
print(
|
||||
" droid install write graphify section to AGENTS.md (Factory Droid)"
|
||||
)
|
||||
print(" droid uninstall remove graphify section from AGENTS.md")
|
||||
print(" trae install write graphify section to AGENTS.md (Trae)")
|
||||
print(" trae uninstall remove graphify section from AGENTS.md")
|
||||
print(" trae-cn install write graphify section to AGENTS.md (Trae CN)")
|
||||
print(" trae-cn uninstall remove graphify section from AGENTS.md")
|
||||
print(
|
||||
" antigravity install write .agents/rules + .agents/workflows + skill (Google Antigravity)"
|
||||
)
|
||||
print(
|
||||
" antigravity uninstall remove .agents/rules, .agents/workflows, and skill"
|
||||
)
|
||||
print(
|
||||
" hermes install write skill to ~/.hermes/skills/graphify/ (Hermes)"
|
||||
)
|
||||
print(" hermes uninstall remove skill from ~/.hermes/skills/graphify/")
|
||||
print(
|
||||
" kiro install write skill to .kiro/skills/graphify/ + steering file (Kiro IDE/CLI)"
|
||||
)
|
||||
print(" kiro uninstall remove skill + steering file")
|
||||
print(" pi install write skill to ~/.pi/agent/skills/graphify/ (Pi coding agent)")
|
||||
print(" pi uninstall remove skill from ~/.pi/agent/skills/graphify/")
|
||||
print(" devin install write skill to ~/.config/devin/skills/graphify/ (Devin CLI)")
|
||||
print(" devin uninstall remove skill from ~/.config/devin/skills/graphify/")
|
||||
print()
|
||||
return
|
||||
|
||||
cmd = sys.argv[1]
|
||||
|
||||
# Universal help guard: -h/--help/-? anywhere after the command shows help
|
||||
# and stops — prevents flags from silently triggering destructive subcommands
|
||||
# (e.g. "cursor install --help" was silently installing into Cursor, #821).
|
||||
# Exempt: free-text commands (user string may contain these tokens), and
|
||||
# "install"/"uninstall" which have their own per-subcommand help handlers.
|
||||
_FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"}
|
||||
if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]):
|
||||
print(f"Run 'graphify --help' for full usage.")
|
||||
return
|
||||
|
||||
if dispatch_install_cli(cmd):
|
||||
return
|
||||
dispatch_command(cmd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,107 @@
|
||||
"""MinHash + band-LSH — datasketch-compatible drop-in (no scipy).
|
||||
|
||||
datasketch.lsh has `from scipy.integrate import quad` at module level.
|
||||
scipy's array_api_compat layer then lazily loads numpy.testing, which calls
|
||||
platform.machine() at import time to set test-skip decorator constants — and
|
||||
that in turn spawns cmd.exe via subprocess, hanging for minutes under EDR
|
||||
software in corporate Windows environments.
|
||||
|
||||
Covers the exact MinHash/MinHashLSH API surface used by dedup.py.
|
||||
Hash family (Mersenne-prime permutations) and LSH band structure are
|
||||
equivalent to datasketch so dedup quality is unchanged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import struct
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
_MP = np.uint64((1 << 61) - 1) # Mersenne prime for the hash family
|
||||
_MH = np.uint64(0xFFFF_FFFF) # mask to 32-bit values
|
||||
|
||||
# One (a, b) coefficient array per num_perm, shared across all instances.
|
||||
_MH_COEFFS: dict[int, tuple[np.ndarray, np.ndarray]] = {}
|
||||
|
||||
|
||||
def _mh_coeffs(num_perm: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
if num_perm not in _MH_COEFFS:
|
||||
rng = np.random.RandomState(1)
|
||||
a = rng.randint(1, int(_MP), num_perm, dtype=np.uint64)
|
||||
b = rng.randint(0, int(_MP), num_perm, dtype=np.uint64)
|
||||
_MH_COEFFS[num_perm] = (a, b)
|
||||
return _MH_COEFFS[num_perm]
|
||||
|
||||
|
||||
class MinHash:
|
||||
"""MinHash sketch — same API as datasketch.MinHash for the subset used here."""
|
||||
|
||||
__slots__ = ("num_perm", "hashvalues", "_a", "_b")
|
||||
|
||||
def __init__(self, num_perm: int = 128) -> None:
|
||||
self.num_perm = num_perm
|
||||
self.hashvalues = np.full(num_perm, int(_MH), dtype=np.uint64)
|
||||
self._a, self._b = _mh_coeffs(num_perm)
|
||||
|
||||
def update(self, v: bytes) -> None:
|
||||
hv = np.uint64(struct.unpack("<I", hashlib.sha1(v).digest()[:4])[0])
|
||||
phv = np.bitwise_and((self._a * hv + self._b) % _MP, _MH)
|
||||
self.hashvalues = np.minimum(self.hashvalues, phv)
|
||||
|
||||
|
||||
def _lsh_integrate(f, lo: float, hi: float, n: int = 128) -> float:
|
||||
"""Numerical integration — replaces scipy.integrate.quad for LSH param search."""
|
||||
h = (hi - lo) / n
|
||||
return h * sum(f(lo + i * h) for i in range(n))
|
||||
|
||||
|
||||
_LSH_PARAMS_CACHE: dict[tuple[float, int], tuple[int, int]] = {}
|
||||
|
||||
|
||||
def _optimal_lsh_params(threshold: float, num_perm: int) -> tuple[int, int]:
|
||||
"""Find (bands, rows) that minimise weighted FP+FN error, without scipy."""
|
||||
key = (threshold, num_perm)
|
||||
if key in _LSH_PARAMS_CACHE:
|
||||
return _LSH_PARAMS_CACHE[key]
|
||||
best_err, best = float("inf"), (1, 1)
|
||||
for b in range(1, num_perm + 1):
|
||||
for r in range(1, num_perm // b + 1):
|
||||
fp = _lsh_integrate(
|
||||
lambda s, _b=float(b), _r=float(r): 1 - (1 - s ** _r) ** _b,
|
||||
0.0, threshold,
|
||||
)
|
||||
fn = _lsh_integrate(
|
||||
lambda s, _b=float(b), _r=float(r): 1 - (1 - (1 - s ** _r) ** _b),
|
||||
threshold, 1.0,
|
||||
)
|
||||
err = 0.5 * fp + 0.5 * fn
|
||||
if err < best_err:
|
||||
best_err, best = err, (b, r)
|
||||
_LSH_PARAMS_CACHE[key] = best
|
||||
return best
|
||||
|
||||
|
||||
class MinHashLSH:
|
||||
"""Band-hashing LSH — same API as datasketch.MinHashLSH for the subset used here."""
|
||||
|
||||
def __init__(self, threshold: float = 0.5, num_perm: int = 128) -> None:
|
||||
self.b, self.r = _optimal_lsh_params(threshold, num_perm)
|
||||
self._tables: list[dict[bytes, list[str]]] = [{} for _ in range(self.b)]
|
||||
self._keys: set[str] = set()
|
||||
|
||||
def insert(self, key: str, minhash: MinHash) -> None:
|
||||
if key in self._keys:
|
||||
raise ValueError(f"Key {key!r} already exists in MinHashLSH")
|
||||
self._keys.add(key)
|
||||
hv = minhash.hashvalues
|
||||
for i, table in enumerate(self._tables):
|
||||
band = hv[i * self.r : (i + 1) * self.r].tobytes()
|
||||
table.setdefault(band, []).append(key)
|
||||
|
||||
def query(self, minhash: MinHash) -> list[str]:
|
||||
hv = minhash.hashvalues
|
||||
candidates: set[str] = set()
|
||||
for i, table in enumerate(self._tables):
|
||||
band = hv[i * self.r : (i + 1) * self.r].tobytes()
|
||||
candidates.update(table.get(band, []))
|
||||
return list(candidates)
|
||||
@@ -0,0 +1,253 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
import unicodedata
|
||||
|
||||
import networkx as nx
|
||||
|
||||
|
||||
DEFAULT_AFFECTED_RELATIONS = (
|
||||
"calls",
|
||||
"indirect_call",
|
||||
"references",
|
||||
"imports",
|
||||
"imports_from",
|
||||
"re_exports",
|
||||
"inherits",
|
||||
"extends",
|
||||
"implements",
|
||||
"uses",
|
||||
"mixes_in",
|
||||
"embeds",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AffectedHit:
|
||||
node_id: str
|
||||
depth: int
|
||||
via_relation: str
|
||||
|
||||
|
||||
def _node_label(graph: nx.Graph, node_id: str) -> str:
|
||||
data = graph.nodes[node_id]
|
||||
return str(data.get("label") or node_id)
|
||||
|
||||
|
||||
def _format_location(data: dict) -> str:
|
||||
source_file = data.get("source_file") or "-"
|
||||
source_location = data.get("source_location")
|
||||
if source_location:
|
||||
return f"{source_file}:{source_location}"
|
||||
return str(source_file)
|
||||
|
||||
|
||||
def _bare_name(label: str) -> str:
|
||||
"""Lowercased label with the callable decoration (trailing "()") removed."""
|
||||
label = _normalize_label(label)
|
||||
return label[:-2] if label.endswith("()") else label
|
||||
|
||||
|
||||
def _normalize_label(label: str) -> str:
|
||||
return unicodedata.normalize("NFC", label).casefold()
|
||||
|
||||
|
||||
def _prefer_file_node(
|
||||
graph: nx.Graph,
|
||||
node_ids: list[str],
|
||||
query: str,
|
||||
) -> str | None:
|
||||
"""Return the file-level node when a source_file query matches many nodes."""
|
||||
query_basename = _normalize_label(Path(query).name)
|
||||
exact_file_nodes = [
|
||||
node_id
|
||||
for node_id in node_ids
|
||||
if str(graph.nodes[node_id].get("source_location", "")) == "L1"
|
||||
and _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename
|
||||
]
|
||||
if len(exact_file_nodes) == 1:
|
||||
return exact_file_nodes[0]
|
||||
|
||||
l1_nodes = [
|
||||
node_id
|
||||
for node_id in node_ids
|
||||
if str(graph.nodes[node_id].get("source_location", "")) == "L1"
|
||||
]
|
||||
if len(l1_nodes) == 1:
|
||||
return l1_nodes[0]
|
||||
|
||||
basename_nodes = [
|
||||
node_id
|
||||
for node_id in node_ids
|
||||
if _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename
|
||||
]
|
||||
if len(basename_nodes) == 1:
|
||||
return basename_nodes[0]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_seed(graph: nx.Graph, query: str) -> str | None:
|
||||
# A trailing path separator must not change a source-file match — serve's
|
||||
# _find_node tokenizes the path (which drops it), so strip it here for parity
|
||||
# (otherwise `affected "src/x.ts/"` returned None while `explain` resolved it).
|
||||
query = query.rstrip("/\\") or query
|
||||
if query in graph:
|
||||
return query
|
||||
query_lower = _normalize_label(query)
|
||||
exact_label_matches = [
|
||||
str(node_id)
|
||||
for node_id, data in graph.nodes(data=True)
|
||||
if _normalize_label(str(data.get("label", ""))) == query_lower
|
||||
]
|
||||
if len(exact_label_matches) == 1:
|
||||
return exact_label_matches[0]
|
||||
# Callable labels are decorated ("name()"), so a bare "name" query falls
|
||||
# through exact matching and then ties with any "name*" sibling in the
|
||||
# contains pass. Match on the undecorated name before giving up.
|
||||
query_bare = _bare_name(query_lower)
|
||||
bare_name_matches = [
|
||||
str(node_id)
|
||||
for node_id, data in graph.nodes(data=True)
|
||||
if _bare_name(str(data.get("label", ""))) == query_bare
|
||||
]
|
||||
if len(bare_name_matches) == 1:
|
||||
return bare_name_matches[0]
|
||||
exact_source_matches = [
|
||||
str(node_id)
|
||||
for node_id, data in graph.nodes(data=True)
|
||||
if _normalize_label(str(data.get("source_file", ""))) == query_lower
|
||||
]
|
||||
if len(exact_source_matches) == 1:
|
||||
return exact_source_matches[0]
|
||||
if exact_source_matches:
|
||||
preferred_file_node = _prefer_file_node(graph, exact_source_matches, query)
|
||||
if preferred_file_node is not None:
|
||||
return preferred_file_node
|
||||
contains_matches = [
|
||||
str(node_id)
|
||||
for node_id, data in graph.nodes(data=True)
|
||||
if query_lower in _normalize_label(str(data.get("label", "")))
|
||||
]
|
||||
if len(contains_matches) == 1:
|
||||
return contains_matches[0]
|
||||
return None
|
||||
|
||||
|
||||
def affected_nodes(
|
||||
graph: nx.Graph,
|
||||
seed: str,
|
||||
*,
|
||||
relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
|
||||
depth: int = 2,
|
||||
) -> list[AffectedHit]:
|
||||
relation_set = set(relations)
|
||||
seen = {seed}
|
||||
queue: deque[tuple[str, int]] = deque([(seed, 0)])
|
||||
hits: list[AffectedHit] = []
|
||||
|
||||
# #1669: seed the reverse walk with the root's own member nodes (one outward
|
||||
# `method`/`contains` hop). A caller can bind to a class's method node rather
|
||||
# than the class node itself (e.g. `Service.call` resolves to the `def
|
||||
# self.call` node, #1634), so those callers are unreachable from the class
|
||||
# otherwise. The member nodes are seeds only (not reported as hits), and
|
||||
# `method`/`contains` stay out of the general relation-filtered walk, so this
|
||||
# adds no forward noise anywhere else.
|
||||
if hasattr(graph, "out_edges"):
|
||||
member_edges = graph.out_edges(seed, data=True)
|
||||
else:
|
||||
member_edges = (
|
||||
(s, t, d) for s, t, d in graph.edges(data=True) if s == seed
|
||||
)
|
||||
for _s, member, data in member_edges:
|
||||
if str(data.get("relation", "")) not in ("method", "contains"):
|
||||
continue
|
||||
member = str(member)
|
||||
if member not in seen:
|
||||
seen.add(member)
|
||||
queue.append((member, 0))
|
||||
|
||||
while queue:
|
||||
current, current_depth = queue.popleft()
|
||||
if current_depth >= depth:
|
||||
continue
|
||||
if hasattr(graph, "in_edges"):
|
||||
incoming = graph.in_edges(current, data=True)
|
||||
else:
|
||||
incoming = (
|
||||
(source, target, data)
|
||||
for source, target, data in graph.edges(data=True)
|
||||
if target == current
|
||||
)
|
||||
for source, _target, data in incoming:
|
||||
relation = str(data.get("relation", ""))
|
||||
if relation not in relation_set:
|
||||
continue
|
||||
source = str(source)
|
||||
if source in seen:
|
||||
continue
|
||||
seen.add(source)
|
||||
hit = AffectedHit(source, current_depth + 1, relation)
|
||||
hits.append(hit)
|
||||
queue.append((source, current_depth + 1))
|
||||
|
||||
return hits
|
||||
|
||||
|
||||
def format_affected(
|
||||
graph: nx.Graph,
|
||||
query: str,
|
||||
*,
|
||||
relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
|
||||
depth: int = 2,
|
||||
) -> str:
|
||||
relation_list = tuple(relations)
|
||||
seed = resolve_seed(graph, query)
|
||||
if seed is None:
|
||||
return f"No unique node match for {query}"
|
||||
|
||||
hits = affected_nodes(graph, seed, relations=relation_list, depth=depth)
|
||||
lines = [
|
||||
f"Affected nodes for {_node_label(graph, seed)}",
|
||||
f"Relations: {', '.join(relation_list)}",
|
||||
f"Depth: {depth}",
|
||||
]
|
||||
if not hits:
|
||||
lines.append("No affected nodes found.")
|
||||
return "\n".join(lines)
|
||||
|
||||
for hit in hits:
|
||||
data = graph.nodes[hit.node_id]
|
||||
lines.append(
|
||||
f"- {_node_label(graph, hit.node_id)} [{hit.via_relation}] {_format_location(data)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def load_graph(path: Path) -> nx.Graph:
|
||||
import json
|
||||
from networkx.readwrite import json_graph
|
||||
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Cannot read graph file {path}: {exc}. "
|
||||
"Re-run 'graphify extract' to regenerate it."
|
||||
) from exc
|
||||
# Force directed so stored caller→callee direction survives the round-trip;
|
||||
# mirrors serve.py and __main__.py (#1174).
|
||||
raw = {**raw, "directed": True}
|
||||
# Normalize the edge key: graphify's `extract` output uses "edges" while
|
||||
# networkx's node_link_data default is "links". Without this, an edges-keyed
|
||||
# graph.json raises an uncaught KeyError: 'links' here — every other loader
|
||||
# (__main__.py) already normalizes this (#738; same class as #1198).
|
||||
if "links" not in raw and "edges" in raw:
|
||||
raw = dict(raw, links=raw["edges"])
|
||||
try:
|
||||
return json_graph.node_link_graph(raw, edges="links")
|
||||
except TypeError:
|
||||
return json_graph.node_link_graph(raw)
|
||||
@@ -0,0 +1,12 @@
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
trigger: always_on
|
||||
description: Consult the graphify knowledge graph at graphify-out/ for codebase and architecture questions.
|
||||
---
|
||||
|
||||
## graphify
|
||||
|
||||
This project has a graphify knowledge graph at graphify-out/.
|
||||
|
||||
Rules:
|
||||
- For codebase or architecture questions, when `graphify-out/graph.json` exists, first run `graphify query "<question>"` (CLI) or `query_graph` (MCP). Use `graphify path "<A>" "<B>"` / `shortest_path` for relationships and `graphify explain "<concept>"` / `get_node` for focused concepts. These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, navigate it instead of reading raw files
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context
|
||||
- After modifying code files in this session, run `graphify update .` to keep the graph current (AST-only, no API cost)
|
||||
@@ -0,0 +1,9 @@
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
@@ -0,0 +1,9 @@
|
||||
## graphify
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
inclusion: always
|
||||
---
|
||||
|
||||
graphify: A knowledge graph of this project lives in `graphify-out/`. For codebase, architecture, or dependency questions, when `graphify-out/graph.json` exists, first run `graphify query "<question>"` (or `graphify path "<A>" "<B>"` / `graphify explain "<concept>"`). These return a scoped subgraph, usually much smaller than `GRAPH_REPORT.md` or raw grep output. Read `GRAPH_REPORT.md` only for broad architecture review or when those commands do not surface enough context.
|
||||
@@ -0,0 +1,17 @@
|
||||
## graphify
|
||||
|
||||
For any question about this repo's architecture, structure, components, or how to add/modify/find
|
||||
code, your first action should be `graphify query "<question>"` when `graphify-out/graph.json`
|
||||
exists. Use `graphify path "<A>" "<B>"` for relationship questions and `graphify explain "<concept>"`
|
||||
for focused-concept questions. These return a scoped subgraph, usually much smaller than the full
|
||||
report or raw grep output.
|
||||
|
||||
Triggers: "how do I…", "where is…", "what does … do", "add/modify a <component>",
|
||||
"explain the architecture", or anything that depends on how files or classes relate.
|
||||
|
||||
If `graphify-out/wiki/index.md` exists, use it for broad navigation. Read `graphify-out/GRAPH_REPORT.md`
|
||||
only for broad architecture review or when query/path/explain do not surface enough context. Only read
|
||||
source files when (a) modifying/debugging specific code, (b) the graph lacks the needed detail, or
|
||||
(c) the graph is missing or stale.
|
||||
|
||||
Type `/graphify` in Copilot Chat to build or update the graph.
|
||||
@@ -0,0 +1,740 @@
|
||||
"""Graph analysis: god nodes (most connected), surprising connections (cross-community), suggested questions."""
|
||||
from __future__ import annotations
|
||||
from pathlib import Path
|
||||
import networkx as nx
|
||||
|
||||
from graphify.build import edge_data
|
||||
|
||||
# Builtin/mock names that can appear as annotation-derived nodes in pre-existing
|
||||
# graphs. Excluded from god-node ranking so they don't displace real abstractions
|
||||
# even if they weren't filtered at extraction time (#1147).
|
||||
_BUILTIN_NOISE_LABELS = frozenset({
|
||||
"str", "int", "float", "bool", "bytes", "bytearray", "complex", "object",
|
||||
"True", "False",
|
||||
"MagicMock", "Mock", "AsyncMock", "NonCallableMock",
|
||||
"NonCallableMagicMock", "PropertyMock", "patch", "sentinel",
|
||||
# Python stdlib types commonly confused for project symbols
|
||||
"Path", "Any", "Optional", "List", "Dict", "Set", "Tuple", "Union",
|
||||
"Callable", "Type", "ClassVar", "Final", "Literal", "Protocol",
|
||||
"Counter", "defaultdict", "OrderedDict", "datetime", "Enum",
|
||||
"os", "sys", "re", "json", "io", "abc", "typing",
|
||||
})
|
||||
|
||||
# Language families — extensions sharing a runtime can legitimately call each other
|
||||
_LANG_FAMILY: dict[str, str] = {
|
||||
**{e: "python" for e in (".py", ".pyw")},
|
||||
**{e: "js" for e in (".js", ".jsx", ".mjs", ".ejs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte")},
|
||||
**{e: "go" for e in (".go",)},
|
||||
**{e: "rust" for e in (".rs",)},
|
||||
**{e: "jvm" for e in (".java", ".kt", ".kts", ".scala")},
|
||||
**{e: "c" for e in (".c", ".h", ".cpp", ".cc", ".cxx", ".hpp")},
|
||||
**{e: "ruby" for e in (".rb", ".rake")},
|
||||
**{e: "swift" for e in (".swift",)},
|
||||
**{e: "dotnet" for e in (".cs",)},
|
||||
**{e: "php" for e in (".php",)},
|
||||
**{e: "r" for e in (".r",)},
|
||||
}
|
||||
|
||||
|
||||
def _cross_language(src_a: str, src_b: str) -> bool:
|
||||
"""Return True if two source files belong to different language families."""
|
||||
ext_a = Path(src_a).suffix.lower()
|
||||
ext_b = Path(src_b).suffix.lower()
|
||||
fam_a = _LANG_FAMILY.get(ext_a)
|
||||
fam_b = _LANG_FAMILY.get(ext_b)
|
||||
if fam_a is None or fam_b is None:
|
||||
return False
|
||||
return fam_a != fam_b
|
||||
|
||||
|
||||
def _node_community_map(communities: dict[int, list[str]]) -> dict[str, int]:
|
||||
"""Invert communities dict: node_id -> community_id."""
|
||||
return {n: cid for cid, nodes in communities.items() for n in nodes}
|
||||
|
||||
|
||||
def _is_file_node(G: nx.Graph, node_id: str) -> bool:
|
||||
"""
|
||||
Return True if this node is a file-level hub node (e.g. 'client', 'models')
|
||||
or an AST method stub (e.g. '.auth_flow()', '.__init__()').
|
||||
|
||||
These are synthetic nodes created by the AST extractor and should be excluded
|
||||
from god nodes, surprising connections, and knowledge gap reporting.
|
||||
"""
|
||||
attrs = G.nodes[node_id]
|
||||
label = attrs.get("label", "")
|
||||
if not label:
|
||||
return False
|
||||
# File-level hub: label matches the actual source filename (not just any label ending in .py)
|
||||
source_file = attrs.get("source_file", "")
|
||||
if source_file:
|
||||
from pathlib import Path as _Path
|
||||
if label == _Path(source_file).name:
|
||||
return True
|
||||
# Method stub: AST extractor labels methods as '.method_name()'
|
||||
if label.startswith(".") and label.endswith("()"):
|
||||
return True
|
||||
# Module-level function stub: labeled 'function_name()' - only has a contains edge
|
||||
# These are real functions but structurally isolated by definition; not a gap worth flagging
|
||||
if label.endswith("()") and G.degree(node_id) <= 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_JSON_NOISE_LABELS: frozenset[str] = frozenset({
|
||||
"start", "end", "name", "id", "type", "properties",
|
||||
"value", "key", "data", "items", "title", "description", "version",
|
||||
"dependencies", "devdependencies", "peerdependencies",
|
||||
"optionaldependencies", "bundleddependencies", "bundledependencies",
|
||||
})
|
||||
|
||||
|
||||
def _is_json_key_node(G: nx.Graph, node_id: str) -> bool:
|
||||
attrs = G.nodes[node_id]
|
||||
src = (attrs.get("source_file") or "").lower()
|
||||
if not src.endswith(".json"):
|
||||
return False
|
||||
label = (attrs.get("label") or "").strip().lower()
|
||||
return label in _JSON_NOISE_LABELS
|
||||
|
||||
|
||||
def god_nodes(G: nx.Graph, top_n: int = 10) -> list[dict]:
|
||||
"""Return the top_n most-connected real entities - the core abstractions.
|
||||
|
||||
File-level hub nodes are excluded: they accumulate import/contains edges
|
||||
mechanically and don't represent meaningful architectural abstractions.
|
||||
"""
|
||||
degree = dict(G.degree())
|
||||
sorted_nodes = sorted(degree.items(), key=lambda x: x[1], reverse=True)
|
||||
result = []
|
||||
for node_id, deg in sorted_nodes:
|
||||
if _is_file_node(G, node_id) or _is_concept_node(G, node_id) or _is_json_key_node(G, node_id):
|
||||
continue
|
||||
if G.nodes[node_id].get("label", "") in _BUILTIN_NOISE_LABELS:
|
||||
continue
|
||||
result.append({
|
||||
"id": node_id,
|
||||
"label": G.nodes[node_id].get("label", node_id),
|
||||
"degree": deg,
|
||||
})
|
||||
if len(result) >= top_n:
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
def surprising_connections(
|
||||
G: nx.Graph,
|
||||
communities: dict[int, list[str]] | None = None,
|
||||
top_n: int = 5,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Find connections that are genuinely surprising - not obvious from file structure.
|
||||
|
||||
Strategy:
|
||||
- Multi-file corpora: cross-file edges between real entities (not concept nodes).
|
||||
Sorted AMBIGUOUS → INFERRED → EXTRACTED.
|
||||
- Single-file / single-source corpora: cross-community edges that bridge
|
||||
distant parts of the graph (betweenness centrality on edges).
|
||||
These reveal non-obvious structural couplings.
|
||||
|
||||
Concept nodes (empty source_file, or injected semantic annotations) are excluded
|
||||
from surprising connections because they are intentional, not discovered.
|
||||
"""
|
||||
# Identify unique source files (ignore empty/null source_file)
|
||||
source_files = {
|
||||
data.get("source_file", "")
|
||||
for _, data in G.nodes(data=True)
|
||||
if data.get("source_file", "")
|
||||
}
|
||||
is_multi_source = len(source_files) > 1
|
||||
|
||||
if is_multi_source:
|
||||
return _cross_file_surprises(G, communities or {}, top_n)
|
||||
else:
|
||||
return _cross_community_surprises(G, communities or {}, top_n)
|
||||
|
||||
|
||||
def _is_concept_node(G: nx.Graph, node_id: str) -> bool:
|
||||
"""
|
||||
Return True if this node is a manually-injected semantic concept node
|
||||
rather than a real entity found in source code.
|
||||
|
||||
Signals:
|
||||
- Empty source_file
|
||||
- source_file doesn't look like a real file path (no extension)
|
||||
"""
|
||||
data = G.nodes[node_id]
|
||||
source = data.get("source_file", "")
|
||||
if not source:
|
||||
return True
|
||||
# Has no file extension → probably a concept label, not a real file
|
||||
if "." not in source.split("/")[-1]:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, PAPER_EXTENSIONS, IMAGE_EXTENSIONS
|
||||
|
||||
|
||||
def _file_category(path: str) -> str:
|
||||
ext = ("." + path.rsplit(".", 1)[-1].lower()) if "." in path else ""
|
||||
if ext in CODE_EXTENSIONS:
|
||||
return "code"
|
||||
if ext in PAPER_EXTENSIONS:
|
||||
return "paper"
|
||||
if ext in IMAGE_EXTENSIONS:
|
||||
return "image"
|
||||
return "doc"
|
||||
|
||||
|
||||
def _top_level_dir(path: str) -> str:
|
||||
"""Return the first path component - used to detect cross-repo edges."""
|
||||
return path.split("/")[0] if "/" in path else path
|
||||
|
||||
|
||||
def _surprise_score(
|
||||
G: nx.Graph,
|
||||
u: str,
|
||||
v: str,
|
||||
data: dict,
|
||||
node_community: dict[str, int],
|
||||
u_source: str,
|
||||
v_source: str,
|
||||
degrees: dict[str, int] | None = None,
|
||||
) -> tuple[int, list[str]]:
|
||||
"""Score how surprising a cross-file edge is. Returns (score, reasons)."""
|
||||
score = 0
|
||||
reasons: list[str] = []
|
||||
|
||||
# 1. Confidence weight - uncertain connections are more noteworthy
|
||||
conf = data.get("confidence", "EXTRACTED")
|
||||
relation = data.get("relation", "")
|
||||
conf_bonus = {"AMBIGUOUS": 3, "INFERRED": 2, "EXTRACTED": 1}.get(conf, 1)
|
||||
|
||||
cat_u = _file_category(u_source)
|
||||
cat_v = _file_category(v_source)
|
||||
|
||||
# Suppress all structural bonuses for INFERRED calls/uses that cross language
|
||||
# boundaries or connect code to a doc file. Both cases are resolver pollution:
|
||||
# label-matching fires across language families in monorepos, and code→doc
|
||||
# "calls" edges are extraction artefacts, not real architecture.
|
||||
# Excludes `semantically_similar_to` (genuine cross-boundary insight) and all
|
||||
# AMBIGUOUS/EXTRACTED edges (not from the resolver path).
|
||||
_suppress_structural = (
|
||||
conf == "INFERRED"
|
||||
and relation in ("calls", "uses")
|
||||
and (_cross_language(u_source, v_source) or {cat_u, cat_v} == {"code", "doc"})
|
||||
)
|
||||
if _suppress_structural:
|
||||
conf_bonus = 0
|
||||
|
||||
score += conf_bonus
|
||||
if conf in ("AMBIGUOUS", "INFERRED"):
|
||||
reasons.append(f"{conf.lower()} connection - not explicitly stated in source")
|
||||
|
||||
# 2. Cross file-type bonus - code↔paper or code↔image is non-obvious
|
||||
if cat_u != cat_v and not _suppress_structural:
|
||||
score += 2
|
||||
reasons.append(f"crosses file types ({cat_u} ↔ {cat_v})")
|
||||
|
||||
# 3. Cross-repo bonus - different top-level directory
|
||||
if _top_level_dir(u_source) != _top_level_dir(v_source) and not _suppress_structural:
|
||||
score += 2
|
||||
reasons.append("connects across different repos/directories")
|
||||
|
||||
# 4. Cross-community bonus - Leiden says these are structurally distant
|
||||
cid_u = node_community.get(u)
|
||||
cid_v = node_community.get(v)
|
||||
if cid_u is not None and cid_v is not None and cid_u != cid_v and not _suppress_structural:
|
||||
score += 1
|
||||
reasons.append("bridges separate communities")
|
||||
|
||||
# 4b. Semantic similarity bonus - non-obvious conceptual links score higher
|
||||
if data.get("relation") == "semantically_similar_to":
|
||||
score = int(score * 1.5)
|
||||
reasons.append("semantically similar concepts with no structural link")
|
||||
|
||||
# 5. Peripheral→hub: a low-degree node connecting to a high-degree one
|
||||
deg_u = degrees[u] if degrees is not None else G.degree(u)
|
||||
deg_v = degrees[v] if degrees is not None else G.degree(v)
|
||||
if min(deg_u, deg_v) <= 2 and max(deg_u, deg_v) >= 5:
|
||||
score += 1
|
||||
peripheral = G.nodes[u].get("label", u) if deg_u <= 2 else G.nodes[v].get("label", v)
|
||||
hub = G.nodes[v].get("label", v) if deg_u <= 2 else G.nodes[u].get("label", u)
|
||||
reasons.append(f"peripheral node `{peripheral}` unexpectedly reaches hub `{hub}`")
|
||||
|
||||
return score, reasons
|
||||
|
||||
|
||||
def _cross_file_surprises(G: nx.Graph, communities: dict[int, list[str]], top_n: int) -> list[dict]:
|
||||
"""
|
||||
Cross-file edges between real code/doc entities, ranked by a composite
|
||||
surprise score rather than confidence alone.
|
||||
|
||||
Surprise score accounts for:
|
||||
- Confidence (AMBIGUOUS > INFERRED > EXTRACTED)
|
||||
- Cross file-type (code↔paper is more surprising than code↔code)
|
||||
- Cross-repo (different top-level directory)
|
||||
- Cross-community (Leiden says structurally distant)
|
||||
- Peripheral→hub (low-degree node reaching a god node)
|
||||
|
||||
Each result includes a 'why' field explaining what makes it non-obvious.
|
||||
"""
|
||||
node_community = _node_community_map(communities)
|
||||
degrees = dict(G.degree())
|
||||
candidates = []
|
||||
|
||||
for u, v, data in G.edges(data=True):
|
||||
relation = data.get("relation", "")
|
||||
if relation in ("imports", "imports_from", "contains", "method"):
|
||||
continue
|
||||
if _is_concept_node(G, u) or _is_concept_node(G, v):
|
||||
continue
|
||||
if _is_file_node(G, u) or _is_file_node(G, v):
|
||||
continue
|
||||
|
||||
u_source = G.nodes[u].get("source_file", "")
|
||||
v_source = G.nodes[v].get("source_file", "")
|
||||
|
||||
if not u_source or not v_source or u_source == v_source:
|
||||
continue
|
||||
|
||||
score, reasons = _surprise_score(G, u, v, data, node_community, u_source, v_source, degrees)
|
||||
src_id = data.get("_src", u)
|
||||
if src_id not in G.nodes:
|
||||
src_id = u
|
||||
tgt_id = data.get("_tgt", v)
|
||||
if tgt_id not in G.nodes:
|
||||
tgt_id = v
|
||||
candidates.append({
|
||||
"_score": score,
|
||||
"source": G.nodes[src_id].get("label", src_id),
|
||||
"target": G.nodes[tgt_id].get("label", tgt_id),
|
||||
"source_files": [
|
||||
G.nodes[src_id].get("source_file", ""),
|
||||
G.nodes[tgt_id].get("source_file", ""),
|
||||
],
|
||||
"confidence": data.get("confidence", "EXTRACTED"),
|
||||
"relation": relation,
|
||||
"why": "; ".join(reasons) if reasons else "cross-file semantic connection",
|
||||
})
|
||||
|
||||
candidates.sort(key=lambda x: x["_score"], reverse=True)
|
||||
for c in candidates:
|
||||
c.pop("_score")
|
||||
|
||||
if candidates:
|
||||
return candidates[:top_n]
|
||||
|
||||
return _cross_community_surprises(G, communities, top_n)
|
||||
|
||||
|
||||
def _cross_community_surprises(
|
||||
G: nx.Graph,
|
||||
communities: dict[int, list[str]],
|
||||
top_n: int,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
For single-source corpora: find edges that bridge different communities.
|
||||
These are surprising because Leiden grouped everything else tightly -
|
||||
these edges cut across the natural structure.
|
||||
|
||||
Falls back to high-betweenness edges if no community info is provided.
|
||||
"""
|
||||
if not communities:
|
||||
# No community info - use edge betweenness centrality
|
||||
if G.number_of_edges() == 0:
|
||||
return []
|
||||
if G.number_of_nodes() > 5000:
|
||||
return []
|
||||
betweenness = nx.edge_betweenness_centrality(G)
|
||||
top_edges = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:top_n]
|
||||
result = []
|
||||
for (u, v), score in top_edges:
|
||||
data = edge_data(G, u, v)
|
||||
result.append({
|
||||
"source": G.nodes[u].get("label", u),
|
||||
"target": G.nodes[v].get("label", v),
|
||||
"source_files": [
|
||||
G.nodes[u].get("source_file", ""),
|
||||
G.nodes[v].get("source_file", ""),
|
||||
],
|
||||
"confidence": data.get("confidence", "EXTRACTED"),
|
||||
"relation": data.get("relation", ""),
|
||||
"note": f"Bridges graph structure (betweenness={score:.3f})",
|
||||
})
|
||||
return result
|
||||
|
||||
# Build node → community map
|
||||
node_community = _node_community_map(communities)
|
||||
|
||||
surprises = []
|
||||
for u, v, data in G.edges(data=True):
|
||||
cid_u = node_community.get(u)
|
||||
cid_v = node_community.get(v)
|
||||
if cid_u is None or cid_v is None or cid_u == cid_v:
|
||||
continue
|
||||
# Skip file hub nodes and plain structural edges
|
||||
if _is_file_node(G, u) or _is_file_node(G, v):
|
||||
continue
|
||||
relation = data.get("relation", "")
|
||||
if relation in ("imports", "imports_from", "contains", "method"):
|
||||
continue
|
||||
# This edge crosses community boundaries - interesting
|
||||
confidence = data.get("confidence", "EXTRACTED")
|
||||
src_id = data.get("_src", u)
|
||||
if src_id not in G.nodes:
|
||||
src_id = u
|
||||
tgt_id = data.get("_tgt", v)
|
||||
if tgt_id not in G.nodes:
|
||||
tgt_id = v
|
||||
surprises.append({
|
||||
"source": G.nodes[src_id].get("label", src_id),
|
||||
"target": G.nodes[tgt_id].get("label", tgt_id),
|
||||
"source_files": [
|
||||
G.nodes[src_id].get("source_file", ""),
|
||||
G.nodes[tgt_id].get("source_file", ""),
|
||||
],
|
||||
"confidence": confidence,
|
||||
"relation": relation,
|
||||
"note": f"Bridges community {cid_u} → community {cid_v}",
|
||||
"_pair": tuple(sorted([cid_u, cid_v])),
|
||||
})
|
||||
|
||||
# Sort: AMBIGUOUS first, then INFERRED, then EXTRACTED
|
||||
order = {"AMBIGUOUS": 0, "INFERRED": 1, "EXTRACTED": 2}
|
||||
surprises.sort(key=lambda x: order.get(x["confidence"], 3))
|
||||
|
||||
# Deduplicate by community pair - one representative edge per (A→B) boundary.
|
||||
# Without this, a single high-betweenness god node dominates all results.
|
||||
seen_pairs: set[tuple] = set()
|
||||
deduped = []
|
||||
for s in surprises:
|
||||
pair = s.pop("_pair")
|
||||
if pair not in seen_pairs:
|
||||
seen_pairs.add(pair)
|
||||
deduped.append(s)
|
||||
return deduped[:top_n]
|
||||
|
||||
|
||||
def suggest_questions(
|
||||
G: nx.Graph,
|
||||
communities: dict[int, list[str]],
|
||||
community_labels: dict[int, str],
|
||||
top_n: int = 7,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Generate questions the graph is uniquely positioned to answer.
|
||||
Based on: AMBIGUOUS edges, bridge nodes, underexplored god nodes, isolated nodes.
|
||||
Each question has a 'type', 'question', and 'why' field.
|
||||
"""
|
||||
if community_labels:
|
||||
community_labels = {int(k) if isinstance(k, str) else k: v for k, v in community_labels.items()}
|
||||
|
||||
questions = []
|
||||
node_community = _node_community_map(communities)
|
||||
|
||||
# 1. AMBIGUOUS edges → unresolved relationship questions
|
||||
for u, v, data in G.edges(data=True):
|
||||
if data.get("confidence") == "AMBIGUOUS":
|
||||
ul = G.nodes[u].get("label", u)
|
||||
vl = G.nodes[v].get("label", v)
|
||||
relation = data.get("relation", "related to")
|
||||
questions.append({
|
||||
"type": "ambiguous_edge",
|
||||
"question": f"What is the exact relationship between `{ul}` and `{vl}`?",
|
||||
"why": f"Edge tagged AMBIGUOUS (relation: {relation}) - confidence is low.",
|
||||
})
|
||||
|
||||
# 2. Bridge nodes (high betweenness) → cross-cutting concern questions
|
||||
if G.number_of_edges() > 0:
|
||||
k = min(100, G.number_of_nodes()) if G.number_of_nodes() > 1000 else None
|
||||
betweenness = nx.betweenness_centrality(G, k=k, seed=42)
|
||||
# Top bridge nodes that are NOT file-level hubs
|
||||
bridges = sorted(
|
||||
[(n, s) for n, s in betweenness.items()
|
||||
if not _is_file_node(G, n) and not _is_concept_node(G, n) and s > 0],
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)[:3]
|
||||
for node_id, score in bridges:
|
||||
label = G.nodes[node_id].get("label", node_id)
|
||||
cid = node_community.get(node_id)
|
||||
comm_label = community_labels.get(cid, f"Community {cid}") if cid is not None else "unknown"
|
||||
neighbors = list(G.neighbors(node_id))
|
||||
neighbor_comms = {node_community.get(n) for n in neighbors if node_community.get(n) != cid}
|
||||
if neighbor_comms:
|
||||
other_labels = [community_labels.get(c, f"Community {c}") for c in neighbor_comms]
|
||||
questions.append({
|
||||
"type": "bridge_node",
|
||||
"question": f"Why does `{label}` connect `{comm_label}` to {', '.join(f'`{l}`' for l in other_labels)}?",
|
||||
"why": f"High betweenness centrality ({score:.3f}) - this node is a cross-community bridge.",
|
||||
})
|
||||
|
||||
# 3. God nodes with many INFERRED edges → verification questions
|
||||
degree = dict(G.degree())
|
||||
top_nodes = sorted(
|
||||
[(n, d) for n, d in degree.items() if not _is_file_node(G, n)],
|
||||
key=lambda x: x[1],
|
||||
reverse=True,
|
||||
)[:5]
|
||||
for node_id, _ in top_nodes:
|
||||
inferred = [
|
||||
(u, v, d) for u, v, d in G.edges(node_id, data=True)
|
||||
if d.get("confidence") == "INFERRED"
|
||||
]
|
||||
if len(inferred) >= 2:
|
||||
label = G.nodes[node_id].get("label", node_id)
|
||||
# Use _src/_tgt to get the correct direction; fall back to v (the other node)
|
||||
others = []
|
||||
for u, v, d in inferred[:2]:
|
||||
src_id = d.get("_src", u)
|
||||
if src_id not in G.nodes:
|
||||
src_id = u
|
||||
tgt_id = d.get("_tgt", v)
|
||||
if tgt_id not in G.nodes:
|
||||
tgt_id = v
|
||||
other_id = tgt_id if src_id == node_id else src_id
|
||||
others.append(G.nodes[other_id].get("label", other_id))
|
||||
questions.append({
|
||||
"type": "verify_inferred",
|
||||
"question": f"Are the {len(inferred)} inferred relationships involving `{label}` (e.g. with `{others[0]}` and `{others[1]}`) actually correct?",
|
||||
"why": f"`{label}` has {len(inferred)} INFERRED edges - model-reasoned connections that need verification.",
|
||||
})
|
||||
|
||||
# 4. Isolated or weakly-connected nodes → exploration questions
|
||||
isolated = [
|
||||
n for n in G.nodes()
|
||||
if G.degree(n) <= 1
|
||||
and not _is_file_node(G, n)
|
||||
and not _is_concept_node(G, n)
|
||||
and G.nodes[n].get("file_type") != "rationale"
|
||||
]
|
||||
if isolated:
|
||||
labels = [G.nodes[n].get("label", n) for n in isolated[:3]]
|
||||
questions.append({
|
||||
"type": "isolated_nodes",
|
||||
"question": f"What connects {', '.join(f'`{l}`' for l in labels)} to the rest of the system?",
|
||||
"why": f"{len(isolated)} weakly-connected nodes found - possible documentation gaps or missing edges.",
|
||||
})
|
||||
|
||||
# 5. Low-cohesion communities → structural questions
|
||||
from .cluster import cohesion_score
|
||||
for cid, nodes in communities.items():
|
||||
score = cohesion_score(G, nodes)
|
||||
if score < 0.15 and len(nodes) >= 5:
|
||||
label = community_labels.get(cid, f"Community {cid}")
|
||||
questions.append({
|
||||
"type": "low_cohesion",
|
||||
"question": f"Should `{label}` be split into smaller, more focused modules?",
|
||||
"why": f"Cohesion score {score} - nodes in this community are weakly interconnected.",
|
||||
})
|
||||
|
||||
if not questions:
|
||||
return [{
|
||||
"type": "no_signal",
|
||||
"question": None,
|
||||
"why": (
|
||||
"Not enough signal to generate questions. "
|
||||
"This usually means the corpus has no AMBIGUOUS edges, no bridge nodes, "
|
||||
"no INFERRED relationships, and all communities are tightly cohesive. "
|
||||
"Add more files or run with --mode deep to extract richer edges."
|
||||
),
|
||||
}]
|
||||
|
||||
return questions[:top_n]
|
||||
|
||||
|
||||
def graph_diff(G_old: nx.Graph, G_new: nx.Graph) -> dict:
|
||||
"""Compare two graph snapshots and return what changed.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"new_nodes": [{"id": ..., "label": ...}],
|
||||
"removed_nodes": [{"id": ..., "label": ...}],
|
||||
"new_edges": [{"source": ..., "target": ..., "relation": ..., "confidence": ...}],
|
||||
"removed_edges": [...],
|
||||
"summary": "3 new nodes, 5 new edges, 1 node removed"
|
||||
}
|
||||
"""
|
||||
old_nodes = set(G_old.nodes())
|
||||
new_nodes = set(G_new.nodes())
|
||||
|
||||
added_node_ids = new_nodes - old_nodes
|
||||
removed_node_ids = old_nodes - new_nodes
|
||||
|
||||
new_nodes_list = [
|
||||
{"id": n, "label": G_new.nodes[n].get("label", n)}
|
||||
for n in added_node_ids
|
||||
]
|
||||
removed_nodes_list = [
|
||||
{"id": n, "label": G_old.nodes[n].get("label", n)}
|
||||
for n in removed_node_ids
|
||||
]
|
||||
|
||||
def edge_key(G: nx.Graph, u: str, v: str, data: dict) -> tuple:
|
||||
if G.is_directed():
|
||||
return (u, v, data.get("relation", ""))
|
||||
return (min(u, v), max(u, v), data.get("relation", ""))
|
||||
|
||||
old_edge_keys = {
|
||||
edge_key(G_old, u, v, d)
|
||||
for u, v, d in G_old.edges(data=True)
|
||||
}
|
||||
new_edge_keys = {
|
||||
edge_key(G_new, u, v, d)
|
||||
for u, v, d in G_new.edges(data=True)
|
||||
}
|
||||
|
||||
added_edge_keys = new_edge_keys - old_edge_keys
|
||||
removed_edge_keys = old_edge_keys - new_edge_keys
|
||||
|
||||
new_edges_list = []
|
||||
for u, v, d in G_new.edges(data=True):
|
||||
if edge_key(G_new, u, v, d) in added_edge_keys:
|
||||
new_edges_list.append({
|
||||
"source": u,
|
||||
"target": v,
|
||||
"relation": d.get("relation", ""),
|
||||
"confidence": d.get("confidence", ""),
|
||||
})
|
||||
|
||||
removed_edges_list = []
|
||||
for u, v, d in G_old.edges(data=True):
|
||||
if edge_key(G_old, u, v, d) in removed_edge_keys:
|
||||
removed_edges_list.append({
|
||||
"source": u,
|
||||
"target": v,
|
||||
"relation": d.get("relation", ""),
|
||||
"confidence": d.get("confidence", ""),
|
||||
})
|
||||
|
||||
parts = []
|
||||
if new_nodes_list:
|
||||
parts.append(f"{len(new_nodes_list)} new node{'s' if len(new_nodes_list) != 1 else ''}")
|
||||
if new_edges_list:
|
||||
parts.append(f"{len(new_edges_list)} new edge{'s' if len(new_edges_list) != 1 else ''}")
|
||||
if removed_nodes_list:
|
||||
parts.append(f"{len(removed_nodes_list)} node{'s' if len(removed_nodes_list) != 1 else ''} removed")
|
||||
if removed_edges_list:
|
||||
parts.append(f"{len(removed_edges_list)} edge{'s' if len(removed_edges_list) != 1 else ''} removed")
|
||||
summary = ", ".join(parts) if parts else "no changes"
|
||||
|
||||
return {
|
||||
"new_nodes": new_nodes_list,
|
||||
"removed_nodes": removed_nodes_list,
|
||||
"new_edges": new_edges_list,
|
||||
"removed_edges": removed_edges_list,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
|
||||
def find_import_cycles(
|
||||
G: nx.Graph,
|
||||
max_cycle_length: int = 5,
|
||||
top_n: int = 20,
|
||||
) -> list[dict]:
|
||||
"""Detect circular import dependencies at the file level.
|
||||
|
||||
Collapses symbol-level nodes to their parent file (using source_file attr
|
||||
or 'contains' edges), builds a directed file-level graph from imports_from
|
||||
edges, then finds simple cycles.
|
||||
|
||||
Args:
|
||||
G: The full knowledge graph (may be undirected or directed).
|
||||
max_cycle_length: Only report cycles with at most this many files.
|
||||
top_n: Maximum number of cycles to return (shortest first).
|
||||
|
||||
Returns:
|
||||
List of cycle records with stable structure:
|
||||
{
|
||||
"cycle": ["a.ts", "b.ts"],
|
||||
"length": 2,
|
||||
"why": "circular dependency"
|
||||
}
|
||||
"""
|
||||
def _endpoint_source_file(node_id: str) -> str:
|
||||
attrs = G.nodes.get(node_id, {})
|
||||
src_file = attrs.get("source_file", "")
|
||||
return src_file if isinstance(src_file, str) else ""
|
||||
|
||||
# Step 1: Build a directed file-level graph from import/re-export edges.
|
||||
# IMPORTANT: resolve endpoints using source_file only; never infer from label/id.
|
||||
file_graph = nx.DiGraph()
|
||||
|
||||
for u, v, data in G.edges(data=True):
|
||||
rel = data.get("relation", "")
|
||||
if rel not in ("imports_from", "re_exports"):
|
||||
continue
|
||||
|
||||
# Deferred `import(...)` edges are real dependencies but do not form a
|
||||
# hard file-level cycle, so they are excluded from cycle detection (#1241).
|
||||
if data.get("deferred"):
|
||||
continue
|
||||
|
||||
src_file_attr = data.get("source_file", "")
|
||||
if not isinstance(src_file_attr, str) or not src_file_attr:
|
||||
continue
|
||||
|
||||
u_file = _endpoint_source_file(u)
|
||||
v_file = _endpoint_source_file(v)
|
||||
|
||||
# Works for both DiGraph and Graph inputs:
|
||||
# orient edge from edge.source_file endpoint to the opposite endpoint.
|
||||
if u_file == src_file_attr:
|
||||
tgt_file = v_file
|
||||
elif v_file == src_file_attr:
|
||||
tgt_file = u_file
|
||||
else:
|
||||
# Fallback: if source endpoint cannot be matched exactly,
|
||||
# still treat edge.source_file as source and pick the opposite endpoint
|
||||
# only if one endpoint has a real source_file.
|
||||
tgt_file = v_file if v_file and v_file != src_file_attr else u_file
|
||||
|
||||
if not tgt_file:
|
||||
continue
|
||||
|
||||
file_graph.add_edge(src_file_attr, tgt_file)
|
||||
|
||||
if not file_graph.edges():
|
||||
return []
|
||||
|
||||
# Step 2: Find simple cycles, bounded by length.
|
||||
# Pass length_bound so networkx prunes during enumeration rather than
|
||||
# enumerating all elementary cycles and post-filtering — avoids exponential
|
||||
# blowup on dense graphs with many long cycles (#1196).
|
||||
cycles: list[list[str]] = []
|
||||
for cycle in nx.simple_cycles(file_graph, length_bound=max_cycle_length):
|
||||
if len(cycle) <= max_cycle_length:
|
||||
cycles.append(cycle)
|
||||
if len(cycles) >= top_n * 10:
|
||||
# Stop early to avoid combinatorial explosion
|
||||
break
|
||||
|
||||
# Step 3: Sort by length (shortest = tightest coupling), then deduplicate.
|
||||
cycles.sort(key=len)
|
||||
|
||||
# Deduplicate rotations: normalize each cycle by starting from the
|
||||
# lexicographically smallest element.
|
||||
seen: set[tuple[str, ...]] = set()
|
||||
unique_cycles: list[list[str]] = []
|
||||
for cycle in cycles:
|
||||
core = list(cycle)
|
||||
if not core:
|
||||
continue
|
||||
min_idx = core.index(min(core))
|
||||
normalized = tuple(core[min_idx:] + core[:min_idx])
|
||||
if normalized not in seen:
|
||||
seen.add(normalized)
|
||||
unique_cycles.append(list(normalized))
|
||||
if len(unique_cycles) >= top_n:
|
||||
break
|
||||
|
||||
result: list[dict] = []
|
||||
for cycle in unique_cycles:
|
||||
result.append({
|
||||
"cycle": cycle,
|
||||
"length": len(cycle),
|
||||
"why": "circular dependency",
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Token-reduction benchmark - measures how much context graphify saves vs naive full-corpus approach."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
|
||||
from graphify.build import edge_data
|
||||
from graphify.serve import _query_terms
|
||||
from graphify.paths import default_graph_json as _default_graph_json
|
||||
|
||||
|
||||
_CHARS_PER_TOKEN = 4 # standard approximation
|
||||
|
||||
|
||||
def _safe(unicode_char: str, ascii_fallback: str) -> str:
|
||||
"""Return unicode_char if stdout can encode it, else ascii_fallback.
|
||||
|
||||
Windows consoles often default to cp1252 which cannot encode box-drawing
|
||||
or arrow glyphs; printing them raises UnicodeEncodeError mid-output.
|
||||
"""
|
||||
encoding = getattr(sys.stdout, "encoding", None) or ""
|
||||
try:
|
||||
unicode_char.encode(encoding)
|
||||
return unicode_char
|
||||
except (UnicodeEncodeError, LookupError):
|
||||
return ascii_fallback
|
||||
|
||||
|
||||
def _hr(width: int = 50) -> str:
|
||||
"""Horizontal rule that survives non-UTF-8 stdout (e.g. Windows cp1252 console)."""
|
||||
return _safe("─", "-") * width
|
||||
|
||||
|
||||
def _estimate_tokens(text: str) -> int:
|
||||
return max(1, len(text) // _CHARS_PER_TOKEN)
|
||||
|
||||
|
||||
def _query_subgraph_tokens(G: nx.Graph, question: str, depth: int = 3) -> int:
|
||||
"""Run BFS from best-matching nodes and return estimated tokens in the subgraph context."""
|
||||
terms = _query_terms(question)
|
||||
scored = []
|
||||
for nid, data in G.nodes(data=True):
|
||||
label = data.get("label", "").lower()
|
||||
score = sum(1 for t in terms if t in label)
|
||||
if score > 0:
|
||||
scored.append((score, nid))
|
||||
scored.sort(reverse=True)
|
||||
start_nodes = [nid for _, nid in scored[:3]]
|
||||
if not start_nodes:
|
||||
return 0
|
||||
|
||||
visited: set[str] = set(start_nodes)
|
||||
frontier = set(start_nodes)
|
||||
edges_seen: list[tuple] = []
|
||||
for _ in range(depth):
|
||||
next_frontier: set[str] = set()
|
||||
for n in frontier:
|
||||
for neighbor in G.neighbors(n):
|
||||
if neighbor not in visited:
|
||||
next_frontier.add(neighbor)
|
||||
edges_seen.append((n, neighbor))
|
||||
visited.update(next_frontier)
|
||||
frontier = next_frontier
|
||||
|
||||
lines = []
|
||||
for nid in visited:
|
||||
d = G.nodes[nid]
|
||||
lines.append(f"NODE {d.get('label', nid)} src={d.get('source_file', '')} loc={d.get('source_location', '')}")
|
||||
for u, v in edges_seen:
|
||||
if u in visited and v in visited:
|
||||
d = edge_data(G, u, v)
|
||||
lines.append(f"EDGE {G.nodes[u].get('label', u)} --{d.get('relation', '')}--> {G.nodes[v].get('label', v)}")
|
||||
|
||||
return _estimate_tokens("\n".join(lines))
|
||||
|
||||
|
||||
_SAMPLE_QUESTIONS = [
|
||||
"how does authentication work",
|
||||
"what is the main entry point",
|
||||
"how are errors handled",
|
||||
"what connects the data layer to the api",
|
||||
"what are the core abstractions",
|
||||
]
|
||||
|
||||
|
||||
def run_benchmark(
|
||||
graph_path: str | None = None,
|
||||
corpus_words: int | None = None,
|
||||
questions: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Measure token reduction: corpus tokens vs graphify query tokens.
|
||||
|
||||
Args:
|
||||
graph_path: path to the built graph
|
||||
corpus_words: total word count from detect() output; if None, estimated from graph
|
||||
questions: list of questions to benchmark; defaults to _SAMPLE_QUESTIONS
|
||||
|
||||
Returns dict with: corpus_tokens, avg_query_tokens, reduction_ratio, per_question
|
||||
"""
|
||||
graph_path = graph_path or _default_graph_json()
|
||||
from graphify.security import check_graph_file_size_cap
|
||||
check_graph_file_size_cap(Path(graph_path))
|
||||
data = json.loads(Path(graph_path).read_text(encoding="utf-8"))
|
||||
try:
|
||||
G = json_graph.node_link_graph(data, edges="links")
|
||||
except TypeError:
|
||||
G = json_graph.node_link_graph(data)
|
||||
|
||||
if corpus_words is None:
|
||||
# Rough estimate: each node label is ~3 words, plus source context
|
||||
corpus_words = G.number_of_nodes() * 50
|
||||
|
||||
corpus_tokens = corpus_words * 100 // 75 # words → tokens (100 words ≈ 133 tokens)
|
||||
|
||||
qs = questions or _SAMPLE_QUESTIONS
|
||||
per_question = []
|
||||
for q in qs:
|
||||
qt = _query_subgraph_tokens(G, q)
|
||||
if qt > 0:
|
||||
per_question.append({"question": q, "query_tokens": qt, "reduction": round(corpus_tokens / qt, 1)})
|
||||
|
||||
if not per_question:
|
||||
return {"error": "No matching nodes found for sample questions. Build the graph first."}
|
||||
|
||||
avg_query_tokens = sum(p["query_tokens"] for p in per_question) // len(per_question)
|
||||
reduction_ratio = round(corpus_tokens / avg_query_tokens, 1) if avg_query_tokens > 0 else 0
|
||||
|
||||
return {
|
||||
"corpus_tokens": corpus_tokens,
|
||||
"corpus_words": corpus_words,
|
||||
"nodes": G.number_of_nodes(),
|
||||
"edges": G.number_of_edges(),
|
||||
"avg_query_tokens": avg_query_tokens,
|
||||
"reduction_ratio": reduction_ratio,
|
||||
"per_question": per_question,
|
||||
}
|
||||
|
||||
|
||||
def print_benchmark(result: dict) -> None:
|
||||
"""Print a human-readable benchmark report."""
|
||||
if "error" in result:
|
||||
print(f"Benchmark error: {result['error']}")
|
||||
return
|
||||
|
||||
print(f"\ngraphify token reduction benchmark")
|
||||
print(_hr(50))
|
||||
arrow = _safe("→", "->")
|
||||
print(f" Corpus: {result['corpus_words']:,} words {arrow} ~{result['corpus_tokens']:,} tokens (naive)")
|
||||
print(f" Graph: {result['nodes']:,} nodes, {result['edges']:,} edges")
|
||||
print(f" Avg query cost: ~{result['avg_query_tokens']:,} tokens")
|
||||
print(f" Reduction: {result['reduction_ratio']}x fewer tokens per query")
|
||||
print(f"\n Per question:")
|
||||
for p in result["per_question"]:
|
||||
print(f" [{p['reduction']}x] {p['question'][:55]}")
|
||||
print()
|
||||
+1097
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,590 @@
|
||||
# per-file extraction cache - skip unchanged files on re-run
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
# Output directory name — override with GRAPHIFY_OUT env var for worktrees or
|
||||
# shared-output setups. Accepts a relative name ("graphify-out-feature") or an
|
||||
# absolute path ("/shared/graphify-out"). Single source of truth in graphify.paths
|
||||
# (#1423); re-exported here as _GRAPHIFY_OUT for the existing call sites.
|
||||
from graphify.paths import GRAPHIFY_OUT as _GRAPHIFY_OUT
|
||||
|
||||
# AST cache entries are the output of graphify's own extractor code, so they
|
||||
# are only valid for the version that wrote them: keying purely on file
|
||||
# content means extractor fixes shipped in a new release keep serving stale
|
||||
# pre-fix results. The AST cache is therefore namespaced by package version
|
||||
# (cache/ast/v{version}/), with entries from other versions removed on first
|
||||
# use. The semantic cache is deliberately NOT versioned — its entries are
|
||||
# produced by the LLM from file contents, and invalidating them on every
|
||||
# release would re-bill extraction for unchanged files.
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version
|
||||
|
||||
_EXTRACTOR_VERSION = _pkg_version("graphifyy")
|
||||
except Exception:
|
||||
_EXTRACTOR_VERSION = "unknown"
|
||||
|
||||
# Version dirs already swept this process — cleanup runs once per (base, version).
|
||||
_cleaned_ast_dirs: set[str] = set()
|
||||
|
||||
|
||||
def _cleanup_stale_ast_entries(ast_base: Path, current_dir: Path) -> None:
|
||||
"""Remove AST cache entries left behind by other graphify versions.
|
||||
|
||||
Sweeps sibling ``v*/`` directories and unversioned ``*.json`` entries
|
||||
(the pre-versioning layout) under ``cache/ast/``. Best-effort: failures
|
||||
are ignored, stragglers are retried on the next run.
|
||||
"""
|
||||
key = str(current_dir)
|
||||
if key in _cleaned_ast_dirs:
|
||||
return
|
||||
_cleaned_ast_dirs.add(key)
|
||||
if not ast_base.is_dir():
|
||||
return
|
||||
import shutil
|
||||
|
||||
for child in ast_base.iterdir():
|
||||
if child == current_dir:
|
||||
continue
|
||||
try:
|
||||
if child.is_dir() and child.name.startswith("v"):
|
||||
shutil.rmtree(child, ignore_errors=True)
|
||||
elif child.suffix == ".json":
|
||||
child.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# A frontmatter delimiter is a whole line of exactly three dashes (optional
|
||||
# trailing whitespace). Substring checks like startswith("---") /
|
||||
# find("\n---") also match `----` thematic breaks and `--- text` prose,
|
||||
# silently dropping everything above them from the hash (#1259).
|
||||
_FRONTMATTER_DELIM = re.compile(r"^---[ \t]*\r?$", re.MULTILINE)
|
||||
|
||||
|
||||
def _body_content(content: bytes) -> bytes:
|
||||
"""Strip YAML frontmatter from Markdown content, returning only the body."""
|
||||
text = content.decode(errors="replace")
|
||||
opener = _FRONTMATTER_DELIM.match(text)
|
||||
if opener is None:
|
||||
return content
|
||||
closer = _FRONTMATTER_DELIM.search(text, opener.end())
|
||||
if closer is None:
|
||||
return content
|
||||
# Slice right after the closing `---` (not after its line) so the output
|
||||
# stays byte-identical with the historical implementation for well-formed
|
||||
# frontmatter -- existing semantic-cache hashes must not churn.
|
||||
return text[closer.start() + 3:].encode()
|
||||
|
||||
|
||||
# Stat-based index: maps absolute path → {size, mtime_ns, hash}.
|
||||
# Loaded once per process, flushed via atexit. Skips full file reads when
|
||||
# size+mtime_ns are unchanged — same trade-off as make(1).
|
||||
# Correctness risks: `touch` causes a harmless extra re-hash; same-size edits
|
||||
# within NFS second-resolution mtime have a 1-second window (same as make).
|
||||
# Use `graphify extract --force` to bypass when needed.
|
||||
_stat_index: dict[str, dict] = {}
|
||||
_stat_index_root: Path | None = None
|
||||
_stat_index_dirty: bool = False
|
||||
|
||||
|
||||
def _stat_index_file(root: Path) -> Path:
|
||||
_out = Path(_GRAPHIFY_OUT)
|
||||
base = _out if _out.is_absolute() else Path(root).resolve() / _out
|
||||
return base / "cache" / "stat-index.json"
|
||||
|
||||
|
||||
def _ensure_stat_index(root: Path, cache_root: "Path | None" = None) -> None:
|
||||
global _stat_index, _stat_index_root, _stat_index_dirty
|
||||
if _stat_index_root is not None:
|
||||
return
|
||||
# The stat index only determines the cache FILE location (entry keys are
|
||||
# absolute paths), so honoring an explicit cache_root keeps detect()'s
|
||||
# word-count cache under the requested --out dir instead of polluting the
|
||||
# scanned corpus with a stray graphify-out/ (#1747).
|
||||
_stat_index_root = Path(cache_root if cache_root is not None else root).resolve()
|
||||
p = _stat_index_file(_stat_index_root)
|
||||
if p.exists():
|
||||
try:
|
||||
_stat_index = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
_stat_index = {}
|
||||
else:
|
||||
_stat_index = {}
|
||||
atexit.register(_flush_stat_index)
|
||||
|
||||
|
||||
def _flush_stat_index() -> None:
|
||||
global _stat_index_dirty, _stat_index_root
|
||||
if not _stat_index_dirty or _stat_index_root is None:
|
||||
return
|
||||
p = _stat_index_file(_stat_index_root)
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=p.parent, prefix="stat-index.", suffix=".tmp")
|
||||
try:
|
||||
os.write(fd, json.dumps(_stat_index, separators=(",", ":")).encode())
|
||||
os.close(fd)
|
||||
os.replace(tmp, p)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
_stat_index_dirty = False
|
||||
|
||||
|
||||
def _normalize_path(path: Path) -> Path:
|
||||
"""Normalize path for consistent cache keys across Windows path spellings."""
|
||||
import sys
|
||||
if sys.platform != "win32":
|
||||
return path
|
||||
s = str(path)
|
||||
if s.startswith("\\\\?\\"):
|
||||
s = s[4:] # strip extended-length prefix \\?\
|
||||
return Path(os.path.normcase(s))
|
||||
|
||||
|
||||
def file_hash(path: Path, root: Path = Path(".")) -> str:
|
||||
"""SHA256 of file contents + path relative to root.
|
||||
|
||||
Uses a stat-based fastpath (size + mtime_ns) to skip full reads when the
|
||||
file hasn't changed. Falls through to full SHA256 on first encounter or
|
||||
when stat changes. Index is flushed atomically at process exit.
|
||||
|
||||
Using a relative path (not absolute) makes cache entries portable across
|
||||
machines and checkout directories, so shared caches and CI work correctly.
|
||||
Falls back to the resolved absolute path if the file is outside root.
|
||||
|
||||
For Markdown files (.md), only the body below the YAML frontmatter is hashed,
|
||||
so metadata-only changes (e.g. reviewed, status, tags) do not invalidate the cache.
|
||||
"""
|
||||
global _stat_index_dirty
|
||||
p = _normalize_path(Path(path))
|
||||
root = _normalize_path(Path(root))
|
||||
if not p.is_file():
|
||||
raise IsADirectoryError(f"file_hash requires a file, got: {p}")
|
||||
|
||||
_ensure_stat_index(root)
|
||||
abs_key = str(p.resolve())
|
||||
st: "os.stat_result | None" = None
|
||||
try:
|
||||
st = p.stat()
|
||||
entry = _stat_index.get(abs_key)
|
||||
if (entry
|
||||
and entry.get("hash") is not None # word-count-only entries carry no hash
|
||||
and entry.get("size") == st.st_size
|
||||
and entry.get("mtime_ns") == st.st_mtime_ns):
|
||||
return entry["hash"]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
raw = p.read_bytes()
|
||||
content = _body_content(raw) if p.suffix.lower() == ".md" else raw
|
||||
h = hashlib.sha256()
|
||||
h.update(content)
|
||||
h.update(b"\x00")
|
||||
try:
|
||||
rel = p.resolve().relative_to(Path(root).resolve())
|
||||
h.update(rel.as_posix().lower().encode())
|
||||
except ValueError:
|
||||
h.update(p.resolve().as_posix().lower().encode())
|
||||
digest = h.hexdigest()
|
||||
|
||||
if st is not None:
|
||||
entry = _stat_index.get(abs_key)
|
||||
if (entry is not None
|
||||
and entry.get("size") == st.st_size
|
||||
and entry.get("mtime_ns") == st.st_mtime_ns):
|
||||
entry["hash"] = digest # preserve a co-located word_count
|
||||
else:
|
||||
_stat_index[abs_key] = {"size": st.st_size, "mtime_ns": st.st_mtime_ns, "hash": digest}
|
||||
_stat_index_dirty = True
|
||||
|
||||
return digest
|
||||
|
||||
|
||||
def cached_word_count(path: Path, root: Path, compute, cache_root: "Path | None" = None) -> int:
|
||||
"""Word count with the same (size, mtime_ns) stat-fastpath cache as
|
||||
:func:`file_hash`, persisted in the shared stat index.
|
||||
|
||||
``detect()`` counts words in every PDF/docx/text file to size the corpus,
|
||||
which re-opens and re-parses every binary on each run — minutes on a large
|
||||
docs corpus even when only a handful of files changed (#1656). This caches
|
||||
the count against the file's stat signature so an unchanged file is counted
|
||||
once and read from the index thereafter. ``compute(path)`` produces the
|
||||
count on a miss. A file that can't be stat'd (e.g. a Windows long path the
|
||||
index normalization can't reach) simply recomputes and isn't cached —
|
||||
correct, just not accelerated.
|
||||
"""
|
||||
global _stat_index_dirty
|
||||
p = _normalize_path(Path(path))
|
||||
root = _normalize_path(Path(root))
|
||||
_ensure_stat_index(root, cache_root=cache_root)
|
||||
abs_key = str(p.resolve())
|
||||
st: "os.stat_result | None" = None
|
||||
try:
|
||||
st = p.stat()
|
||||
entry = _stat_index.get(abs_key)
|
||||
if (entry
|
||||
and entry.get("size") == st.st_size
|
||||
and entry.get("mtime_ns") == st.st_mtime_ns
|
||||
and "word_count" in entry):
|
||||
return entry["word_count"]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
wc = compute(Path(path))
|
||||
|
||||
if st is not None:
|
||||
entry = _stat_index.get(abs_key)
|
||||
if (entry
|
||||
and entry.get("size") == st.st_size
|
||||
and entry.get("mtime_ns") == st.st_mtime_ns):
|
||||
entry["word_count"] = wc # augment the existing hash entry in place
|
||||
else:
|
||||
_stat_index[abs_key] = {
|
||||
"size": st.st_size, "mtime_ns": st.st_mtime_ns, "word_count": wc,
|
||||
}
|
||||
_stat_index_dirty = True
|
||||
|
||||
return wc
|
||||
|
||||
|
||||
def _relativize_source_files_in(payload: dict, root: Path) -> None:
|
||||
"""Mutate ``payload`` to rewrite absolute ``source_file`` fields as
|
||||
forward-slash relative paths from ``root``.
|
||||
|
||||
Mirror of :func:`graphify.watch._relativize_source_files` so cached
|
||||
extraction fragments persist in portable form (#777). Already-relative
|
||||
fields and out-of-root paths pass through unchanged.
|
||||
|
||||
Only ``root`` is resolved — ``source_file`` itself is relativized
|
||||
symbolically so in-root symlinks keep their original name rather than
|
||||
pointing at the resolved target. Same reasoning as
|
||||
:func:`graphify.detect._to_relative_for_storage`.
|
||||
"""
|
||||
try:
|
||||
root_resolved = Path(root).resolve()
|
||||
except OSError:
|
||||
return
|
||||
# raw_calls (#: Pascal/Delphi cross-file inherited-call resolution) carries
|
||||
# source_file the same way nodes/edges/hyperedges do, so it needs the same
|
||||
# portable-path treatment for cache entries to round-trip correctly across
|
||||
# machines/checkout directories.
|
||||
for bucket in ("nodes", "edges", "hyperedges", "raw_calls"):
|
||||
for item in payload.get(bucket, []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
source = item.get("source_file")
|
||||
if not source:
|
||||
continue
|
||||
sp = Path(source)
|
||||
if not sp.is_absolute():
|
||||
continue
|
||||
try:
|
||||
rel = os.path.relpath(sp, root_resolved)
|
||||
except (ValueError, OSError):
|
||||
continue # out-of-root (e.g. Windows cross-drive)
|
||||
if rel == ".." or rel.startswith(".." + os.sep) or rel.startswith("../"):
|
||||
continue # escaped root — keep absolute
|
||||
item["source_file"] = rel.replace(os.sep, "/")
|
||||
|
||||
|
||||
def _absolutize_source_files_in(payload: dict, root: Path) -> None:
|
||||
"""Inverse of :func:`_relativize_source_files_in`.
|
||||
|
||||
Re-anchor relative ``source_file`` fields against ``root`` so callers
|
||||
that load a cached fragment see the same absolute-path shape that a
|
||||
fresh in-process extraction would produce. Legacy cache entries with
|
||||
absolute ``source_file`` values pass through unchanged.
|
||||
"""
|
||||
try:
|
||||
root_resolved = Path(root).resolve()
|
||||
except OSError:
|
||||
return
|
||||
for bucket in ("nodes", "edges", "hyperedges", "raw_calls"):
|
||||
for item in payload.get(bucket, []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
source = item.get("source_file")
|
||||
if not source:
|
||||
continue
|
||||
sp = Path(source)
|
||||
if sp.is_absolute():
|
||||
continue
|
||||
try:
|
||||
item["source_file"] = str(root_resolved / sp)
|
||||
except (TypeError, OSError):
|
||||
continue
|
||||
|
||||
|
||||
def cache_dir(root: Path = Path("."), kind: str = "ast") -> Path:
|
||||
"""Returns the cache directory for ``kind`` - creates it if needed.
|
||||
|
||||
kind is "ast" or "semantic". Separate subdirectories prevent semantic cache
|
||||
entries from overwriting AST cache entries for the same source_file (#582).
|
||||
|
||||
AST entries live in graphify-out/cache/ast/v{version}/ — namespaced by
|
||||
graphify version because they depend on extractor code, not just file
|
||||
contents. Semantic entries live unversioned in graphify-out/cache/semantic/
|
||||
(re-extraction costs LLM calls).
|
||||
"""
|
||||
_out = Path(_GRAPHIFY_OUT)
|
||||
base = _out if _out.is_absolute() else Path(root).resolve() / _out
|
||||
d = base / "cache" / kind
|
||||
if kind == "ast":
|
||||
d = d / f"v{_EXTRACTOR_VERSION}"
|
||||
_cleanup_stale_ast_entries(d.parent, d)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def load_cached(path: Path, root: Path = Path("."), kind: str = "ast") -> dict | None:
|
||||
"""Return cached extraction for this file if hash matches, else None.
|
||||
|
||||
Cache key: SHA256 of file contents.
|
||||
Cache value: stored as graphify-out/cache/{kind}/{hash}.json (AST entries
|
||||
under the per-version subdirectory, see :func:`cache_dir`).
|
||||
|
||||
AST entries written by other graphify versions — including the legacy
|
||||
flat cache/ layout (pre-0.5.3) and the unversioned cache/ast/ layout —
|
||||
are deliberately not consulted: they were produced by a different
|
||||
extractor and may be stale.
|
||||
Returns None if no cache entry or file has changed.
|
||||
"""
|
||||
try:
|
||||
h = file_hash(path, root)
|
||||
except OSError:
|
||||
return None
|
||||
entry = cache_dir(root, kind) / f"{h}.json"
|
||||
if entry.exists():
|
||||
try:
|
||||
result = json.loads(entry.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
# Re-anchor relative source_file fields so callers see the same
|
||||
# absolute-path shape that a fresh in-process extraction produces
|
||||
# (#777). Legacy entries with absolute source_file pass through.
|
||||
if isinstance(result, dict):
|
||||
_absolutize_source_files_in(result, root)
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def save_cached(path: Path, result: dict, root: Path = Path("."), kind: str = "ast") -> None:
|
||||
"""Save extraction result for this file.
|
||||
|
||||
Stores as graphify-out/cache/{kind}/{hash}.json where hash = SHA256 of current file contents.
|
||||
result should be a dict with 'nodes' and 'edges' lists.
|
||||
|
||||
No-ops if `path` is not a regular file. Subagent-produced semantic fragments
|
||||
occasionally carry a directory path in `source_file`; skipping them prevents
|
||||
IsADirectoryError from aborting the whole batch.
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return
|
||||
# Relativize source_file fields against ``root`` before write so the
|
||||
# cache file on disk is portable across machines and checkout
|
||||
# directories (#777). The cache key is content-hashed so lookup is
|
||||
# already path-independent; this fixes the embedded path leak.
|
||||
#
|
||||
# Serialize a relativized copy rather than mutating the caller's dict —
|
||||
# downstream pipeline steps (notably extract.py's AST prefix remap, which
|
||||
# looks up Path(source_file).resolve() in a prefix table) depend on the
|
||||
# source_file field's original absolute form. Mutating the input here would
|
||||
# silently break those remaps on the first extraction pass.
|
||||
on_disk = result
|
||||
if isinstance(result, dict) and any(result.get(k) for k in ("nodes", "edges", "hyperedges", "raw_calls")):
|
||||
import copy as _copy
|
||||
on_disk = _copy.deepcopy(result)
|
||||
_relativize_source_files_in(on_disk, root)
|
||||
h = file_hash(p, root)
|
||||
target_dir = cache_dir(root, kind)
|
||||
entry = target_dir / f"{h}.json"
|
||||
fd, tmp_path = tempfile.mkstemp(dir=target_dir, prefix=f"{h}.", suffix=".tmp")
|
||||
try:
|
||||
os.write(fd, json.dumps(on_disk).encode())
|
||||
os.close(fd)
|
||||
try:
|
||||
os.replace(tmp_path, entry)
|
||||
except PermissionError:
|
||||
# Windows: os.replace can fail with WinError 5 if the target is
|
||||
# briefly locked. Fall back to copy-then-delete.
|
||||
import shutil
|
||||
shutil.copy2(tmp_path, entry)
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def cached_files(root: Path = Path(".")) -> set[str]:
|
||||
"""Return set of file hashes that have a valid cache entry (any kind)."""
|
||||
base = Path(root).resolve() / _GRAPHIFY_OUT / "cache"
|
||||
hashes: set[str] = set()
|
||||
# Legacy flat entries
|
||||
if base.is_dir():
|
||||
hashes.update(p.stem for p in base.glob("*.json"))
|
||||
# Namespaced entries (ast/ recursively, covering per-version subdirs)
|
||||
for kind, pattern in (("ast", "**/*.json"), ("semantic", "*.json")):
|
||||
d = base / kind
|
||||
if d.is_dir():
|
||||
hashes.update(p.stem for p in d.glob(pattern))
|
||||
return hashes
|
||||
|
||||
|
||||
def clear_cache(root: Path = Path(".")) -> None:
|
||||
"""Delete all cache entries (ast/, semantic/, and legacy flat entries)."""
|
||||
base = Path(root).resolve() / _GRAPHIFY_OUT / "cache"
|
||||
# Legacy flat entries
|
||||
if base.is_dir():
|
||||
for f in base.glob("*.json"):
|
||||
f.unlink()
|
||||
# Namespaced entries (ast/ recursively, covering per-version subdirs)
|
||||
for kind, pattern in (("ast", "**/*.json"), ("semantic", "*.json")):
|
||||
d = base / kind
|
||||
if d.is_dir():
|
||||
for f in d.glob(pattern):
|
||||
f.unlink()
|
||||
|
||||
|
||||
def prune_semantic_cache(root: Path, live_hashes: set[str]) -> int:
|
||||
"""Remove orphaned semantic cache entries, returning the count pruned.
|
||||
|
||||
The semantic cache is content-hash-keyed (``{file_hash}.json`` under
|
||||
``cache/semantic/``) and deliberately UNVERSIONED — entries are produced by
|
||||
the LLM from file contents, so invalidating them on every release would
|
||||
re-bill extraction. Because it is unversioned it is also never swept by the
|
||||
AST version-cleanup, so every content change or file deletion leaves a
|
||||
permanent orphan entry that accumulates unbounded.
|
||||
|
||||
This sweeps ``cache/semantic/*.json`` and deletes any entry whose stem (the
|
||||
content hash) is not in ``live_hashes`` — the hashes of the current live
|
||||
document set. ``*.tmp`` atomic-write temporaries are skipped, and only this
|
||||
directory is touched (never ``cache/ast/**`` or anything else). The
|
||||
unversioned design is preserved: we prune by liveness, not by version.
|
||||
|
||||
Best-effort, mirroring :func:`_cleanup_stale_ast_entries`: each unlink is
|
||||
wrapped in ``try/except OSError`` and a failure is ignored. The worst-case
|
||||
failure mode is benign — a surviving orphan costs only one re-extraction of
|
||||
one doc on a future run, never incorrect output.
|
||||
"""
|
||||
_out = Path(_GRAPHIFY_OUT)
|
||||
base = _out if _out.is_absolute() else Path(root).resolve() / _out
|
||||
semantic_dir = base / "cache" / "semantic"
|
||||
if not semantic_dir.is_dir():
|
||||
return 0
|
||||
pruned = 0
|
||||
for entry in semantic_dir.glob("*.json"):
|
||||
if entry.stem in live_hashes:
|
||||
continue
|
||||
try:
|
||||
entry.unlink()
|
||||
pruned += 1
|
||||
except OSError:
|
||||
pass
|
||||
return pruned
|
||||
|
||||
|
||||
def check_semantic_cache(
|
||||
files: list[str],
|
||||
root: Path = Path("."),
|
||||
) -> tuple[list[dict], list[dict], list[dict], list[str]]:
|
||||
"""Check semantic extraction cache for a list of absolute file paths.
|
||||
|
||||
Returns (cached_nodes, cached_edges, cached_hyperedges, uncached_files).
|
||||
Uncached files need Claude extraction; cached files are merged directly.
|
||||
"""
|
||||
cached_nodes: list[dict] = []
|
||||
cached_edges: list[dict] = []
|
||||
cached_hyperedges: list[dict] = []
|
||||
uncached: list[str] = []
|
||||
|
||||
for fpath in files:
|
||||
p = Path(fpath)
|
||||
if not p.is_absolute():
|
||||
p = Path(root) / p
|
||||
result = load_cached(p, root, kind="semantic")
|
||||
if result is not None:
|
||||
cached_nodes.extend(result.get("nodes", []))
|
||||
cached_edges.extend(result.get("edges", []))
|
||||
cached_hyperedges.extend(result.get("hyperedges", []))
|
||||
else:
|
||||
uncached.append(fpath)
|
||||
|
||||
return cached_nodes, cached_edges, cached_hyperedges, uncached
|
||||
|
||||
|
||||
def save_semantic_cache(
|
||||
nodes: list[dict],
|
||||
edges: list[dict],
|
||||
hyperedges: list[dict] | None = None,
|
||||
root: Path = Path("."),
|
||||
merge_existing: bool = False,
|
||||
) -> int:
|
||||
"""Save semantic extraction results to cache, keyed by source_file.
|
||||
|
||||
Groups nodes and edges by source_file, then saves one cache entry per file
|
||||
under cache/semantic/ (separate from AST entries in cache/ast/) to prevent
|
||||
hash-key collisions (#582).
|
||||
|
||||
When ``merge_existing`` is True, any already-cached entry for a file is
|
||||
unioned with the new results before saving instead of being overwritten.
|
||||
This lets callers checkpoint incrementally (e.g. once per chunk) without
|
||||
dropping a prior slice of a large file that was split across chunks.
|
||||
Returns the number of files cached.
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
by_file: dict[str, dict] = defaultdict(lambda: {"nodes": [], "edges": [], "hyperedges": []})
|
||||
for n in nodes:
|
||||
src = n.get("source_file", "")
|
||||
if src:
|
||||
by_file[src]["nodes"].append(n)
|
||||
for e in edges:
|
||||
src = e.get("source_file", "")
|
||||
if src:
|
||||
by_file[src]["edges"].append(e)
|
||||
for h in (hyperedges or []):
|
||||
src = h.get("source_file", "")
|
||||
if src:
|
||||
by_file[src]["hyperedges"].append(h)
|
||||
|
||||
saved = 0
|
||||
for fpath, result in by_file.items():
|
||||
p = Path(fpath)
|
||||
if not p.is_absolute():
|
||||
p = Path(root) / p
|
||||
if p.is_file():
|
||||
if merge_existing:
|
||||
prev = load_cached(p, root, kind="semantic")
|
||||
if prev:
|
||||
result = {
|
||||
"nodes": (prev.get("nodes", []) or []) + result["nodes"],
|
||||
"edges": (prev.get("edges", []) or []) + result["edges"],
|
||||
"hyperedges": (prev.get("hyperedges", []) or []) + result["hyperedges"],
|
||||
}
|
||||
save_cached(p, result, root, kind="semantic")
|
||||
saved += 1
|
||||
return saved
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
"""Cargo manifest introspection for workspace-internal crate dependencies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
_CONFIDENCE_EXTRACTED = "EXTRACTED"
|
||||
|
||||
|
||||
def _load_toml(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
import tomllib # type: ignore[import-not-found]
|
||||
except ModuleNotFoundError:
|
||||
try:
|
||||
import tomli as tomllib # type: ignore[import-not-found,no-redef]
|
||||
except ModuleNotFoundError:
|
||||
raise ImportError(
|
||||
"--cargo on Python 3.10 needs tomli. Install with: pip install tomli"
|
||||
) from None
|
||||
|
||||
with path.open("rb") as manifest:
|
||||
return tomllib.load(manifest)
|
||||
|
||||
|
||||
def _member_manifest_paths(root: Path, root_data: dict[str, Any]) -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
if isinstance(root_data.get("package"), dict):
|
||||
paths.append(root / "Cargo.toml")
|
||||
|
||||
workspace = root_data.get("workspace")
|
||||
members = workspace.get("members", []) if isinstance(workspace, dict) else []
|
||||
if not isinstance(members, list):
|
||||
return paths
|
||||
|
||||
for pattern in members:
|
||||
if not isinstance(pattern, str):
|
||||
continue
|
||||
for member in sorted(root.glob(pattern)):
|
||||
manifest = member / "Cargo.toml"
|
||||
if manifest.is_file() and manifest not in paths:
|
||||
paths.append(manifest)
|
||||
return paths
|
||||
|
||||
|
||||
def introspect_cargo(root: str | Path) -> dict[str, Any]:
|
||||
"""Return crate nodes and internal dependency edges from Cargo manifests."""
|
||||
root_path = Path(root).resolve()
|
||||
root_manifest = root_path / "Cargo.toml"
|
||||
root_data = _load_toml(root_manifest)
|
||||
|
||||
manifests = _member_manifest_paths(root_path, root_data)
|
||||
crates: dict[str, tuple[str, Path, dict[str, Any]]] = {}
|
||||
|
||||
for manifest in manifests:
|
||||
data = root_data if manifest == root_manifest else _load_toml(manifest)
|
||||
package = data.get("package")
|
||||
if not isinstance(package, dict):
|
||||
continue
|
||||
name = package.get("name")
|
||||
if isinstance(name, str):
|
||||
crates[name] = (f"crate:{name}", manifest, data)
|
||||
|
||||
nodes = [
|
||||
{
|
||||
"id": crate_id,
|
||||
"label": name,
|
||||
"source_file": manifest.relative_to(root_path).as_posix(),
|
||||
"source_location": "L1",
|
||||
}
|
||||
for name, (crate_id, manifest, _data) in sorted(crates.items())
|
||||
]
|
||||
|
||||
edges: list[dict[str, Any]] = []
|
||||
for source_name, (source_id, manifest, data) in sorted(crates.items()):
|
||||
dependencies = data.get("dependencies", {})
|
||||
if not isinstance(dependencies, dict):
|
||||
continue
|
||||
source_file = manifest.relative_to(root_path).as_posix()
|
||||
for dependency_name in sorted(dependencies):
|
||||
target = crates.get(dependency_name)
|
||||
if target is None:
|
||||
continue
|
||||
edges.append(
|
||||
{
|
||||
"source": source_id,
|
||||
"target": target[0],
|
||||
"relation": "crate_depends_on",
|
||||
"context": "cargo_dependency",
|
||||
"weight": 1.0,
|
||||
"confidence": _CONFIDENCE_EXTRACTED,
|
||||
"source_file": source_file,
|
||||
"source_location": "L1",
|
||||
}
|
||||
)
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
+2772
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,320 @@
|
||||
"""Community detection on NetworkX graphs. Uses Leiden (graspologic) if available, falls back to Louvain (networkx). Splits oversized communities. Returns cohesion scores."""
|
||||
from __future__ import annotations
|
||||
import contextlib
|
||||
import inspect
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import networkx as nx
|
||||
|
||||
|
||||
def _suppress_output():
|
||||
"""Context manager to suppress stdout/stderr during library calls.
|
||||
|
||||
graspologic's leiden() emits ANSI escape sequences (progress bars,
|
||||
colored warnings) that corrupt PowerShell 5.1's scroll buffer on
|
||||
Windows (see issue #19). Redirecting stdout/stderr to devnull during
|
||||
the call prevents this without losing any graphify output.
|
||||
"""
|
||||
return contextlib.redirect_stdout(io.StringIO())
|
||||
|
||||
|
||||
def _partition(G: nx.Graph, resolution: float = 1.0) -> dict[str, int]:
|
||||
"""Run community detection. Returns {node_id: community_id}.
|
||||
|
||||
Tries Leiden (graspologic) first — best quality.
|
||||
Falls back to Louvain (built into networkx) if graspologic is not installed.
|
||||
|
||||
resolution > 1.0 → more, smaller communities.
|
||||
resolution < 1.0 → fewer, larger communities.
|
||||
|
||||
Output from graspologic is suppressed to prevent ANSI escape codes
|
||||
from corrupting terminal scroll buffers on Windows PowerShell 5.1.
|
||||
"""
|
||||
stable = nx.Graph()
|
||||
stable.add_nodes_from(sorted(G.nodes(), key=str))
|
||||
edge_rows = sorted(
|
||||
G.edges(data=True),
|
||||
key=lambda row: (
|
||||
str(row[0]),
|
||||
str(row[1]),
|
||||
json.dumps(row[2], sort_keys=True, ensure_ascii=False, default=str),
|
||||
),
|
||||
)
|
||||
for src, tgt, attrs in edge_rows:
|
||||
stable.add_edge(src, tgt, **attrs)
|
||||
|
||||
try:
|
||||
from graspologic.partition import leiden
|
||||
lsig = inspect.signature(leiden).parameters
|
||||
kwargs: dict = {}
|
||||
if "random_seed" in lsig:
|
||||
kwargs["random_seed"] = 42
|
||||
if "trials" in lsig:
|
||||
kwargs["trials"] = 1
|
||||
if "resolution" in lsig:
|
||||
kwargs["resolution"] = resolution
|
||||
# Suppress graspologic output to prevent ANSI escape codes from
|
||||
# corrupting PowerShell 5.1 scroll buffer (issue #19)
|
||||
old_stderr = sys.stderr
|
||||
try:
|
||||
sys.stderr = io.StringIO()
|
||||
with _suppress_output():
|
||||
result = leiden(stable, **kwargs)
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
return result
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: networkx louvain (available since networkx 2.7).
|
||||
# Inspect kwargs to stay compatible across NetworkX versions — max_level
|
||||
# was added in a later release and prevents hangs on large sparse graphs.
|
||||
kwargs: dict = {"seed": 42, "threshold": 1e-4, "resolution": resolution}
|
||||
if "max_level" in inspect.signature(nx.community.louvain_communities).parameters:
|
||||
kwargs["max_level"] = 10
|
||||
communities = nx.community.louvain_communities(stable, **kwargs)
|
||||
return {node: cid for cid, nodes in enumerate(communities) for node in nodes}
|
||||
|
||||
|
||||
_MAX_COMMUNITY_FRACTION = 0.25 # communities larger than 25% of graph get split
|
||||
_MIN_SPLIT_SIZE = 10 # only split if community has at least this many nodes
|
||||
_COHESION_SPLIT_THRESHOLD = 0.05 # re-split communities with cohesion below this
|
||||
_COHESION_SPLIT_MIN_SIZE = 50 # only cohesion-split if community has at least this many nodes
|
||||
|
||||
|
||||
def label_communities_by_hub(
|
||||
G: nx.Graph, communities: dict[int, list[str]]
|
||||
) -> dict[int, str]:
|
||||
"""Deterministic, LLM-free community labels: name each community after its
|
||||
highest-degree member — the structural hub — so a report reads ``auth`` /
|
||||
``log_action`` instead of ``Community 70``. Degree is measured on the full graph
|
||||
``G``; ties break by node id for run-to-run stability. A community whose members
|
||||
are all absent from ``G`` falls back to ``Community {cid}``.
|
||||
|
||||
Used as the default (no-backend) labeler; an LLM naming pass, when configured,
|
||||
overrides these with richer names.
|
||||
"""
|
||||
labels: dict[int, str] = {}
|
||||
for cid, members in communities.items():
|
||||
present = [n for n in members if n in G]
|
||||
if not present:
|
||||
labels[cid] = f"Community {cid}"
|
||||
continue
|
||||
# highest degree wins; ties broken by node id (ascending) for determinism
|
||||
hub = min(present, key=lambda n: (-G.degree(n), str(n)))
|
||||
name = str(G.nodes[hub].get("label") or hub).strip()
|
||||
if name.endswith("()"):
|
||||
name = name[:-2]
|
||||
labels[cid] = name or f"Community {cid}"
|
||||
return labels
|
||||
|
||||
|
||||
def community_member_sigs(communities: dict[int, list[str]]) -> dict[int, str]:
|
||||
"""Per-community membership fingerprints: ``{cid: sha256(sorted member ids)}``.
|
||||
|
||||
Persisted next to ``.graphify_labels.json`` so a later ``cluster-only`` can tell
|
||||
which communities actually changed since labeling. A cid whose members no longer
|
||||
hash the same is a different community — reusing its old (LLM) label there is the
|
||||
"stale label after re-scoping" bug this guards against. Deterministic; independent
|
||||
of cid index, node order, and machine.
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
sigs: dict[int, str] = {}
|
||||
for cid, members in communities.items():
|
||||
h = hashlib.sha256()
|
||||
for nid in sorted(str(n) for n in members):
|
||||
h.update(nid.encode("utf-8", "replace"))
|
||||
h.update(b"\x00")
|
||||
sigs[cid] = h.hexdigest()[:16]
|
||||
return sigs
|
||||
|
||||
|
||||
def cluster(
|
||||
G: nx.Graph,
|
||||
resolution: float = 1.0,
|
||||
exclude_hubs_percentile: float | None = None,
|
||||
) -> dict[int, list[str]]:
|
||||
"""Run Leiden community detection. Returns {community_id: [node_ids]}.
|
||||
|
||||
Community IDs are stable across runs: 0 = largest community after splitting.
|
||||
Oversized communities (> 25% of graph nodes, min 10) are split by running
|
||||
a second Leiden pass on the subgraph.
|
||||
|
||||
Accepts directed or undirected graphs. DiGraphs are converted to undirected
|
||||
internally since Louvain/Leiden require undirected input.
|
||||
|
||||
resolution: passed to Leiden/Louvain. >1.0 = more smaller communities,
|
||||
<1.0 = fewer larger communities. Default 1.0.
|
||||
exclude_hubs_percentile: if set (0-100), nodes whose degree exceeds this
|
||||
percentile are excluded from partitioning and reattached to their
|
||||
majority-vote neighbour community afterwards. Useful for staging/utility
|
||||
super-hubs that inflate god-node rankings (#919).
|
||||
"""
|
||||
if G.number_of_nodes() == 0:
|
||||
return {}
|
||||
if G.is_directed():
|
||||
G = G.to_undirected()
|
||||
if G.number_of_edges() == 0:
|
||||
return {i: [n] for i, n in enumerate(sorted(G.nodes))}
|
||||
|
||||
# Compute hub exclusion set before removing anything so degree is based on full graph
|
||||
hub_nodes: set[str] = set()
|
||||
if exclude_hubs_percentile is not None:
|
||||
degrees = sorted(d for _, d in G.degree())
|
||||
if degrees:
|
||||
idx = max(0, int(len(degrees) * exclude_hubs_percentile / 100) - 1)
|
||||
threshold = degrees[idx]
|
||||
hub_nodes = {n for n, d in G.degree() if d > threshold}
|
||||
|
||||
# Leiden warns and drops isolates - handle them separately
|
||||
# Also exclude hub nodes from partitioning so they don't pull unrelated
|
||||
# subsystems into the same community
|
||||
excluded = hub_nodes
|
||||
isolates = [n for n in G.nodes() if G.degree(n) == 0 and n not in excluded]
|
||||
connected_nodes = [n for n in G.nodes() if G.degree(n) > 0 and n not in excluded]
|
||||
connected = G.subgraph(connected_nodes)
|
||||
|
||||
raw: dict[int, list[str]] = {}
|
||||
if connected.number_of_nodes() > 0:
|
||||
partition = _partition(connected, resolution=resolution)
|
||||
for node, cid in partition.items():
|
||||
raw.setdefault(cid, []).append(node)
|
||||
|
||||
# Each isolate becomes its own single-node community
|
||||
next_cid = max(raw.keys(), default=-1) + 1
|
||||
for node in isolates:
|
||||
raw[next_cid] = [node]
|
||||
next_cid += 1
|
||||
|
||||
# Reattach excluded hubs by majority-vote neighbour community
|
||||
if hub_nodes:
|
||||
node_community: dict[str, int] = {n: cid for cid, nodes in raw.items() for n in nodes}
|
||||
for hub in sorted(hub_nodes):
|
||||
votes: dict[int, int] = {}
|
||||
for nb in G.neighbors(hub):
|
||||
cid = node_community.get(nb)
|
||||
if cid is not None:
|
||||
votes[cid] = votes.get(cid, 0) + 1
|
||||
if votes:
|
||||
best = min(votes, key=lambda c: (-votes[c], c))
|
||||
raw.setdefault(best, []).append(hub)
|
||||
node_community[hub] = best
|
||||
else:
|
||||
raw[next_cid] = [hub]
|
||||
node_community[hub] = next_cid
|
||||
next_cid += 1
|
||||
|
||||
# Split oversized communities
|
||||
max_size = max(_MIN_SPLIT_SIZE, int(G.number_of_nodes() * _MAX_COMMUNITY_FRACTION))
|
||||
final_communities: list[list[str]] = []
|
||||
for nodes in raw.values():
|
||||
if len(nodes) > max_size:
|
||||
final_communities.extend(_split_community(G, nodes))
|
||||
else:
|
||||
final_communities.append(nodes)
|
||||
|
||||
# Second pass: re-split low-cohesion communities caused by doc-hub nodes
|
||||
# that bridge otherwise-unrelated subsystems (e.g. CLAUDE.md connected to everything).
|
||||
second_pass: list[list[str]] = []
|
||||
for nodes in final_communities:
|
||||
if len(nodes) >= _COHESION_SPLIT_MIN_SIZE and cohesion_score(G, nodes) < _COHESION_SPLIT_THRESHOLD:
|
||||
splits = _split_community(G, nodes)
|
||||
second_pass.extend(splits if len(splits) > 1 else [nodes])
|
||||
else:
|
||||
second_pass.append(nodes)
|
||||
final_communities = second_pass
|
||||
|
||||
# Re-index by size descending. The tuple(sorted(nodes)) tiebreak makes this a
|
||||
# TOTAL order, so an identical grouping always gets identical community IDs.
|
||||
# Without it, the hundreds of equal-sized small communities are ordered by the
|
||||
# partitioner's (not seed-stable) enumeration order, so their integer IDs
|
||||
# permute run-to-run - which reads as massive "community churn" in a per-node
|
||||
# cid diff even though the actual grouping is reproducible (#1090 follow-up).
|
||||
final_communities.sort(key=lambda nodes: (-len(nodes), tuple(sorted(map(str, nodes)))))
|
||||
return {i: sorted(nodes) for i, nodes in enumerate(final_communities)}
|
||||
|
||||
|
||||
def _split_community(G: nx.Graph, nodes: list[str]) -> list[list[str]]:
|
||||
"""Run a second Leiden pass on a community subgraph to split it further."""
|
||||
subgraph = G.subgraph(nodes)
|
||||
if subgraph.number_of_edges() == 0:
|
||||
# No edges - split into individual nodes
|
||||
return [[n] for n in sorted(nodes)]
|
||||
try:
|
||||
sub_partition = _partition(subgraph)
|
||||
sub_communities: dict[int, list[str]] = {}
|
||||
for node, cid in sub_partition.items():
|
||||
sub_communities.setdefault(cid, []).append(node)
|
||||
if len(sub_communities) <= 1:
|
||||
return [sorted(nodes)]
|
||||
return [sorted(v) for v in sub_communities.values()]
|
||||
except Exception:
|
||||
return [sorted(nodes)]
|
||||
|
||||
|
||||
def cohesion_score(G: nx.Graph, community_nodes: list[str]) -> float:
|
||||
"""Ratio of actual intra-community edges to maximum possible."""
|
||||
n = len(community_nodes)
|
||||
if n <= 1:
|
||||
return 1.0
|
||||
subgraph = G.subgraph(community_nodes)
|
||||
actual = subgraph.number_of_edges()
|
||||
possible = n * (n - 1) / 2
|
||||
return actual / possible if possible > 0 else 0.0
|
||||
|
||||
|
||||
def score_all(G: nx.Graph, communities: dict[int, list[str]]) -> dict[int, float]:
|
||||
return {cid: cohesion_score(G, nodes) for cid, nodes in communities.items()}
|
||||
|
||||
|
||||
def remap_communities_to_previous(
|
||||
communities: dict[int, list[str]],
|
||||
previous_node_community: dict[str, int],
|
||||
) -> dict[int, list[str]]:
|
||||
"""Remap community IDs to maximize overlap with a previous assignment.
|
||||
|
||||
Uses greedy one-to-one matching by intersection size, then assigns fresh IDs
|
||||
to unmatched communities in deterministic order (size desc, lexical tie-break).
|
||||
"""
|
||||
if not communities:
|
||||
return {}
|
||||
|
||||
new_sets = {cid: set(nodes) for cid, nodes in communities.items()}
|
||||
old_sets: dict[int, set[str]] = {}
|
||||
for node, old_cid in previous_node_community.items():
|
||||
old_sets.setdefault(old_cid, set()).add(node)
|
||||
|
||||
overlaps: list[tuple[int, int, int]] = []
|
||||
for old_cid, old_nodes in old_sets.items():
|
||||
for new_cid, new_nodes in new_sets.items():
|
||||
overlap = len(old_nodes & new_nodes)
|
||||
if overlap > 0:
|
||||
overlaps.append((overlap, old_cid, new_cid))
|
||||
overlaps.sort(key=lambda x: (-x[0], x[1], x[2]))
|
||||
|
||||
new_to_final: dict[int, int] = {}
|
||||
used_old_ids: set[int] = set()
|
||||
matched_new_ids: set[int] = set()
|
||||
for _overlap, old_cid, new_cid in overlaps:
|
||||
if old_cid in used_old_ids or new_cid in matched_new_ids:
|
||||
continue
|
||||
new_to_final[new_cid] = old_cid
|
||||
used_old_ids.add(old_cid)
|
||||
matched_new_ids.add(new_cid)
|
||||
|
||||
unmatched = [cid for cid in communities if cid not in matched_new_ids]
|
||||
unmatched.sort(key=lambda cid: (-len(communities[cid]), tuple(sorted(communities[cid]))))
|
||||
next_id = 0
|
||||
for new_cid in unmatched:
|
||||
while next_id in used_old_ids:
|
||||
next_id += 1
|
||||
new_to_final[new_cid] = next_id
|
||||
used_old_ids.add(next_id)
|
||||
next_id += 1
|
||||
|
||||
remapped: dict[int, list[str]] = {}
|
||||
for new_cid, nodes in communities.items():
|
||||
remapped[new_to_final[new_cid]] = sorted(nodes)
|
||||
return dict(sorted(remapped.items(), key=lambda kv: kv[0]))
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
description: Build or query a graphify knowledge graph
|
||||
---
|
||||
|
||||
Invoke the `graphify` skill immediately.
|
||||
|
||||
Pass the full `/graphify` argument string through unchanged.
|
||||
If no arguments were supplied, treat the target path as `.`.
|
||||
|
||||
Examples:
|
||||
- `/graphify`
|
||||
- `/graphify src --update`
|
||||
- `/graphify query "what connects auth to billing?"`
|
||||
|
||||
Do not answer from raw files before handing off to the `graphify` skill.
|
||||
@@ -0,0 +1,568 @@
|
||||
"""Entity deduplication pipeline for graphify knowledge graphs.
|
||||
|
||||
Pipeline: exact normalization → entropy gate → MinHash/LSH blocking →
|
||||
Jaro-Winkler verification → same-community boost → union-find merge.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
|
||||
from graphify._minhash import MinHash, MinHashLSH
|
||||
from rapidfuzz.distance import Jaro, JaroWinkler
|
||||
|
||||
|
||||
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _norm(label: str | None) -> str:
|
||||
"""Lowercase + collapse non-alphanumeric runs to space (Unicode-aware)."""
|
||||
if not isinstance(label, str):
|
||||
label = "" if label is None else str(label)
|
||||
label = unicodedata.normalize("NFKC", label)
|
||||
return re.sub(r"[\W_]+", " ", label.casefold(), flags=re.UNICODE).strip()
|
||||
|
||||
|
||||
def _entropy(label: str) -> float:
|
||||
"""Shannon entropy in bits/char of the normalised label."""
|
||||
s = _norm(label)
|
||||
if not s:
|
||||
return 0.0
|
||||
freq: dict[str, int] = defaultdict(int)
|
||||
for ch in s:
|
||||
freq[ch] += 1
|
||||
n = len(s)
|
||||
return -sum((c / n) * math.log2(c / n) for c in freq.values())
|
||||
|
||||
|
||||
def _shingles(text: str, k: int = 3) -> set[str]:
|
||||
"""Return k-gram character shingles of text."""
|
||||
if len(text) < k:
|
||||
return {text}
|
||||
return {text[i : i + k] for i in range(len(text) - k + 1)}
|
||||
|
||||
|
||||
def _make_minhash(text: str, num_perm: int = 128) -> MinHash:
|
||||
# Strip spaces so "graph extractor" and "graphextractor" share shingles
|
||||
m = MinHash(num_perm=num_perm)
|
||||
for shingle in _shingles(text.replace(" ", "")):
|
||||
m.update(shingle.encode("utf-8"))
|
||||
return m
|
||||
|
||||
|
||||
# Matches labels whose trailing token is a version/variant suffix:
|
||||
# digits optionally followed by letters (chip SKUs: ASR1603, M1, Cortex-A55)
|
||||
# or 2+ letters (codename revisions: cranelr vs cranel).
|
||||
# Requires the stem to end in a letter so plain words don't accidentally match.
|
||||
_VARIANT_SUFFIX = re.compile(r"^(.*[a-z])([0-9]+[a-z]*|[a-z]{2,})$")
|
||||
|
||||
|
||||
def _is_variant_pair(a: str, b: str) -> bool:
|
||||
"""True if a and b are sibling model/SKU variants (same stem, different suffix).
|
||||
|
||||
Only applied to short labels (< 12 chars); long labels go through JW normally.
|
||||
"""
|
||||
if a == b:
|
||||
return False
|
||||
if max(len(a), len(b)) >= 12:
|
||||
return False
|
||||
ma, mb = _VARIANT_SUFFIX.match(a), _VARIANT_SUFFIX.match(b)
|
||||
if not (ma and mb):
|
||||
return False
|
||||
return ma.group(1) == mb.group(1) and ma.group(2) != mb.group(2)
|
||||
|
||||
|
||||
def _short_label_blocked(a: str, b: str, jw_score: float) -> bool:
|
||||
"""Block fuzzy merge for short labels unless it's a same-length single-char substitution.
|
||||
|
||||
Insertions/deletions on short strings (cranel/cranelr, M1/M1 Pro) produce
|
||||
high Jaro-Winkler scores due to the prefix bonus but are almost never true
|
||||
duplicates — they're abbreviations or variants.
|
||||
"""
|
||||
if max(len(a), len(b)) >= 12:
|
||||
return False
|
||||
from rapidfuzz.distance import DamerauLevenshtein
|
||||
# Allow only same-length single-char substitutions (true typos like "Extractor"/"Extractar").
|
||||
# Block length-differing pairs regardless of score.
|
||||
if jw_score >= 97.0 and len(a) == len(b) and DamerauLevenshtein.distance(a, b) <= 1:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_DIGIT_RUN = re.compile(r"\d+")
|
||||
|
||||
|
||||
def _numeric_tokens_differ(a: str, b: str) -> bool:
|
||||
"""True when two labels carry different embedded numbers (#1284).
|
||||
|
||||
Long labels that differ only in their digit runs ("ADR 0011 §D5" vs
|
||||
"ADR 0013 D4", "3.1 Product Goals" vs "1.1 Product Goals", "block3" vs
|
||||
"block13", "40%+ retention" vs "<20% retention") are numbered/versioned
|
||||
siblings, not duplicates -- but the long shared boilerplate keeps
|
||||
Jaro-Winkler above _MERGE_THRESHOLD, and _is_variant_pair only covers
|
||||
short trailing suffixes. Digit runs are compared as multisets with
|
||||
leading zeros stripped, so zero-padding ("09" vs "9") does not count as
|
||||
a difference. (String comparison, not int(): a pathological label with a
|
||||
>4300-digit run would crash int() on Python's conversion limit.) Labels
|
||||
with identical numbers, or none at all, are unaffected.
|
||||
"""
|
||||
if a == b:
|
||||
return False
|
||||
return sorted(t.lstrip("0") or "0" for t in _DIGIT_RUN.findall(a)) != \
|
||||
sorted(t.lstrip("0") or "0" for t in _DIGIT_RUN.findall(b))
|
||||
|
||||
|
||||
# file_type values whose identity is anchored to their source location, not
|
||||
# their label text. Like code (#1205), these must not be label-merged across
|
||||
# files: rationale = module/class docstrings, document = headings/positional
|
||||
# content. `concept` is intentionally excluded -- it is the type meant to unify
|
||||
# across files (protected from over-merge by the numeric/Jaro guards instead).
|
||||
_FILE_ANCHORED_NONCODE = frozenset({"rationale", "document"})
|
||||
|
||||
|
||||
def _crossfile_fileanchored_blocked(node: dict, neighbor: dict) -> bool:
|
||||
"""Block label-based merging of file-anchored non-code nodes across files (#1284).
|
||||
|
||||
rationale/document nodes are docstring- and heading-derived and as
|
||||
file-anchored as the code they describe (#1205's reasoning, one layer up):
|
||||
parallel modules carry near-identical boilerplate ("Django app config for
|
||||
apps.<name>. No business logic here...") that differs by one word and sails
|
||||
past the JW threshold. Same-file duplicates of these types may still merge.
|
||||
"""
|
||||
if (node.get("file_type") not in _FILE_ANCHORED_NONCODE
|
||||
and neighbor.get("file_type") not in _FILE_ANCHORED_NONCODE):
|
||||
return False
|
||||
return (node.get("source_file") or "") != (neighbor.get("source_file") or "")
|
||||
|
||||
|
||||
# ── union-find ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _UF:
|
||||
def __init__(self) -> None:
|
||||
self._parent: dict[str, str] = {}
|
||||
|
||||
def find(self, x: str) -> str:
|
||||
self._parent.setdefault(x, x)
|
||||
while self._parent[x] != x:
|
||||
self._parent[x] = self._parent[self._parent[x]]
|
||||
x = self._parent[x]
|
||||
return x
|
||||
|
||||
def union(self, x: str, y: str) -> None:
|
||||
self._parent.setdefault(x, x)
|
||||
self._parent.setdefault(y, y)
|
||||
rx, ry = self.find(x), self.find(y)
|
||||
if rx != ry:
|
||||
self._parent[ry] = rx
|
||||
|
||||
def components(self) -> dict[str, list[str]]:
|
||||
groups: dict[str, list[str]] = defaultdict(list)
|
||||
for x in self._parent:
|
||||
groups[self.find(x)].append(x)
|
||||
return dict(groups)
|
||||
|
||||
|
||||
# ── constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_ENTROPY_THRESHOLD = 2.5
|
||||
_LSH_THRESHOLD = 0.7
|
||||
_MERGE_THRESHOLD = 92.0 # rapidfuzz normalized_similarity * 100
|
||||
_COMMUNITY_BOOST = 5.0 # score bonus when both nodes share community
|
||||
_NUM_PERM = 128
|
||||
_CHUNK_SUFFIX = re.compile(r"_c\d+$")
|
||||
|
||||
|
||||
def _is_code(node: dict) -> bool:
|
||||
"""True for AST-extracted code symbols.
|
||||
|
||||
Code-node identity is the node ID (which already encodes the fully
|
||||
qualified path: module/class/symbol). The label is only a display name
|
||||
(e.g. a bare ``.draw()`` method name, or a function name shared by two
|
||||
parallel backends), so label-based merging conflates distinct symbols
|
||||
(#1205). Genuine duplicates — the same symbol re-extracted — share an ID
|
||||
and are already collapsed by the exact-ID ``seen_ids`` pre-dedup above,
|
||||
so code never needs label-based merging.
|
||||
"""
|
||||
return node.get("file_type") == "code"
|
||||
|
||||
|
||||
# ── main entry point ──────────────────────────────────────────────────────────
|
||||
|
||||
def deduplicate_entities(
|
||||
nodes: list[dict],
|
||||
edges: list[dict],
|
||||
*,
|
||||
communities: dict[str, int],
|
||||
dedup_llm_backend: str | None = None,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""Deduplicate near-identical entities in a knowledge graph.
|
||||
|
||||
Args:
|
||||
nodes: list of node dicts with at minimum {"id": str, "label": str}
|
||||
edges: list of edge dicts with {"source": str, "target": str, ...}
|
||||
communities: mapping of node_id -> community_id (from cluster())
|
||||
dedup_llm_backend: if set, use LLM to resolve ambiguous pairs
|
||||
|
||||
Returns:
|
||||
(deduped_nodes, deduped_edges) with edges rewired to survivors
|
||||
"""
|
||||
# Guard: cross-project dedup is not supported — nodes from different repos
|
||||
# share label names by coincidence and must never be merged by string similarity.
|
||||
# If you need to dedup a global graph, run deduplicate_entities per-repo first.
|
||||
repos_seen = {n.get("repo") for n in nodes if n.get("repo")}
|
||||
if len(repos_seen) > 1:
|
||||
raise ValueError(
|
||||
f"deduplicate_entities: nodes span multiple repos {sorted(repos_seen)!r}. "
|
||||
f"Cross-project dedup is disabled — run dedup per-repo before merging."
|
||||
)
|
||||
|
||||
if len(nodes) <= 1:
|
||||
return nodes, edges
|
||||
|
||||
# Pre-deduplicate: keep first occurrence of each id.
|
||||
# Warn when two nodes share an ID but originate from different source files —
|
||||
# this indicates a cross-chunk ID collision (#1504) where silent data loss occurs.
|
||||
seen_ids: dict[str, dict] = {}
|
||||
for node in nodes:
|
||||
nid = node.get("id", "")
|
||||
if not nid:
|
||||
continue
|
||||
if nid not in seen_ids:
|
||||
seen_ids[nid] = node
|
||||
else:
|
||||
existing_sf = seen_ids[nid].get("source_file") or ""
|
||||
new_sf = node.get("source_file") or ""
|
||||
if existing_sf != new_sf:
|
||||
print(
|
||||
f"[graphify] WARNING: node '{nid}' from '{new_sf}' collides with "
|
||||
f"node from '{existing_sf}' — the second node will be dropped. "
|
||||
f"This is a cross-chunk ID collision caused by two files with the "
|
||||
f"same name in different directories. To avoid data loss, run "
|
||||
f"'graphify extract' per subfolder and merge with "
|
||||
f"'graphify merge-graphs'.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
unique_nodes = list(seen_ids.values())
|
||||
|
||||
if len(unique_nodes) <= 1:
|
||||
return unique_nodes, edges
|
||||
|
||||
# ── pass 1: exact normalization ───────────────────────────────────────────
|
||||
norm_to_nodes: dict[str, list[dict]] = defaultdict(list)
|
||||
for node in unique_nodes:
|
||||
# Code symbols are keyed by ID, never by label — skip them entirely so
|
||||
# distinct same-named symbols are never merged by string similarity (#1205).
|
||||
if _is_code(node):
|
||||
continue
|
||||
key = _norm(node.get("label", node.get("id", "")))
|
||||
if key:
|
||||
norm_to_nodes[key].append(node)
|
||||
|
||||
uf = _UF()
|
||||
exact_merges = 0
|
||||
for key, group in norm_to_nodes.items():
|
||||
if len(group) <= 1:
|
||||
continue
|
||||
# Partition by source_file — only merge within the same file in Pass 1.
|
||||
# Cross-file matches fall through to Pass 2 fuzzy matching.
|
||||
by_file: dict[str, list[dict]] = defaultdict(list)
|
||||
for node in group:
|
||||
sf = node.get("source_file") or ""
|
||||
by_file[sf].append(node)
|
||||
for sf, file_group in by_file.items():
|
||||
if not sf:
|
||||
# No source_file — cannot prove same symbol; skip to avoid
|
||||
# collapsing distinct nodes that happen to share a label (#1178).
|
||||
continue
|
||||
if len(file_group) > 1:
|
||||
winner = _pick_winner(file_group)
|
||||
for node in file_group:
|
||||
uf.union(winner["id"], node["id"])
|
||||
exact_merges += len(file_group) - 1
|
||||
|
||||
# ── pass 2: MinHash/LSH + Jaro-Winkler (high-entropy nodes only) ─────────
|
||||
candidates: list[dict] = []
|
||||
seen_norms: set[str] = set()
|
||||
for node in unique_nodes:
|
||||
# Code symbols are excluded from fuzzy matching too: two functions with
|
||||
# similar long names in different files (parallel backends, sibling
|
||||
# classes) must not be fuzzy-merged, and a code↔concept fuzzy match must
|
||||
# not transitively union two distinct code symbols via a concept (#1205).
|
||||
if _is_code(node):
|
||||
continue
|
||||
key = _norm(node.get("label", node.get("id", "")))
|
||||
if key and key not in seen_norms:
|
||||
seen_norms.add(key)
|
||||
if _entropy(node.get("label", "")) >= _ENTROPY_THRESHOLD:
|
||||
candidates.append(node)
|
||||
|
||||
fuzzy_merges = 0
|
||||
if len(candidates) >= 2:
|
||||
lsh = MinHashLSH(threshold=_LSH_THRESHOLD, num_perm=_NUM_PERM)
|
||||
minhashes: dict[str, MinHash] = {}
|
||||
# Pre-build O(1) lookup structures so the query loop below doesn't scan
|
||||
# the candidates list linearly for every LSH neighbor (was O(n²×B)).
|
||||
candidates_by_id: dict[str, dict] = {}
|
||||
norm_cache: dict[str, str] = {}
|
||||
|
||||
for node in candidates:
|
||||
node_id = node["id"]
|
||||
candidates_by_id[node_id] = node
|
||||
nl = _norm(node.get("label", node.get("id", "")))
|
||||
norm_cache[node_id] = nl
|
||||
m = _make_minhash(nl)
|
||||
minhashes[node_id] = m
|
||||
try:
|
||||
lsh.insert(node_id, m)
|
||||
except ValueError:
|
||||
pass # duplicate key in LSH — already inserted
|
||||
|
||||
for node in candidates:
|
||||
node_id = node["id"]
|
||||
norm_label = norm_cache[node_id]
|
||||
neighbors = lsh.query(minhashes[node_id])
|
||||
|
||||
for neighbor_id in neighbors:
|
||||
if neighbor_id == node_id:
|
||||
continue
|
||||
if uf.find(node_id) == uf.find(neighbor_id):
|
||||
continue
|
||||
|
||||
neighbor = candidates_by_id.get(neighbor_id)
|
||||
if neighbor is None:
|
||||
continue
|
||||
|
||||
neighbor_norm = norm_cache.get(neighbor_id) or _norm(neighbor.get("label", neighbor.get("id", "")))
|
||||
# Cross-file long labels score on plain Jaro (no prefix bonus).
|
||||
# Jaro-Winkler's leading-prefix bonus lifts pairs that share a
|
||||
# prefix but diverge in a distinguishing token ("testing-library
|
||||
# jest-native" vs "react-native") past threshold, fabricating
|
||||
# destructive cross-file merges; on Jaro alone they fall short
|
||||
# while true cross-file duplicates still clear it (#1243). Same-file
|
||||
# near-duplicates keep Jaro-Winkler (low-risk, and a mid-string
|
||||
# stopword insertion needs the prefix bonus to merge); short labels
|
||||
# keep Jaro-Winkler too (gated by _short_label_blocked).
|
||||
_xfile = (node.get("source_file") or "") != (neighbor.get("source_file") or "")
|
||||
if _xfile and max(len(norm_label), len(neighbor_norm)) >= 12:
|
||||
score = Jaro.normalized_similarity(norm_label, neighbor_norm) * 100
|
||||
else:
|
||||
score = JaroWinkler.normalized_similarity(norm_label, neighbor_norm) * 100
|
||||
|
||||
if _is_variant_pair(norm_label, neighbor_norm):
|
||||
continue
|
||||
if _short_label_blocked(norm_label, neighbor_norm, score):
|
||||
continue
|
||||
# Prefix-extension pairs (getActiveSession / getActiveSessions,
|
||||
# parseConfig / parseConfigFile) are almost never duplicates —
|
||||
# one is a strict suffix-extension of the other. Block the merge
|
||||
# regardless of JW score (#1201).
|
||||
_lo, _hi = sorted((norm_label, neighbor_norm), key=len)
|
||||
if _hi.startswith(_lo) and _hi != _lo:
|
||||
continue
|
||||
# Numbered/versioned siblings and cross-file file-anchored
|
||||
# boilerplate (rationale/document) are decisively distinct
|
||||
# regardless of score (#1284).
|
||||
if _numeric_tokens_differ(norm_label, neighbor_norm):
|
||||
continue
|
||||
if _crossfile_fileanchored_blocked(node, neighbor):
|
||||
continue
|
||||
|
||||
c1 = communities.get(node_id)
|
||||
c2 = communities.get(neighbor_id)
|
||||
if (c1 is not None and c2 is not None and c1 == c2
|
||||
and min(len(norm_label), len(neighbor_norm)) >= 12):
|
||||
score += _COMMUNITY_BOOST
|
||||
|
||||
if score >= _MERGE_THRESHOLD:
|
||||
# Identical labels across different source files almost always
|
||||
# means same-named-but-different symbols (trait impls, wrapper
|
||||
# methods, common type names). Mirror Pass 1's source_file
|
||||
# partition for this sub-case. (#1046, leaks #895's fix)
|
||||
if norm_label == neighbor_norm:
|
||||
sf_a = node.get("source_file") or ""
|
||||
sf_b = neighbor.get("source_file") or ""
|
||||
if sf_a != sf_b:
|
||||
continue
|
||||
# Pick the winner from the verified pair only. Selecting it
|
||||
# from the union of both normalized-label groups pulls
|
||||
# never-compared nodes (same label, different source_file)
|
||||
# into the merge, bypassing the #1046/#1178 guards.
|
||||
winner = _pick_winner([node, neighbor])
|
||||
uf.union(winner["id"], node_id)
|
||||
uf.union(winner["id"], neighbor_id)
|
||||
fuzzy_merges += 1
|
||||
|
||||
# ── pass 3: LLM tiebreaker for ambiguous pairs (opt-in) ──────────────────
|
||||
if dedup_llm_backend is not None:
|
||||
_llm_tiebreak(candidates, uf, communities, backend=dedup_llm_backend)
|
||||
|
||||
# ── build remap table from union-find components ──────────────────────────
|
||||
components = uf.components()
|
||||
remap: dict[str, str] = {}
|
||||
|
||||
for root, members in components.items():
|
||||
if len(members) == 1:
|
||||
continue
|
||||
group_nodes = [n for n in unique_nodes if n["id"] in members]
|
||||
winner = _pick_winner(group_nodes) if group_nodes else {"id": root}
|
||||
winner_id = winner["id"]
|
||||
for member in members:
|
||||
if member != winner_id:
|
||||
remap[member] = winner_id
|
||||
|
||||
# ── apply remap ───────────────────────────────────────────────────────────
|
||||
if not remap:
|
||||
return unique_nodes, edges
|
||||
|
||||
total = len(remap)
|
||||
msg = f"[graphify] Deduplicated {total} node(s)"
|
||||
if exact_merges:
|
||||
msg += f" ({exact_merges} exact"
|
||||
if fuzzy_merges:
|
||||
msg += f", {fuzzy_merges} fuzzy"
|
||||
msg += ")"
|
||||
print(msg + ".", flush=True)
|
||||
|
||||
deduped_nodes = [n for n in unique_nodes if n["id"] not in remap]
|
||||
deduped_edges = []
|
||||
for edge in edges:
|
||||
e = dict(edge)
|
||||
# Tolerate "from"/"to" keys from LLM backends that don't follow the
|
||||
# schema exactly — build_from_json normalises later but dedup runs
|
||||
# first so bracket access would KeyError here (#803).
|
||||
# Use explicit key presence check (not `or`) so empty-string src/tgt
|
||||
# aren't silently replaced by the fallback key.
|
||||
src = e["source"] if "source" in e else e.get("from")
|
||||
tgt = e["target"] if "target" in e else e.get("to")
|
||||
if src is None or tgt is None:
|
||||
continue
|
||||
e["source"] = remap.get(src, src)
|
||||
e["target"] = remap.get(tgt, tgt)
|
||||
# Remove legacy keys so they don't leak into edge attrs in graph.json.
|
||||
e.pop("from", None)
|
||||
e.pop("to", None)
|
||||
if e["source"] != e["target"]:
|
||||
deduped_edges.append(e)
|
||||
|
||||
return deduped_nodes, deduped_edges
|
||||
|
||||
|
||||
def _pick_winner(nodes: list[dict]) -> dict:
|
||||
"""Pick the canonical survivor: prefer no chunk suffix, then shorter ID."""
|
||||
if not nodes:
|
||||
raise ValueError("Cannot pick winner from empty list")
|
||||
|
||||
def _score(n: dict) -> tuple[int, int]:
|
||||
has_suffix = bool(_CHUNK_SUFFIX.search(n["id"]))
|
||||
return (1 if has_suffix else 0, len(n["id"]))
|
||||
|
||||
return min(nodes, key=_score)
|
||||
|
||||
|
||||
def _llm_tiebreak(
|
||||
candidates: list[dict],
|
||||
uf: _UF,
|
||||
communities: dict[str, int],
|
||||
*,
|
||||
backend: str,
|
||||
batch_size: int = 30,
|
||||
low: float = 75.0,
|
||||
high: float = 92.0,
|
||||
) -> None:
|
||||
"""Batch-resolve ambiguous pairs (score in [low, high)) via LLM."""
|
||||
try:
|
||||
from graphify.llm import BACKENDS, _format_backend_env_keys, _get_backend_api_key
|
||||
if backend not in BACKENDS:
|
||||
print(f"[graphify] --dedup-llm: unknown backend {backend!r}, skipping LLM tiebreaker.", flush=True)
|
||||
return
|
||||
if not _get_backend_api_key(backend):
|
||||
env_keys = _format_backend_env_keys(backend)
|
||||
print(f"[graphify] --dedup-llm: {env_keys} not set, skipping LLM tiebreaker.", flush=True)
|
||||
return
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
ambiguous: list[tuple[dict, dict, float]] = []
|
||||
for i, node in enumerate(candidates):
|
||||
norm_i = _norm(node.get("label", node.get("id", "")))
|
||||
for j in range(i + 1, len(candidates)):
|
||||
neighbor = candidates[j]
|
||||
if uf.find(node["id"]) == uf.find(neighbor["id"]):
|
||||
continue
|
||||
norm_j = _norm(neighbor.get("label", neighbor.get("id", "")))
|
||||
# Mirror pass 2: plain Jaro for cross-file long labels (#1243).
|
||||
_xfile = (node.get("source_file") or "") != (neighbor.get("source_file") or "")
|
||||
if _xfile and max(len(norm_i), len(norm_j)) >= 12:
|
||||
score = Jaro.normalized_similarity(norm_i, norm_j) * 100
|
||||
else:
|
||||
score = JaroWinkler.normalized_similarity(norm_i, norm_j) * 100
|
||||
if _is_variant_pair(norm_i, norm_j):
|
||||
continue
|
||||
if _short_label_blocked(norm_i, norm_j, score):
|
||||
continue
|
||||
_lo, _hi = sorted((norm_i, norm_j), key=len)
|
||||
if _hi.startswith(_lo) and _hi != _lo:
|
||||
continue
|
||||
# Mirror pass 2: decisively-distinct pairs never reach the LLM (#1284).
|
||||
if _numeric_tokens_differ(norm_i, norm_j):
|
||||
continue
|
||||
if _crossfile_fileanchored_blocked(node, neighbor):
|
||||
continue
|
||||
c1 = communities.get(node["id"])
|
||||
c2 = communities.get(neighbor["id"])
|
||||
if (c1 is not None and c2 is not None and c1 == c2
|
||||
and min(len(norm_i), len(norm_j)) >= 12):
|
||||
score += _COMMUNITY_BOOST
|
||||
if low <= score < high:
|
||||
ambiguous.append((node, neighbor, score))
|
||||
|
||||
if not ambiguous:
|
||||
return
|
||||
|
||||
try:
|
||||
from graphify.llm import _call_llm
|
||||
except ImportError as exc:
|
||||
# F-038: previously this silent fallback hid the fact that `_call_llm`
|
||||
# didn't exist in `graphify.llm` at all, so `--dedup-llm` was a no-op.
|
||||
# Surface the import failure so future regressions are visible.
|
||||
print(
|
||||
f"[graphify] --dedup-llm: cannot import _call_llm ({exc}); skipping LLM tiebreaker.",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
|
||||
for batch_start in range(0, len(ambiguous), batch_size):
|
||||
batch = ambiguous[batch_start : batch_start + batch_size]
|
||||
pairs_text = "\n".join(
|
||||
f"{i+1}. \"{a['label']}\" vs \"{b['label']}\""
|
||||
for i, (a, b, _) in enumerate(batch)
|
||||
)
|
||||
prompt = (
|
||||
"For each pair below, answer only 'yes' or 'no': are they the same real-world concept?\n\n"
|
||||
f"{pairs_text}\n\n"
|
||||
"Reply with one line per pair: '1. yes', '2. no', etc."
|
||||
)
|
||||
try:
|
||||
response = _call_llm(prompt, backend=backend, max_tokens=200)
|
||||
lines = response.strip().splitlines()
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
parts = line.split(".", 1)
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
try:
|
||||
idx = int(parts[0].strip()) - 1
|
||||
except ValueError:
|
||||
continue
|
||||
if 0 <= idx < len(batch):
|
||||
answer = parts[1].strip().lower()
|
||||
if answer.startswith("yes"):
|
||||
a, b, _ = batch[idx]
|
||||
winner = _pick_winner([a, b])
|
||||
uf.union(winner["id"], a["id"])
|
||||
uf.union(winner["id"], b["id"])
|
||||
except Exception as exc:
|
||||
print(f"[graphify] --dedup-llm batch failed: {exc}", flush=True)
|
||||
+1573
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,396 @@
|
||||
"""Read-only diagnostics for MultiDiGraph readiness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections import Counter, defaultdict
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import networkx as nx
|
||||
|
||||
|
||||
_SUPPRESSION_DECL_RE = re.compile(r"^\s*(?P<name>seen_[A-Za-z0-9_]+)\s*[:=]")
|
||||
_TYPE_TUPLE_RE = re.compile(r"set\[tuple\[(?P<inside>[^\]]+)\]\]")
|
||||
|
||||
|
||||
def _safe_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, (str, int, float, bool)):
|
||||
return str(value)
|
||||
return json.dumps(value, sort_keys=True, default=str, ensure_ascii=False)
|
||||
|
||||
|
||||
def _edge_list(extraction: dict[str, Any]) -> list[Any]:
|
||||
edges = extraction.get("edges")
|
||||
if edges is None:
|
||||
edges = extraction.get("links")
|
||||
return edges if isinstance(edges, list) else []
|
||||
|
||||
|
||||
def _node_ids(extraction: dict[str, Any]) -> set[str]:
|
||||
nodes = extraction.get("nodes", [])
|
||||
if not isinstance(nodes, list):
|
||||
return set()
|
||||
return {
|
||||
str(node["id"])
|
||||
for node in nodes
|
||||
if isinstance(node, dict) and "id" in node and node.get("id") is not None
|
||||
}
|
||||
|
||||
|
||||
def _canonical_edge(edge: Any) -> dict[str, str]:
|
||||
if not isinstance(edge, dict):
|
||||
return {
|
||||
"source": "",
|
||||
"target": "",
|
||||
"relation": "",
|
||||
"confidence": "",
|
||||
"source_file": "",
|
||||
"source_location": "",
|
||||
"context": "",
|
||||
"_invalid": "non_object_edge",
|
||||
}
|
||||
source = edge.get("source", edge.get("from"))
|
||||
target = edge.get("target", edge.get("to"))
|
||||
return {
|
||||
"source": _safe_text(source),
|
||||
"target": _safe_text(target),
|
||||
"relation": _safe_text(edge.get("relation")),
|
||||
"confidence": _safe_text(edge.get("confidence")),
|
||||
"source_file": _safe_text(edge.get("source_file")),
|
||||
"source_location": _safe_text(edge.get("source_location")),
|
||||
"context": _safe_text(edge.get("context")),
|
||||
"_invalid": "",
|
||||
}
|
||||
|
||||
|
||||
def _exact_signature(edge: Any) -> str:
|
||||
if not isinstance(edge, dict):
|
||||
return "<non-object>"
|
||||
normalized = dict(edge)
|
||||
if "source" not in normalized and "from" in normalized:
|
||||
normalized["source"] = normalized["from"]
|
||||
if "target" not in normalized and "to" in normalized:
|
||||
normalized["target"] = normalized["to"]
|
||||
normalized.pop("from", None)
|
||||
normalized.pop("to", None)
|
||||
return json.dumps(
|
||||
normalized,
|
||||
sort_keys=True,
|
||||
default=str,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def _count_extra(counter: Counter[Any]) -> int:
|
||||
return sum(count - 1 for count in counter.values() if count > 1)
|
||||
|
||||
|
||||
def _variant_group_count(
|
||||
grouped_edges: dict[tuple[str, str], list[dict[str, str]]],
|
||||
field: str,
|
||||
*,
|
||||
relation_sensitive: bool = False,
|
||||
) -> int:
|
||||
groups = 0
|
||||
for edges in grouped_edges.values():
|
||||
if relation_sensitive:
|
||||
by_relation: dict[str, set[str]] = defaultdict(set)
|
||||
for edge in edges:
|
||||
by_relation[edge["relation"]].add(edge[field])
|
||||
groups += sum(1 for values in by_relation.values() if len(values) > 1)
|
||||
elif len({edge[field] for edge in edges}) > 1:
|
||||
groups += 1
|
||||
return groups
|
||||
|
||||
|
||||
def _tuple_arity_from_annotation(line: str) -> int:
|
||||
match = _TYPE_TUPLE_RE.search(line)
|
||||
if not match:
|
||||
return 0
|
||||
inside = match.group("inside").strip()
|
||||
if not inside:
|
||||
return 0
|
||||
return inside.count(",") + 1
|
||||
|
||||
|
||||
def scan_producer_suppression_sites(path: str | Path) -> dict[str, Any]:
|
||||
"""Find likely `seen_*` producer-suppression sets in an extractor file."""
|
||||
source_path = Path(path)
|
||||
if not source_path.exists():
|
||||
return {
|
||||
"path": str(source_path),
|
||||
"total_sites": 0,
|
||||
"sites": [],
|
||||
"error": "file not found",
|
||||
}
|
||||
|
||||
sites: list[dict[str, Any]] = []
|
||||
lines = source_path.read_text(encoding="utf-8").splitlines()
|
||||
for lineno, line in enumerate(lines, start=1):
|
||||
match = _SUPPRESSION_DECL_RE.match(line)
|
||||
if not match:
|
||||
continue
|
||||
sites.append(
|
||||
{
|
||||
"line": lineno,
|
||||
"name": match.group("name"),
|
||||
"tuple_arity": _tuple_arity_from_annotation(line),
|
||||
"sample": line.strip()[:120],
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"path": str(source_path),
|
||||
"total_sites": len(sites),
|
||||
"sites": sites,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def diagnose_extraction(
|
||||
extraction: dict[str, Any],
|
||||
*,
|
||||
directed: bool = True,
|
||||
root: str | Path | None = None,
|
||||
max_examples: int = 5,
|
||||
extract_path: str | Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Summarize same-endpoint edge-collapse risk for one JSON graph/extraction dict."""
|
||||
from graphify.build import build_from_json
|
||||
|
||||
node_ids = _node_ids(extraction)
|
||||
raw_edges = _edge_list(extraction)
|
||||
canonical_edges = [_canonical_edge(edge) for edge in raw_edges]
|
||||
|
||||
exact_counts: Counter[str] = Counter(_exact_signature(edge) for edge in raw_edges)
|
||||
directed_pairs: Counter[tuple[str, str]] = Counter()
|
||||
undirected_pairs: Counter[tuple[str, str]] = Counter()
|
||||
grouped: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
|
||||
|
||||
non_object_edges = 0
|
||||
missing_endpoint_edges = 0
|
||||
dangling_endpoint_edges = 0
|
||||
self_loop_edges = 0
|
||||
valid_candidate_edges = 0
|
||||
|
||||
for edge in canonical_edges:
|
||||
if edge["_invalid"]:
|
||||
non_object_edges += 1
|
||||
continue
|
||||
source = edge["source"]
|
||||
target = edge["target"]
|
||||
if not source or not target:
|
||||
missing_endpoint_edges += 1
|
||||
continue
|
||||
if source not in node_ids or target not in node_ids:
|
||||
dangling_endpoint_edges += 1
|
||||
continue
|
||||
if source == target:
|
||||
self_loop_edges += 1
|
||||
valid_candidate_edges += 1
|
||||
directed_pair = (source, target)
|
||||
undirected_pair = (source, target) if source <= target else (target, source)
|
||||
directed_pairs[directed_pair] += 1
|
||||
undirected_pairs[undirected_pair] += 1
|
||||
grouped[directed_pair].append(edge)
|
||||
|
||||
examples: list[dict[str, Any]] = []
|
||||
if max_examples > 0:
|
||||
for (source, target), count in directed_pairs.most_common():
|
||||
if count < 2:
|
||||
continue
|
||||
edges = grouped[(source, target)]
|
||||
examples.append(
|
||||
{
|
||||
"source": source,
|
||||
"target": target,
|
||||
"edge_count": count,
|
||||
"relations": sorted({edge["relation"] for edge in edges}),
|
||||
"source_files": sorted({edge["source_file"] for edge in edges}),
|
||||
"source_locations": sorted({edge["source_location"] for edge in edges}),
|
||||
"contexts": sorted({edge["context"] for edge in edges}),
|
||||
}
|
||||
)
|
||||
if len(examples) >= max_examples:
|
||||
break
|
||||
|
||||
build_error = ""
|
||||
graph_type = ""
|
||||
post_build_edge_count: int | None = None
|
||||
post_build_node_count: int | None = None
|
||||
try:
|
||||
graph_input = deepcopy(extraction)
|
||||
graph: nx.Graph = build_from_json(graph_input, directed=directed, root=root)
|
||||
graph_type = type(graph).__name__
|
||||
post_build_edge_count = graph.number_of_edges()
|
||||
post_build_node_count = graph.number_of_nodes()
|
||||
except Exception as exc:
|
||||
build_error = f"{type(exc).__name__}: {exc}"
|
||||
|
||||
suppression_path = (
|
||||
Path(extract_path) if extract_path else Path(__file__).with_name("extract.py")
|
||||
)
|
||||
|
||||
return {
|
||||
"node_count": len(node_ids),
|
||||
"raw_edge_count": len(raw_edges),
|
||||
"non_object_edges": non_object_edges,
|
||||
"missing_endpoint_edges": missing_endpoint_edges,
|
||||
"dangling_endpoint_edges": dangling_endpoint_edges,
|
||||
"self_loop_edges": self_loop_edges,
|
||||
"valid_candidate_edges": valid_candidate_edges,
|
||||
"exact_duplicate_edges": _count_extra(exact_counts),
|
||||
"directed_unique_endpoint_pairs": len(directed_pairs),
|
||||
"directed_same_endpoint_collapsed_edges": _count_extra(directed_pairs),
|
||||
"undirected_unique_endpoint_pairs": len(undirected_pairs),
|
||||
"undirected_same_endpoint_collapsed_edges": _count_extra(undirected_pairs),
|
||||
"same_endpoint_group_count": sum(1 for count in directed_pairs.values() if count > 1),
|
||||
"relation_variant_groups": _variant_group_count(grouped, "relation"),
|
||||
"source_file_variant_groups": _variant_group_count(
|
||||
grouped, "source_file", relation_sensitive=True
|
||||
),
|
||||
"source_location_variant_groups": _variant_group_count(
|
||||
grouped, "source_location", relation_sensitive=True
|
||||
),
|
||||
"context_variant_groups": _variant_group_count(grouped, "context", relation_sensitive=True),
|
||||
"post_build_graph_type": graph_type,
|
||||
"post_build_node_count": post_build_node_count,
|
||||
"post_build_edge_count": post_build_edge_count,
|
||||
"post_build_error": build_error,
|
||||
"producer_suppression": scan_producer_suppression_sites(suppression_path),
|
||||
"examples": examples,
|
||||
}
|
||||
|
||||
|
||||
def _read_json_file(path: str | Path) -> dict[str, Any]:
|
||||
"""Read a JSON graph after applying Graphify's graph-load size cap."""
|
||||
from graphify.security import check_graph_file_size_cap
|
||||
|
||||
json_path = Path(path)
|
||||
check_graph_file_size_cap(json_path)
|
||||
try:
|
||||
data = json.loads(json_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError) as exc:
|
||||
raise RuntimeError(
|
||||
f"Cannot parse {json_path}: {exc}. "
|
||||
"The file may be corrupted — re-run 'graphify extract'."
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("diagnostic input must be a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
def diagnose_file(
|
||||
path: str | Path,
|
||||
*,
|
||||
directed: bool | None = None,
|
||||
root: str | Path | None = None,
|
||||
max_examples: int = 5,
|
||||
extract_path: str | Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Diagnose a graph/extraction JSON file without mutating it.
|
||||
|
||||
When `directed` is None, the JSON's "directed" flag is honored. Raw
|
||||
extraction JSON that has no "directed" flag defaults to directed analysis.
|
||||
"""
|
||||
data = _read_json_file(path)
|
||||
if directed is None:
|
||||
raw_directed = data.get("directed")
|
||||
effective_directed = raw_directed if isinstance(raw_directed, bool) else True
|
||||
else:
|
||||
effective_directed = directed
|
||||
|
||||
summary = diagnose_extraction(
|
||||
data,
|
||||
directed=effective_directed,
|
||||
root=root,
|
||||
max_examples=max_examples,
|
||||
extract_path=extract_path,
|
||||
)
|
||||
summary["input_path"] = str(path)
|
||||
summary["effective_directed"] = effective_directed
|
||||
return summary
|
||||
|
||||
|
||||
def format_diagnostic_json(summary: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"summary": {
|
||||
key: value
|
||||
for key, value in summary.items()
|
||||
if key not in {"examples", "producer_suppression"}
|
||||
},
|
||||
"examples": summary.get("examples", []),
|
||||
"producer_suppression": summary.get("producer_suppression", {}),
|
||||
"notes": [
|
||||
"Diagnostics are read-only.",
|
||||
"A normal graph.json is already post-build and cannot recover raw producer edges.",
|
||||
"Producer suppression sites are heuristic source-code evidence.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def format_diagnostic_report(summary: dict[str, Any]) -> str:
|
||||
suppression = summary.get("producer_suppression", {})
|
||||
lines = [
|
||||
"[graphify] MultiDiGraph edge-collapse diagnostic",
|
||||
f"input: {summary.get('input_path', '<in-memory>')}",
|
||||
"input_stage: provided JSON (normal graph.json is post-build)",
|
||||
f"effective_directed: {summary.get('effective_directed', '<direct-call>')}",
|
||||
f"nodes: {summary['node_count']}",
|
||||
f"raw_edges: {summary['raw_edge_count']}",
|
||||
f"valid_candidate_edges: {summary['valid_candidate_edges']}",
|
||||
f"missing_endpoint_edges: {summary['missing_endpoint_edges']}",
|
||||
f"dangling_endpoint_edges: {summary['dangling_endpoint_edges']}",
|
||||
f"self_loop_edges: {summary['self_loop_edges']}",
|
||||
f"exact_duplicate_edges: {summary['exact_duplicate_edges']}",
|
||||
f"directed_unique_endpoint_pairs: {summary['directed_unique_endpoint_pairs']}",
|
||||
(
|
||||
"directed_same_endpoint_collapsed_edges: "
|
||||
f"{summary['directed_same_endpoint_collapsed_edges']}"
|
||||
),
|
||||
f"undirected_unique_endpoint_pairs: {summary['undirected_unique_endpoint_pairs']}",
|
||||
(
|
||||
"undirected_same_endpoint_collapsed_edges: "
|
||||
f"{summary['undirected_same_endpoint_collapsed_edges']}"
|
||||
),
|
||||
f"same_endpoint_group_count: {summary['same_endpoint_group_count']}",
|
||||
f"relation_variant_groups: {summary['relation_variant_groups']}",
|
||||
f"source_file_variant_groups: {summary['source_file_variant_groups']}",
|
||||
f"source_location_variant_groups: {summary['source_location_variant_groups']}",
|
||||
f"context_variant_groups: {summary['context_variant_groups']}",
|
||||
f"post_build_graph_type: {summary['post_build_graph_type']}",
|
||||
f"post_build_edges: {summary['post_build_edge_count']}",
|
||||
f"producer_suppression_sites: {suppression.get('total_sites', 0)}",
|
||||
]
|
||||
if summary.get("post_build_error"):
|
||||
lines.append(f"post_build_error: {summary['post_build_error']}")
|
||||
if suppression.get("error"):
|
||||
lines.append(f"producer_suppression_error: {suppression['error']}")
|
||||
if suppression.get("sites"):
|
||||
lines.append("producer_suppression_examples:")
|
||||
for site in suppression["sites"][:8]:
|
||||
lines.append(
|
||||
f" - L{site['line']} {site['name']} arity={site['tuple_arity'] or 'unknown'}"
|
||||
)
|
||||
if summary.get("examples"):
|
||||
lines.append("examples:")
|
||||
for example in summary["examples"]:
|
||||
lines.append(
|
||||
" - "
|
||||
f"{example['source']} -> {example['target']} "
|
||||
f"edges={example['edge_count']} "
|
||||
f"relations={example['relations']} "
|
||||
f"locations={example['source_locations']} "
|
||||
f"contexts={example['contexts']}"
|
||||
)
|
||||
lines.append(
|
||||
"note: normal graph.json is post-build; raw producer loss must be measured earlier."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,993 @@
|
||||
# write graph to HTML, JSON, SVG, GraphML, Obsidian vault, and Neo4j Cypher
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import html as _html
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
import networkx as nx
|
||||
from networkx.readwrite import json_graph
|
||||
from graphify.security import sanitize_label
|
||||
from graphify.analyze import _node_community_map
|
||||
from graphify.build import edge_data
|
||||
|
||||
from graphify.exporters.graphdb import push_to_falkordb, push_to_neo4j # noqa: E402,F401
|
||||
|
||||
|
||||
# Artifacts worth preserving across rebuilds (non-regenerable without LLM or curation).
|
||||
_BACKUP_ARTIFACTS = [
|
||||
"graph.json",
|
||||
"GRAPH_REPORT.md",
|
||||
".graphify_labels.json",
|
||||
".graphify_analysis.json",
|
||||
"manifest.json",
|
||||
".graphify_semantic_marker",
|
||||
"cost.json",
|
||||
]
|
||||
|
||||
|
||||
def backup_if_protected(out_dir: Path) -> "Path | None":
|
||||
"""Snapshot graph artifacts to a dated subfolder before an overwrite.
|
||||
|
||||
Triggers when graph.json exists AND either:
|
||||
- .graphify_semantic_marker is present (graph cost real LLM tokens), or
|
||||
- .graphify_labels.json contains at least one non-default community label
|
||||
(graph has been curated by a human or skill).
|
||||
|
||||
Returns the backup folder path, or None if no backup was taken.
|
||||
Never raises — backup failure prints a warning but never blocks the write.
|
||||
Set GRAPHIFY_NO_BACKUP=1 to disable.
|
||||
"""
|
||||
if os.environ.get("GRAPHIFY_NO_BACKUP"):
|
||||
return None
|
||||
out = Path(out_dir)
|
||||
if not (out / "graph.json").exists():
|
||||
return None
|
||||
|
||||
is_semantic = (out / ".graphify_semantic_marker").exists()
|
||||
is_curated = False
|
||||
labels_file = out / ".graphify_labels.json"
|
||||
if labels_file.exists():
|
||||
try:
|
||||
labels = json.loads(labels_file.read_text(encoding="utf-8"))
|
||||
is_curated = any(v != f"Community {k}" for k, v in labels.items())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not is_semantic and not is_curated:
|
||||
return None
|
||||
|
||||
reason = "+".join(filter(None, ["semantic" if is_semantic else "", "curated" if is_curated else ""]))
|
||||
today = date.today().isoformat()
|
||||
backup_dir = out / today
|
||||
graph_src = out / "graph.json"
|
||||
|
||||
# Skip re-copying if today's backup already has identical graph.json content.
|
||||
# If content differs (graph changed since the last backup today), overwrite
|
||||
# the backup in place — one folder per day, always the latest pre-overwrite state.
|
||||
if backup_dir.exists() and (backup_dir / "graph.json").exists():
|
||||
src_hash = hashlib.sha256(graph_src.read_bytes()).hexdigest()
|
||||
bak_hash = hashlib.sha256((backup_dir / "graph.json").read_bytes()).hexdigest()
|
||||
if src_hash == bak_hash:
|
||||
return backup_dir # identical content, nothing to do
|
||||
|
||||
try:
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
copied = 0
|
||||
for name in _BACKUP_ARTIFACTS:
|
||||
src = out / name
|
||||
if src.exists():
|
||||
try:
|
||||
shutil.copy2(src, backup_dir / name)
|
||||
copied += 1
|
||||
except Exception:
|
||||
pass
|
||||
if copied:
|
||||
print(f"[graphify] backed up {reason} graph ({copied} files) -> {backup_dir.name}/")
|
||||
return backup_dir
|
||||
except Exception as exc:
|
||||
import sys
|
||||
print(f"[graphify] warning: backup failed ({exc}) - continuing with overwrite", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def _obsidian_tag(name: str) -> str:
|
||||
"""Sanitize a community name for use as an Obsidian tag.
|
||||
|
||||
Obsidian tags only allow alphanumerics, hyphens, underscores, and slashes.
|
||||
Spaces become underscores; everything else is stripped.
|
||||
"""
|
||||
return re.sub(r"[^a-zA-Z0-9_\-/]", "", name.replace(" ", "_"))
|
||||
|
||||
|
||||
def _strip_diacritics(text: str | None) -> str:
|
||||
import unicodedata
|
||||
if not isinstance(text, str):
|
||||
text = "" if text is None else str(text)
|
||||
nfkd = unicodedata.normalize("NFKD", text)
|
||||
return "".join(c for c in nfkd if not unicodedata.combining(c))
|
||||
|
||||
|
||||
def _yaml_str(s: str) -> str:
|
||||
"""Escape a value for safe embedding in a YAML double-quoted scalar (F-009).
|
||||
|
||||
See `graphify.ingest._yaml_str` for the full rationale; duplicated here to
|
||||
avoid pulling the URL-fetching `ingest` module into export's dependency
|
||||
graph. Handles backslash, double-quote, all line breaks (\\n, \\r,
|
||||
U+2028, U+2029), tab, NUL, and other C0/DEL control characters that
|
||||
would otherwise let a hostile `source_file` / `community` / etc. break
|
||||
out of the YAML scalar and inject sibling keys.
|
||||
"""
|
||||
if s is None:
|
||||
return ""
|
||||
out: list[str] = []
|
||||
for ch in str(s):
|
||||
cp = ord(ch)
|
||||
if ch == "\\":
|
||||
out.append("\\\\")
|
||||
elif ch == '"':
|
||||
out.append('\\"')
|
||||
elif ch == "\n":
|
||||
out.append("\\n")
|
||||
elif ch == "\r":
|
||||
out.append("\\r")
|
||||
elif ch == "\t":
|
||||
out.append("\\t")
|
||||
elif ch == "\0":
|
||||
out.append("\\0")
|
||||
elif cp == 0x2028:
|
||||
out.append("\\L")
|
||||
elif cp == 0x2029:
|
||||
out.append("\\P")
|
||||
elif cp < 0x20 or cp == 0x7F:
|
||||
out.append(f"\\x{cp:02x}")
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
from graphify.exporters.base import COMMUNITY_COLORS # noqa: E402,F401
|
||||
|
||||
from graphify.exporters.html import to_html # noqa: E402,F401
|
||||
|
||||
|
||||
_CONFIDENCE_SCORE_DEFAULTS = {"EXTRACTED": 1.0, "INFERRED": 0.5, "AMBIGUOUS": 0.2}
|
||||
|
||||
|
||||
def attach_hyperedges(G: nx.Graph, hyperedges: list) -> None:
|
||||
"""Store hyperedges in the graph's metadata dict."""
|
||||
existing = G.graph.get("hyperedges", [])
|
||||
seen_ids = {h["id"] for h in existing}
|
||||
for h in hyperedges:
|
||||
if h.get("id") and h["id"] not in seen_ids:
|
||||
existing.append(h)
|
||||
seen_ids.add(h["id"])
|
||||
G.graph["hyperedges"] = existing
|
||||
|
||||
|
||||
def _git_head() -> str | None:
|
||||
"""Return the current git HEAD commit hash, or None if not in a git repo."""
|
||||
import subprocess as _sp
|
||||
try:
|
||||
r = _sp.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=3)
|
||||
return r.stdout.strip() if r.returncode == 0 else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def to_json(G: nx.Graph, communities: dict[int, list[str]], output_path: str, *, force: bool = False, built_at_commit: str | None = None, community_labels: dict[int, str] | None = None) -> bool:
|
||||
# Safety check: refuse to silently shrink an existing graph (#479)
|
||||
existing_path = Path(output_path)
|
||||
if not force and existing_path.exists():
|
||||
from graphify.security import check_graph_file_size_cap
|
||||
try:
|
||||
check_graph_file_size_cap(existing_path)
|
||||
except Exception:
|
||||
# Existing graph.json trips the size cap; reading it to compare would
|
||||
# be the very DoS the cap guards against. Can't verify — let the new
|
||||
# graph replace the oversized file.
|
||||
oversized = True
|
||||
else:
|
||||
oversized = False
|
||||
if not oversized:
|
||||
try:
|
||||
raw = existing_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
raw = ""
|
||||
if not raw.strip():
|
||||
# Empty/whitespace existing file (e.g. a freshly touched path):
|
||||
# no nodes to lose, so any new graph is a growth — proceed.
|
||||
existing_n = 0
|
||||
else:
|
||||
try:
|
||||
existing_data = json.loads(raw)
|
||||
existing_n = len(existing_data.get("nodes", []))
|
||||
except Exception as exc:
|
||||
# Non-empty but unparseable existing graph (corrupt or a
|
||||
# mid-write): we cannot verify the new graph is not a silent
|
||||
# shrink. Fail SAFE — refuse rather than overwrite. A
|
||||
# fail-OPEN here (the prior behavior) is the silent data-loss
|
||||
# path #479 exists to prevent: a transiently unreadable
|
||||
# graph.json would let a partial rebuild clobber a good one.
|
||||
import sys as _sys
|
||||
print(
|
||||
f"[graphify] WARNING: existing {existing_path} could not be "
|
||||
f"read to verify the new graph is not smaller ({exc}). "
|
||||
f"Refusing to overwrite; pass force=True to override.",
|
||||
file=_sys.stderr,
|
||||
)
|
||||
return False
|
||||
new_n = G.number_of_nodes()
|
||||
if new_n < existing_n:
|
||||
import sys as _sys
|
||||
print(
|
||||
f"[graphify] WARNING: new graph has {new_n} nodes but existing "
|
||||
f"graph.json has {existing_n} (net -{existing_n - new_n}). "
|
||||
f"Refusing to overwrite. Possible causes: missing chunk files from "
|
||||
f"a previous session, or fuzzy dedup collapsed same-named symbols "
|
||||
f"across files during an --update on an already-current graph. "
|
||||
f"Run a full rebuild (/graphify .) to be safe, or pass force=True "
|
||||
f"only if you have verified the reduction is legitimate.",
|
||||
file=_sys.stderr,
|
||||
)
|
||||
return False
|
||||
|
||||
node_community = _node_community_map(communities)
|
||||
_labels: dict[int, str] = {int(k): v for k, v in (community_labels or {}).items()}
|
||||
try:
|
||||
data = json_graph.node_link_data(G, edges="links")
|
||||
except TypeError:
|
||||
data = json_graph.node_link_data(G)
|
||||
for node in data["nodes"]:
|
||||
cid = node_community.get(node["id"])
|
||||
node["community"] = cid
|
||||
if cid is not None and _labels:
|
||||
node["community_name"] = _labels.get(cid, f"Community {cid}")
|
||||
node["norm_label"] = _strip_diacritics(node.get("label", "")).lower()
|
||||
for link in data["links"]:
|
||||
if "confidence_score" not in link:
|
||||
conf = link.get("confidence", "EXTRACTED")
|
||||
link["confidence_score"] = _CONFIDENCE_SCORE_DEFAULTS.get(conf, 1.0)
|
||||
# Restore original edge direction. Undirected NetworkX storage may
|
||||
# canonicalize endpoint order, flipping `calls` and other directional
|
||||
# edges in graph.json. The build path stashes the true endpoints in
|
||||
# _src/_tgt for exactly this purpose (#563).
|
||||
true_src = link.pop("_src", None)
|
||||
true_tgt = link.pop("_tgt", None)
|
||||
if true_src is not None and true_tgt is not None:
|
||||
link["source"] = true_src
|
||||
link["target"] = true_tgt
|
||||
data["hyperedges"] = getattr(G, "graph", {}).get("hyperedges", [])
|
||||
commit = built_at_commit if built_at_commit is not None else _git_head()
|
||||
if commit:
|
||||
data["built_at_commit"] = commit
|
||||
with open(output_path, "w", encoding="utf-8") as f: # nosec
|
||||
json.dump(data, f, indent=2)
|
||||
return True
|
||||
|
||||
|
||||
def prune_dangling_edges(graph_data: dict) -> tuple[dict, int]:
|
||||
"""Remove edges whose source or target node is not in the node set.
|
||||
|
||||
Returns the cleaned graph_data dict and the number of pruned edges.
|
||||
"""
|
||||
node_ids = {n["id"] for n in graph_data["nodes"]}
|
||||
links_key = "links" if "links" in graph_data else "edges"
|
||||
before = len(graph_data[links_key])
|
||||
graph_data[links_key] = [
|
||||
e for e in graph_data[links_key]
|
||||
if e["source"] in node_ids and e["target"] in node_ids
|
||||
]
|
||||
return graph_data, before - len(graph_data[links_key])
|
||||
|
||||
|
||||
def _cypher_escape(s: str) -> str:
|
||||
"""Escape a string for safe embedding in a Cypher single-quoted literal.
|
||||
|
||||
Handles all characters that could prematurely terminate the literal or
|
||||
inject control sequences:
|
||||
- `\\` and `'` (literal terminators)
|
||||
- newlines/CRs (would break the per-line statement framing)
|
||||
- NUL/control bytes (defensive — Neo4j errors on raw NULs)
|
||||
|
||||
Also strips any leading/trailing whitespace that would let an attacker
|
||||
break the `;`-terminated statement boundary used by `cypher-shell`.
|
||||
Closing `}` and `)` are NOT special inside a single-quoted Cypher string,
|
||||
so escaping the quote and backslash correctly is sufficient (a `}` inside
|
||||
a properly-closed `'...'` literal is just a character) — but we previously
|
||||
missed `\\n` / `\\r` which DO let a payload break out of the statement
|
||||
line and inject a fresh MATCH/DELETE on the following line. See F-008.
|
||||
"""
|
||||
# First normalise: drop NUL and other C0 control chars except tab.
|
||||
s = "".join(ch for ch in s if ch >= " " or ch == "\t")
|
||||
return (
|
||||
s.replace("\\", "\\\\")
|
||||
.replace("'", "\\'")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
)
|
||||
|
||||
|
||||
# Restrict identifier-position values (labels and relationship types are NOT
|
||||
# quoted in Cypher and so cannot be safely escaped — they must be allowlisted).
|
||||
_CYPHER_IDENT_RE = re.compile(r"[^A-Za-z0-9_]")
|
||||
|
||||
|
||||
def _cypher_label(raw: str, fallback: str) -> str:
|
||||
"""Sanitise a value used in identifier position (node label / rel type).
|
||||
|
||||
Cypher does not provide a way to escape `:Foo` label syntax, so we must
|
||||
strip everything except `[A-Za-z0-9_]` and require the result to start
|
||||
with a letter; otherwise we fall back to a safe constant.
|
||||
"""
|
||||
cleaned = _CYPHER_IDENT_RE.sub("", raw or "")
|
||||
if not cleaned or not cleaned[0].isalpha():
|
||||
return fallback
|
||||
return cleaned
|
||||
|
||||
|
||||
def to_cypher(G: nx.Graph, output_path: str) -> None:
|
||||
lines = ["// Neo4j Cypher import - generated by /graphify", ""]
|
||||
for node_id, data in G.nodes(data=True):
|
||||
label = _cypher_escape(data.get("label", node_id))
|
||||
node_id_esc = _cypher_escape(node_id)
|
||||
ftype = _cypher_label(
|
||||
(data.get("file_type", "unknown") or "unknown").capitalize(),
|
||||
"Entity",
|
||||
)
|
||||
lines.append(f"MERGE (n:{ftype} {{id: '{node_id_esc}', label: '{label}'}});")
|
||||
lines.append("")
|
||||
for u, v, data in G.edges(data=True):
|
||||
rel = _cypher_label(
|
||||
(data.get("relation", "RELATES_TO") or "RELATES_TO").upper(),
|
||||
"RELATES_TO",
|
||||
)
|
||||
conf = _cypher_escape(data.get("confidence", "EXTRACTED"))
|
||||
u_esc = _cypher_escape(u)
|
||||
v_esc = _cypher_escape(v)
|
||||
lines.append(
|
||||
f"MATCH (a {{id: '{u_esc}'}}), (b {{id: '{v_esc}'}}) "
|
||||
f"MERGE (a)-[:{rel} {{confidence: '{conf}'}}]->(b);"
|
||||
)
|
||||
with open(output_path, "w", encoding="utf-8") as f: # nosec
|
||||
f.write("\n".join(lines))
|
||||
|
||||
|
||||
# Keep backward-compatible alias - skill.md calls generate_html
|
||||
generate_html = to_html
|
||||
|
||||
|
||||
def _cap_filename(s: str, limit: int = 200) -> str:
|
||||
"""Cap a filename stem to ``limit`` UTF-8 bytes so it stays under the 255-byte
|
||||
filesystem limit even after the ``.md`` extension and dedup suffix are added
|
||||
(#1094). The cap is on BYTES, not chars, because a label of multibyte
|
||||
characters (CJK, accented) can exceed 255 bytes well under 255 chars. When
|
||||
truncation happens, an 8-char hash of the full label is appended so two
|
||||
distinct labels sharing a long prefix produce distinct, deterministic
|
||||
filenames instead of colliding."""
|
||||
b = s.encode("utf-8")
|
||||
if len(b) <= limit:
|
||||
return s
|
||||
digest = hashlib.sha1(s.encode("utf-8")).hexdigest()[:8] # nosec - not security
|
||||
keep = limit - 9 # "_" + 8 hex chars
|
||||
truncated = b[:keep].decode("utf-8", "ignore") # "ignore" drops a split trailing char
|
||||
return f"{truncated}_{digest}"
|
||||
|
||||
|
||||
def _dedup_node_filenames(G: nx.Graph, safe_name) -> dict[str, str]:
|
||||
"""Map each node_id to a unique note filename, appending a numeric suffix on
|
||||
collision. The collision set is keyed on the lowercased name so two labels
|
||||
differing only by case (e.g. "References" vs "references") still get distinct
|
||||
filenames - on case-insensitive filesystems (macOS/APFS, Windows/NTFS) they
|
||||
would otherwise resolve to one path and silently overwrite each other on disk.
|
||||
The suffixed candidate is itself re-checked, so a generated "base_1" never
|
||||
silently overwrites a node whose literal label is already "base_1"."""
|
||||
node_filenames: dict[str, str] = {}
|
||||
used: set[str] = set()
|
||||
for node_id, data in G.nodes(data=True):
|
||||
base = safe_name(data.get("label", node_id))
|
||||
candidate = base
|
||||
n = 1
|
||||
while candidate.lower() in used:
|
||||
candidate = f"{base}_{n}"
|
||||
n += 1
|
||||
used.add(candidate.lower())
|
||||
node_filenames[node_id] = candidate
|
||||
return node_filenames
|
||||
|
||||
|
||||
def to_obsidian(
|
||||
G: nx.Graph,
|
||||
communities: dict[int, list[str]],
|
||||
output_dir: str,
|
||||
community_labels: dict[int, str] | None = None,
|
||||
cohesion: dict[int, float] | None = None,
|
||||
) -> int:
|
||||
"""Export graph as an Obsidian vault - one .md file per node with [[wikilinks]],
|
||||
plus one _COMMUNITY_name.md overview note per community (sorted to top by underscore prefix).
|
||||
|
||||
Open the output directory as a vault in Obsidian to get an interactive
|
||||
graph view with community colors and full-text search over node metadata.
|
||||
|
||||
Returns the number of node notes + community notes written.
|
||||
"""
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# #1506: when the export target is an existing Obsidian vault (a user pointed
|
||||
# --obsidian-dir at one), we must not clobber the user's own notes or their
|
||||
# .obsidian/ config. Track the files graphify owns in a manifest; a pre-existing
|
||||
# file NOT in the manifest is the user's and is never overwritten.
|
||||
_manifest_path = out / ".graphify_obsidian_manifest.json"
|
||||
try:
|
||||
_owned: set[str] = set(json.loads(_manifest_path.read_text(encoding="utf-8")).get("files", []))
|
||||
except (OSError, ValueError):
|
||||
_owned = set()
|
||||
_written: list[str] = []
|
||||
_skipped: list[str] = []
|
||||
|
||||
def _owned_write(rel_name: str, content: str) -> bool:
|
||||
"""Write a graphify-owned file, refusing to overwrite a pre-existing file
|
||||
graphify didn't create. Returns True if written."""
|
||||
target = out / rel_name
|
||||
if target.exists() and rel_name not in _owned:
|
||||
_skipped.append(rel_name)
|
||||
return False
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8") # nosec
|
||||
_written.append(rel_name)
|
||||
return True
|
||||
|
||||
node_community = _node_community_map(communities)
|
||||
|
||||
# Map node_id → safe filename so wikilinks stay consistent.
|
||||
# Deduplicate: if two nodes produce the same filename, append a numeric suffix.
|
||||
def safe_name(label: str) -> str:
|
||||
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
|
||||
# Strip trailing .md/.mdx/.markdown so "CLAUDE.md" doesn't become "CLAUDE.md.md"
|
||||
cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
|
||||
# A stem of only punctuation (e.g. "@", "*", "#") survives the unsafe-char
|
||||
# strip above but is empty once a downstream tool re-slugs on word chars
|
||||
# (e.g. qmd's handelize() reduces "@" -> "" and raises, aborting the whole
|
||||
# `qmd update`). Require at least one word char; else fall back so we never
|
||||
# emit a "@.md"-style filename. (#1409)
|
||||
if not re.search(r"\w", cleaned, flags=re.UNICODE):
|
||||
return "unnamed"
|
||||
return _cap_filename(cleaned)
|
||||
|
||||
node_filename = _dedup_node_filenames(G, safe_name)
|
||||
|
||||
# Helper: compute dominant confidence for a node across all its edges
|
||||
def _dominant_confidence(node_id: str) -> str:
|
||||
confs = []
|
||||
for u, v, edata in G.edges(node_id, data=True):
|
||||
confs.append(edata.get("confidence", "EXTRACTED"))
|
||||
if not confs:
|
||||
return "EXTRACTED"
|
||||
return Counter(confs).most_common(1)[0][0]
|
||||
|
||||
# Map file_type → graphify tag
|
||||
_FTYPE_TAG = {
|
||||
"code": "graphify/code",
|
||||
"document": "graphify/document",
|
||||
"paper": "graphify/paper",
|
||||
"image": "graphify/image",
|
||||
}
|
||||
|
||||
# Write one .md file per node
|
||||
node_notes_written = 0
|
||||
for node_id, data in G.nodes(data=True):
|
||||
label = data.get("label", node_id)
|
||||
cid = node_community.get(node_id)
|
||||
community_name = (
|
||||
community_labels.get(cid, f"Community {cid}")
|
||||
if community_labels and cid is not None
|
||||
else f"Community {cid}"
|
||||
)
|
||||
|
||||
# Build tags for this node
|
||||
ftype = data.get("file_type", "")
|
||||
ftype_tag = _FTYPE_TAG.get(ftype, f"graphify/{ftype}" if ftype else "graphify/document")
|
||||
dom_conf = _dominant_confidence(node_id)
|
||||
conf_tag = f"graphify/{dom_conf}"
|
||||
comm_tag = f"community/{_obsidian_tag(community_name)}"
|
||||
node_tags = [ftype_tag, conf_tag, comm_tag]
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
# YAML frontmatter - readable in Obsidian's properties panel.
|
||||
# All scalars pass through _yaml_str so a hostile source_file or
|
||||
# community label cannot break out and inject sibling keys (F-009).
|
||||
lines += [
|
||||
"---",
|
||||
f'source_file: "{_yaml_str(data.get("source_file", ""))}"',
|
||||
f'type: "{_yaml_str(ftype)}"',
|
||||
f'community: "{_yaml_str(community_name)}"',
|
||||
]
|
||||
if data.get("source_location"):
|
||||
lines.append(f'location: "{_yaml_str(str(data["source_location"]))}"')
|
||||
# Add tags list to frontmatter
|
||||
lines.append("tags:")
|
||||
for tag in node_tags:
|
||||
lines.append(f" - {tag}")
|
||||
lines += ["---", "", f"# {label}", ""]
|
||||
|
||||
# Outgoing edges as wikilinks
|
||||
neighbors = list(G.neighbors(node_id))
|
||||
if neighbors:
|
||||
lines.append("## Connections")
|
||||
for neighbor in sorted(neighbors, key=lambda n: G.nodes[n].get("label", n)):
|
||||
edata = edge_data(G, node_id, neighbor)
|
||||
neighbor_label = node_filename[neighbor]
|
||||
relation = edata.get("relation", "")
|
||||
confidence = edata.get("confidence", "EXTRACTED")
|
||||
lines.append(f"- [[{neighbor_label}]] - `{relation}` [{confidence}]")
|
||||
lines.append("")
|
||||
|
||||
# Inline tags at bottom of note body (for Obsidian tag panel)
|
||||
inline_tags = " ".join(f"#{t}" for t in node_tags)
|
||||
lines.append(inline_tags)
|
||||
|
||||
fname = node_filename[node_id] + ".md"
|
||||
if _owned_write(fname, "\n".join(lines)):
|
||||
node_notes_written += 1
|
||||
|
||||
# Write one _COMMUNITY_name.md overview note per community
|
||||
# Build inter-community edge counts for "Connections to other communities"
|
||||
inter_community_edges: dict[int, dict[int, int]] = {}
|
||||
for cid in communities:
|
||||
inter_community_edges[cid] = {}
|
||||
for u, v in G.edges():
|
||||
cu = node_community.get(u)
|
||||
cv = node_community.get(v)
|
||||
if cu is not None and cv is not None and cu != cv:
|
||||
inter_community_edges.setdefault(cu, {})
|
||||
inter_community_edges.setdefault(cv, {})
|
||||
inter_community_edges[cu][cv] = inter_community_edges[cu].get(cv, 0) + 1
|
||||
inter_community_edges[cv][cu] = inter_community_edges[cv].get(cu, 0) + 1
|
||||
|
||||
# Precompute per-node community reach (number of distinct communities a node connects to)
|
||||
def _community_reach(node_id: str) -> int:
|
||||
neighbor_cids = {
|
||||
node_community[nb]
|
||||
for nb in G.neighbors(node_id)
|
||||
if nb in node_community and node_community[nb] != node_community.get(node_id)
|
||||
}
|
||||
return len(neighbor_cids)
|
||||
|
||||
def _community_name(cid) -> str:
|
||||
return (
|
||||
community_labels.get(cid, f"Community {cid}")
|
||||
if community_labels and cid is not None
|
||||
else f"Community {cid}"
|
||||
)
|
||||
|
||||
# One case-folded-deduped filename per community, computed once so the note we
|
||||
# write and every [[_COMMUNITY_...]] cross-reference resolve to the same file.
|
||||
# Two community labels differing only by case (e.g. LLM labels "API" vs "Api")
|
||||
# would otherwise overwrite each other on case-insensitive filesystems - and
|
||||
# this path had no dedup at all, so even same-case duplicate labels collided.
|
||||
community_filename: dict = {}
|
||||
used_community: set[str] = set()
|
||||
for cid in communities:
|
||||
base = f"_COMMUNITY_{safe_name(_community_name(cid))}"
|
||||
candidate = base
|
||||
n = 1
|
||||
while candidate.lower() in used_community:
|
||||
candidate = f"{base}_{n}"
|
||||
n += 1
|
||||
used_community.add(candidate.lower())
|
||||
community_filename[cid] = candidate
|
||||
|
||||
community_notes_written = 0
|
||||
for cid, all_members in communities.items():
|
||||
community_name = _community_name(cid)
|
||||
# A community's member list can contain ids with no backing node in G
|
||||
# (e.g. pruned nodes, stale community assignments from a prior run, or
|
||||
# synthesized/merge-artifact ids). Dereferencing those via G.nodes[n] or
|
||||
# node_filename[n] raises KeyError and aborts the whole vault export, so
|
||||
# skip dangling members rather than crashing (issue #1236).
|
||||
members = [m for m in all_members if m in G and m in node_filename]
|
||||
n_members = len(members)
|
||||
coh_value = cohesion.get(cid) if cohesion else None
|
||||
|
||||
lines: list[str] = []
|
||||
|
||||
# YAML frontmatter
|
||||
lines.append("---")
|
||||
lines.append("type: community")
|
||||
if coh_value is not None:
|
||||
lines.append(f"cohesion: {coh_value:.2f}")
|
||||
lines.append(f"members: {n_members}")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append(f"# {community_name}")
|
||||
lines.append("")
|
||||
|
||||
# Cohesion + member count summary
|
||||
if coh_value is not None:
|
||||
cohesion_desc = (
|
||||
"tightly connected" if coh_value >= 0.7
|
||||
else "moderately connected" if coh_value >= 0.4
|
||||
else "loosely connected"
|
||||
)
|
||||
lines.append(f"**Cohesion:** {coh_value:.2f} - {cohesion_desc}")
|
||||
lines.append(f"**Members:** {n_members} nodes")
|
||||
lines.append("")
|
||||
|
||||
# Members section
|
||||
lines.append("## Members")
|
||||
for node_id in sorted(members, key=lambda n: G.nodes[n].get("label", n)):
|
||||
data = G.nodes[node_id]
|
||||
node_label = node_filename[node_id]
|
||||
ftype = data.get("file_type", "")
|
||||
source = data.get("source_file", "")
|
||||
entry = f"- [[{node_label}]]"
|
||||
if ftype:
|
||||
entry += f" - {ftype}"
|
||||
if source:
|
||||
entry += f" - {source}"
|
||||
lines.append(entry)
|
||||
lines.append("")
|
||||
|
||||
# Dataview live query (improvement 2)
|
||||
comm_tag_name = _obsidian_tag(community_name)
|
||||
lines.append("## Live Query (requires Dataview plugin)")
|
||||
lines.append("")
|
||||
lines.append("```dataview")
|
||||
lines.append(f"TABLE source_file, type FROM #community/{comm_tag_name}")
|
||||
lines.append("SORT file.name ASC")
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
# Connections to other communities
|
||||
cross = inter_community_edges.get(cid, {})
|
||||
if cross:
|
||||
lines.append("## Connections to other communities")
|
||||
for other_cid, edge_count in sorted(cross.items(), key=lambda x: -x[1]):
|
||||
other_fname = community_filename.get(other_cid) or f"_COMMUNITY_{safe_name(_community_name(other_cid))}"
|
||||
lines.append(f"- {edge_count} edge{'s' if edge_count != 1 else ''} to [[{other_fname}]]")
|
||||
lines.append("")
|
||||
|
||||
# Top bridge nodes - highest degree nodes that connect to other communities
|
||||
bridge_nodes = [
|
||||
(node_id, G.degree(node_id), _community_reach(node_id))
|
||||
for node_id in members
|
||||
if _community_reach(node_id) > 0
|
||||
]
|
||||
bridge_nodes.sort(key=lambda x: (-x[2], -x[1]))
|
||||
top_bridges = bridge_nodes[:5]
|
||||
if top_bridges:
|
||||
lines.append("## Top bridge nodes")
|
||||
for node_id, degree, reach in top_bridges:
|
||||
node_label = node_filename[node_id]
|
||||
lines.append(
|
||||
f"- [[{node_label}]] - degree {degree}, connects to {reach} "
|
||||
f"{'community' if reach == 1 else 'communities'}"
|
||||
)
|
||||
|
||||
fname = community_filename[cid] + ".md"
|
||||
if _owned_write(fname, "\n".join(lines)):
|
||||
community_notes_written += 1
|
||||
|
||||
# Improvement 4: write .obsidian/graph.json to color nodes by community in graph
|
||||
# view — but never clobber an existing .obsidian/graph.json graphify doesn't own
|
||||
# (the user's graph-view settings live there). _owned_write handles that and
|
||||
# creates the .obsidian/ dir only when it actually writes.
|
||||
graph_config = {
|
||||
"colorGroups": [
|
||||
{
|
||||
"query": f"tag:#community/{label.replace(' ', '_')}",
|
||||
"color": {"a": 1, "rgb": int(COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)].lstrip('#'), 16)}
|
||||
}
|
||||
for cid, label in sorted((community_labels or {}).items())
|
||||
]
|
||||
}
|
||||
_owned_write(".obsidian/graph.json", json.dumps(graph_config, indent=2))
|
||||
|
||||
# Persist the manifest of files graphify owns, so a re-run can safely update its
|
||||
# own notes while still refusing to touch the user's. Warn (once, aggregated)
|
||||
# about anything skipped to avoid clobbering a pre-existing file.
|
||||
try:
|
||||
_manifest_path.write_text(json.dumps({"files": sorted(set(_written))}, indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
if _skipped:
|
||||
shown = ", ".join(_skipped[:5]) + (f" (+{len(_skipped) - 5} more)" if len(_skipped) > 5 else "")
|
||||
print(
|
||||
f"[graphify] WARNING: skipped {len(_skipped)} pre-existing file(s) graphify "
|
||||
f"did not create, to avoid overwriting your notes: {shown}. "
|
||||
f"Export into an empty directory (or the default graphify-out/obsidian) "
|
||||
f"to get the full vault.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
return node_notes_written + community_notes_written
|
||||
|
||||
|
||||
def to_canvas(
|
||||
G: nx.Graph,
|
||||
communities: dict[int, list[str]],
|
||||
output_path: str,
|
||||
community_labels: dict[int, str] | None = None,
|
||||
node_filenames: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
"""Export graph as an Obsidian Canvas file - communities as groups, nodes as cards.
|
||||
|
||||
Generates a structured layout: communities arranged in a grid, nodes within
|
||||
each community arranged in rows. Edges shown between connected nodes.
|
||||
Opens in Obsidian as an infinite canvas with community groupings visible.
|
||||
"""
|
||||
# Obsidian canvas color codes (cycle through for communities)
|
||||
CANVAS_COLORS = ["1", "2", "3", "4", "5", "6"] # red, orange, yellow, green, cyan, purple
|
||||
|
||||
def safe_name(label: str) -> str:
|
||||
cleaned = re.sub(r'[\\/*?:"<>|#^[\]]', "", label.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")).strip()
|
||||
cleaned = re.sub(r"\.(md|mdx|qmd|markdown)$", "", cleaned, flags=re.IGNORECASE)
|
||||
# A stem of only punctuation (e.g. "@", "*", "#") survives the unsafe-char
|
||||
# strip above but is empty once a downstream tool re-slugs on word chars
|
||||
# (e.g. qmd's handelize() reduces "@" -> "" and raises, aborting the whole
|
||||
# `qmd update`). Require at least one word char; else fall back so we never
|
||||
# emit a "@.md"-style filename. (#1409)
|
||||
if not re.search(r"\w", cleaned, flags=re.UNICODE):
|
||||
return "unnamed"
|
||||
return _cap_filename(cleaned)
|
||||
|
||||
# Build node_filenames if not provided (same dedup logic as to_obsidian)
|
||||
if node_filenames is None:
|
||||
node_filenames = _dedup_node_filenames(G, safe_name)
|
||||
|
||||
# Fallback: with no community data (e.g. --no-cluster builds or a missing
|
||||
# analysis sidecar) the grid below produces nothing and the canvas is written
|
||||
# as an empty 32-byte shell on an otherwise populated graph. Emit every node
|
||||
# into one synthetic community so the canvas always reflects the graph (#1324).
|
||||
if not communities and G.number_of_nodes() > 0:
|
||||
communities = {0: [str(n) for n in G.nodes()]}
|
||||
|
||||
num_communities = len(communities)
|
||||
cols = math.ceil(math.sqrt(num_communities)) if num_communities > 0 else 1
|
||||
rows = math.ceil(num_communities / cols) if num_communities > 0 else 1
|
||||
|
||||
canvas_nodes: list[dict] = []
|
||||
canvas_edges: list[dict] = []
|
||||
|
||||
# Lay out communities in a grid
|
||||
gap = 80
|
||||
group_x_offsets: list[int] = []
|
||||
group_y_offsets: list[int] = []
|
||||
|
||||
# Precompute group sizes so we can calculate offsets.
|
||||
# inner_cols is the per-community grid width; the box dimensions AND the node
|
||||
# placement loop below both derive from it, so the cards always fill the box
|
||||
# instead of wrapping into a narrow strip inside an oversized box.
|
||||
sorted_cids = sorted(communities.keys())
|
||||
group_sizes: dict[int, tuple[int, int]] = {}
|
||||
group_cols: dict[int, int] = {}
|
||||
for cid in sorted_cids:
|
||||
# Skip dangling community members with no backing node / filename, so box
|
||||
# sizing matches the cards actually laid out and `G.nodes[m]` never
|
||||
# KeyErrors below — mirrors the to_obsidian guard (#1236).
|
||||
members = [m for m in communities[cid] if m in G and m in node_filenames]
|
||||
n = len(members)
|
||||
inner_cols = max(1, math.ceil(math.sqrt(n)))
|
||||
w = max(600, 220 * inner_cols)
|
||||
h = max(400, 100 * math.ceil(n / inner_cols) + 120)
|
||||
group_sizes[cid] = (w, h)
|
||||
group_cols[cid] = inner_cols
|
||||
|
||||
# Compute cumulative row heights and col widths for grid placement
|
||||
# Each grid cell uses the max width/height in its col/row
|
||||
col_widths: list[int] = []
|
||||
row_heights: list[int] = []
|
||||
for col_idx in range(cols):
|
||||
max_w = 0
|
||||
for row_idx in range(rows):
|
||||
linear = row_idx * cols + col_idx
|
||||
if linear < len(sorted_cids):
|
||||
cid = sorted_cids[linear]
|
||||
w, _ = group_sizes[cid]
|
||||
max_w = max(max_w, w)
|
||||
col_widths.append(max_w)
|
||||
|
||||
for row_idx in range(rows):
|
||||
max_h = 0
|
||||
for col_idx in range(cols):
|
||||
linear = row_idx * cols + col_idx
|
||||
if linear < len(sorted_cids):
|
||||
cid = sorted_cids[linear]
|
||||
_, h = group_sizes[cid]
|
||||
max_h = max(max_h, h)
|
||||
row_heights.append(max_h)
|
||||
|
||||
# Map from cid → (group_x, group_y, group_w, group_h)
|
||||
group_layout: dict[int, tuple[int, int, int, int]] = {}
|
||||
for idx, cid in enumerate(sorted_cids):
|
||||
col_idx = idx % cols
|
||||
row_idx = idx // cols
|
||||
gx = sum(col_widths[:col_idx]) + col_idx * gap
|
||||
gy = sum(row_heights[:row_idx]) + row_idx * gap
|
||||
gw, gh = group_sizes[cid]
|
||||
group_layout[cid] = (gx, gy, gw, gh)
|
||||
|
||||
# Build set of all node_ids in canvas for edge filtering
|
||||
all_canvas_nodes: set[str] = set()
|
||||
for members in communities.values():
|
||||
all_canvas_nodes.update(members)
|
||||
|
||||
# Generate group and node canvas entries
|
||||
for idx, cid in enumerate(sorted_cids):
|
||||
members = communities[cid]
|
||||
community_name = (
|
||||
community_labels.get(cid, f"Community {cid}")
|
||||
if community_labels and cid is not None
|
||||
else f"Community {cid}"
|
||||
)
|
||||
gx, gy, gw, gh = group_layout[cid]
|
||||
canvas_color = CANVAS_COLORS[idx % len(CANVAS_COLORS)]
|
||||
|
||||
# Group node
|
||||
canvas_nodes.append({
|
||||
"id": f"g{cid}",
|
||||
"type": "group",
|
||||
"label": community_name,
|
||||
"x": gx,
|
||||
"y": gy,
|
||||
"width": gw,
|
||||
"height": gh,
|
||||
"color": canvas_color,
|
||||
})
|
||||
|
||||
# Node cards inside the group - laid out in the same ceil(sqrt(n))-column
|
||||
# grid the box was sized for (group_cols[cid]), so cards fill the box.
|
||||
inner_cols = group_cols[cid]
|
||||
# Same dangling-member guard as the sizing loop and to_obsidian (#1236):
|
||||
# a community id absent from G / node_filenames would KeyError the sort.
|
||||
members = [m for m in members if m in G and m in node_filenames]
|
||||
sorted_members = sorted(members, key=lambda n: G.nodes[n].get("label", n))
|
||||
for m_idx, node_id in enumerate(sorted_members):
|
||||
col = m_idx % inner_cols
|
||||
row = m_idx // inner_cols
|
||||
nx_x = gx + 20 + col * (180 + 20)
|
||||
nx_y = gy + 80 + row * (60 + 20)
|
||||
fname = node_filenames.get(node_id, safe_name(G.nodes[node_id].get("label", node_id)))
|
||||
canvas_nodes.append({
|
||||
"id": f"n_{node_id}",
|
||||
"type": "file",
|
||||
"file": f"{fname}.md",
|
||||
"x": nx_x,
|
||||
"y": nx_y,
|
||||
"width": 180,
|
||||
"height": 60,
|
||||
})
|
||||
|
||||
# Generate edges - only between nodes both in canvas, cap at 200 highest-weight
|
||||
all_edges_weighted: list[tuple[float, str, str, str]] = []
|
||||
for u, v, edata in G.edges(data=True):
|
||||
if u in all_canvas_nodes and v in all_canvas_nodes:
|
||||
weight = edata.get("weight", 1.0)
|
||||
relation = edata.get("relation", "")
|
||||
conf = edata.get("confidence", "EXTRACTED")
|
||||
label = f"{relation} [{conf}]" if relation else f"[{conf}]"
|
||||
all_edges_weighted.append((weight, u, v, label))
|
||||
|
||||
all_edges_weighted.sort(key=lambda x: -x[0])
|
||||
for weight, u, v, label in all_edges_weighted[:200]:
|
||||
canvas_edges.append({
|
||||
"id": f"e_{u}_{v}",
|
||||
"fromNode": f"n_{u}",
|
||||
"toNode": f"n_{v}",
|
||||
"label": label,
|
||||
})
|
||||
|
||||
canvas_data = {"nodes": canvas_nodes, "edges": canvas_edges}
|
||||
Path(output_path).write_text(json.dumps(canvas_data, indent=2), encoding="utf-8") # nosec
|
||||
|
||||
|
||||
def to_graphml(
|
||||
G: nx.Graph,
|
||||
communities: dict[int, list[str]],
|
||||
output_path: str,
|
||||
) -> None:
|
||||
"""Export graph as GraphML - opens in Gephi, yEd, and any GraphML-compatible tool.
|
||||
|
||||
Community IDs are written as a node attribute so Gephi can colour by community.
|
||||
Edge confidence (EXTRACTED/INFERRED/AMBIGUOUS) is preserved as an edge attribute.
|
||||
"""
|
||||
H = G.copy()
|
||||
node_community = _node_community_map(communities)
|
||||
for node_id in H.nodes():
|
||||
H.nodes[node_id]["community"] = node_community.get(node_id, -1)
|
||||
# Drop internal markers (e.g. the AST-provenance "_origin" tag, #1116, and
|
||||
# the "_src"/"_tgt" direction markers) — they are persistence/runtime details,
|
||||
# not graph data, and should not leak into the exported file.
|
||||
for _, attrs in H.nodes(data=True):
|
||||
for k in [k for k in attrs if k.startswith("_")]:
|
||||
del attrs[k]
|
||||
for _, _, attrs in H.edges(data=True):
|
||||
for k in [k for k in attrs if k.startswith("_")]:
|
||||
del attrs[k]
|
||||
# nx.write_graphml raises ValueError on None attribute values; replace with "".
|
||||
for node_id in H.nodes():
|
||||
for key, val in list(H.nodes[node_id].items()):
|
||||
if val is None:
|
||||
H.nodes[node_id][key] = ""
|
||||
for u, v in H.edges():
|
||||
for key, val in list(H.edges[u, v].items()):
|
||||
if val is None:
|
||||
H.edges[u, v][key] = ""
|
||||
nx.write_graphml(H, output_path)
|
||||
|
||||
|
||||
def to_svg(
|
||||
G: nx.Graph,
|
||||
communities: dict[int, list[str]],
|
||||
output_path: str,
|
||||
community_labels: dict[int, str] | None = None,
|
||||
figsize: tuple[int, int] = (20, 14),
|
||||
) -> None:
|
||||
"""Export graph as an SVG file using matplotlib + spring layout.
|
||||
|
||||
Lightweight and embeddable - works in Obsidian notes, Notion, GitHub READMEs,
|
||||
and any markdown renderer. No JavaScript required.
|
||||
|
||||
Node size scales with degree. Community colors match the HTML output.
|
||||
"""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.patches as mpatches
|
||||
except ImportError as e:
|
||||
raise ImportError("matplotlib not installed. Run: pip install matplotlib") from e
|
||||
|
||||
node_community = _node_community_map(communities)
|
||||
|
||||
fig, ax = plt.subplots(figsize=figsize, facecolor="#1a1a2e")
|
||||
ax.set_facecolor("#1a1a2e")
|
||||
ax.axis("off")
|
||||
|
||||
pos = nx.spring_layout(G, seed=42, k=2.0 / (G.number_of_nodes() ** 0.5 + 1))
|
||||
|
||||
degree = dict(G.degree())
|
||||
max_deg = max(degree.values(), default=1) or 1
|
||||
|
||||
node_colors = [COMMUNITY_COLORS[node_community.get(n, 0) % len(COMMUNITY_COLORS)] for n in G.nodes()]
|
||||
node_sizes = [300 + 1200 * (degree.get(n, 1) / max_deg) for n in G.nodes()]
|
||||
|
||||
# Draw edges - dashed for non-EXTRACTED
|
||||
for u, v, data in G.edges(data=True):
|
||||
conf = data.get("confidence", "EXTRACTED")
|
||||
style = "solid" if conf == "EXTRACTED" else "dashed"
|
||||
alpha = 0.6 if conf == "EXTRACTED" else 0.3
|
||||
x0, y0 = pos[u]
|
||||
x1, y1 = pos[v]
|
||||
ax.plot([x0, x1], [y0, y1], color="#aaaaaa", linewidth=0.8,
|
||||
linestyle=style, alpha=alpha, zorder=1)
|
||||
|
||||
nx.draw_networkx_nodes(G, pos, ax=ax, node_color=node_colors,
|
||||
node_size=node_sizes, alpha=0.9)
|
||||
nx.draw_networkx_labels(G, pos, ax=ax,
|
||||
labels={n: G.nodes[n].get("label", n) for n in G.nodes()},
|
||||
font_size=7, font_color="white")
|
||||
|
||||
# Legend
|
||||
if community_labels:
|
||||
patches = [
|
||||
mpatches.Patch(
|
||||
color=COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)],
|
||||
label=f"{label} ({len(communities.get(cid, []))})",
|
||||
)
|
||||
for cid, label in sorted(community_labels.items())
|
||||
]
|
||||
ax.legend(handles=patches, loc="upper left", framealpha=0.7,
|
||||
facecolor="#2a2a4e", labelcolor="white", fontsize=8)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig(output_path, format="svg", bbox_inches="tight",
|
||||
facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
@@ -0,0 +1 @@
|
||||
"""exporters package."""
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Shared constants/helpers for the graphify exporters package.
|
||||
|
||||
Symbols used by more than one exporter live here so each exporter module can be
|
||||
split out of graphify/export.py without a circular import (export.py and the
|
||||
per-format modules both import from here, never from each other).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# Categorical palette for community coloring, shared by the HTML, SVG, and
|
||||
# Obsidian exporters. Moved verbatim from graphify/export.py.
|
||||
COMMUNITY_COLORS = [
|
||||
"#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
|
||||
"#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
|
||||
]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""graphdb — moved verbatim from graphify/export.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
from graphify.analyze import _node_community_map
|
||||
import networkx as nx
|
||||
import re
|
||||
|
||||
|
||||
def push_to_neo4j(
|
||||
G: nx.Graph,
|
||||
uri: str,
|
||||
user: str,
|
||||
password: str,
|
||||
communities: dict[int, list[str]] | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Push graph directly to a running Neo4j instance via the Python driver.
|
||||
|
||||
Requires: pip install neo4j
|
||||
|
||||
Uses MERGE so re-running is safe - nodes and edges are upserted, not duplicated.
|
||||
Returns a dict with counts of nodes and edges pushed.
|
||||
"""
|
||||
try:
|
||||
from neo4j import GraphDatabase
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"neo4j driver not installed. Run: pip install neo4j"
|
||||
) from e
|
||||
|
||||
node_community = _node_community_map(communities) if communities else {}
|
||||
|
||||
def _safe_rel(relation: str) -> str:
|
||||
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
|
||||
|
||||
def _safe_label(label: str) -> str:
|
||||
"""Sanitize a Neo4j node label to prevent Cypher injection."""
|
||||
sanitized = re.sub(r"[^A-Za-z0-9_]", "", label)
|
||||
return sanitized if sanitized else "Entity"
|
||||
|
||||
driver = GraphDatabase.driver(uri, auth=(user, password))
|
||||
nodes_pushed = 0
|
||||
edges_pushed = 0
|
||||
|
||||
with driver.session() as session:
|
||||
for node_id, data in G.nodes(data=True):
|
||||
props = {
|
||||
k: v for k, v in data.items()
|
||||
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
|
||||
}
|
||||
props["id"] = node_id
|
||||
cid = node_community.get(node_id)
|
||||
if cid is not None:
|
||||
props["community"] = cid
|
||||
ftype = _safe_label(data.get("file_type", "Entity").capitalize())
|
||||
session.run(
|
||||
f"MERGE (n:{ftype} {{id: $id}}) SET n += $props",
|
||||
id=node_id,
|
||||
props=props,
|
||||
)
|
||||
nodes_pushed += 1
|
||||
|
||||
for u, v, data in G.edges(data=True):
|
||||
rel = _safe_rel(data.get("relation", "RELATED_TO"))
|
||||
props = {
|
||||
k: v for k, v in data.items()
|
||||
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
|
||||
}
|
||||
session.run(
|
||||
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
|
||||
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
|
||||
src=u,
|
||||
tgt=v,
|
||||
props=props,
|
||||
)
|
||||
edges_pushed += 1
|
||||
|
||||
driver.close()
|
||||
return {"nodes": nodes_pushed, "edges": edges_pushed}
|
||||
|
||||
def push_to_falkordb(
|
||||
G: nx.Graph,
|
||||
uri: str,
|
||||
user: str | None = None,
|
||||
password: str | None = None,
|
||||
communities: dict[int, list[str]] | None = None,
|
||||
graph_name: str = "graphify",
|
||||
) -> dict[str, int]:
|
||||
"""Push graph directly to a running FalkorDB instance via the Python SDK.
|
||||
|
||||
Requires: pip install falkordb
|
||||
|
||||
FalkorDB is OpenCypher-compatible, so the MERGE/SET upsert queries are
|
||||
identical to push_to_neo4j. Differences from the Neo4j path:
|
||||
- connects with FalkorDB(host, port, username, password) instead of a bolt
|
||||
driver; only the host/port are read from the URI, so the scheme is
|
||||
informational - "falkordb://localhost:6379", "redis://localhost:6379"
|
||||
and a bare "localhost:6379" are all equivalent (default port 6379).
|
||||
- a named graph is selected via db.select_graph(graph_name) (default
|
||||
"graphify"); FalkorDB keys each graph by name in the same instance.
|
||||
- queries run via graph.query(cypher, params) - there is no session object.
|
||||
- auth is optional (FalkorDB runs without credentials by default), so user
|
||||
and password may be None.
|
||||
- no APOC: the Neo4j path does not use APOC either, so nothing to port.
|
||||
|
||||
Uses MERGE so re-running is safe - nodes and edges are upserted, not
|
||||
duplicated. Returns a dict with counts of nodes and edges pushed.
|
||||
"""
|
||||
try:
|
||||
from falkordb import FalkorDB
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"falkordb SDK not installed. Run: pip install falkordb"
|
||||
) from e
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
node_community = _node_community_map(communities) if communities else {}
|
||||
|
||||
def _safe_rel(relation: str) -> str:
|
||||
return re.sub(r"[^A-Z0-9_]", "_", relation.upper().replace(" ", "_").replace("-", "_")) or "RELATED_TO"
|
||||
|
||||
def _safe_label(label: str) -> str:
|
||||
"""Sanitize a FalkorDB node label to prevent Cypher injection."""
|
||||
sanitized = re.sub(r"[^A-Za-z0-9_]", "", label)
|
||||
return sanitized if sanitized else "Entity"
|
||||
|
||||
parsed = urlparse(uri if "://" in uri else f"redis://{uri}")
|
||||
# FalkorDB auth is optional. Only send credentials when a password is
|
||||
# provided; otherwise connect anonymously and ignore any bolt-style default
|
||||
# username (e.g. Neo4j's "neo4j"), which FalkorDB rejects as an unknown ACL
|
||||
# user. Credentials embedded in the URI take precedence over the args.
|
||||
connect_user = parsed.username or (user if password else None)
|
||||
connect_password = parsed.password or (password or None)
|
||||
db = FalkorDB(
|
||||
host=parsed.hostname or "localhost",
|
||||
port=parsed.port or 6379,
|
||||
username=connect_user,
|
||||
password=connect_password,
|
||||
)
|
||||
graph = db.select_graph(graph_name)
|
||||
nodes_pushed = 0
|
||||
edges_pushed = 0
|
||||
|
||||
for node_id, data in G.nodes(data=True):
|
||||
props = {
|
||||
k: v for k, v in data.items()
|
||||
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
|
||||
}
|
||||
props["id"] = node_id
|
||||
cid = node_community.get(node_id)
|
||||
if cid is not None:
|
||||
props["community"] = cid
|
||||
ftype = _safe_label(data.get("file_type", "Entity").capitalize())
|
||||
graph.query(
|
||||
f"MERGE (n:{ftype} {{id: $id}}) SET n += $props",
|
||||
{"id": node_id, "props": props},
|
||||
)
|
||||
nodes_pushed += 1
|
||||
|
||||
for u, v, data in G.edges(data=True):
|
||||
rel = _safe_rel(data.get("relation", "RELATED_TO"))
|
||||
props = {
|
||||
k: v for k, v in data.items()
|
||||
if isinstance(v, (str, int, float, bool)) and not k.startswith("_")
|
||||
}
|
||||
graph.query(
|
||||
f"MATCH (a {{id: $src}}), (b {{id: $tgt}}) "
|
||||
f"MERGE (a)-[r:{rel}]->(b) SET r += $props",
|
||||
{"src": u, "tgt": v, "props": props},
|
||||
)
|
||||
edges_pushed += 1
|
||||
|
||||
return {"nodes": nodes_pushed, "edges": edges_pushed}
|
||||
@@ -0,0 +1,547 @@
|
||||
"""html — moved verbatim from graphify/export.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
from graphify.exporters.base import COMMUNITY_COLORS # noqa: E402,F401
|
||||
from pathlib import Path
|
||||
import html as _html
|
||||
from graphify.analyze import _node_community_map
|
||||
import json
|
||||
import networkx as nx
|
||||
from graphify.security import sanitize_label
|
||||
|
||||
|
||||
MAX_NODES_FOR_VIZ = 5_000
|
||||
|
||||
def _viz_node_limit() -> int:
|
||||
"""Return the effective viz node limit, honoring GRAPHIFY_VIZ_NODE_LIMIT env var.
|
||||
|
||||
Falls back to MAX_NODES_FOR_VIZ when the env var is unset, empty, or non-integer.
|
||||
Set to 0 to disable HTML viz unconditionally (useful for CI runners).
|
||||
"""
|
||||
import os
|
||||
raw = os.environ.get("GRAPHIFY_VIZ_NODE_LIMIT")
|
||||
if raw is None or not raw.strip():
|
||||
return MAX_NODES_FOR_VIZ
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return MAX_NODES_FOR_VIZ
|
||||
|
||||
def _html_styles() -> str:
|
||||
return """<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #0f0f1a; color: #e0e0e0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; display: flex; height: 100vh; overflow: hidden; }
|
||||
#graph { flex: 1; }
|
||||
#sidebar { width: 280px; background: #1a1a2e; border-left: 1px solid #2a2a4e; display: flex; flex-direction: column; overflow: hidden; }
|
||||
#search-wrap { padding: 12px; border-bottom: 1px solid #2a2a4e; }
|
||||
#search { width: 100%; background: #0f0f1a; border: 1px solid #3a3a5e; color: #e0e0e0; padding: 7px 10px; border-radius: 6px; font-size: 13px; outline: none; }
|
||||
#search:focus { border-color: #4E79A7; }
|
||||
#search-results { max-height: 140px; overflow-y: auto; padding: 4px 12px; border-bottom: 1px solid #2a2a4e; display: none; }
|
||||
.search-item { padding: 4px 6px; cursor: pointer; border-radius: 4px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.search-item:hover { background: #2a2a4e; }
|
||||
#info-panel { padding: 14px; border-bottom: 1px solid #2a2a4e; min-height: 140px; }
|
||||
#info-panel h3 { font-size: 13px; color: #aaa; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
#info-content { font-size: 13px; color: #ccc; line-height: 1.6; }
|
||||
#info-content .field { margin-bottom: 5px; }
|
||||
#info-content .field b { color: #e0e0e0; }
|
||||
#info-content .empty { color: #555; font-style: italic; }
|
||||
.neighbor-link { display: block; padding: 2px 6px; margin: 2px 0; border-radius: 3px; cursor: pointer; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 3px solid #333; }
|
||||
.neighbor-link:hover { background: #2a2a4e; }
|
||||
#neighbors-list { max-height: 160px; overflow-y: auto; margin-top: 4px; }
|
||||
#legend-wrap { flex: 1; overflow-y: auto; padding: 12px; }
|
||||
#legend-wrap h3 { font-size: 13px; color: #aaa; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.legend-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; cursor: pointer; border-radius: 4px; font-size: 12px; }
|
||||
.legend-item:hover { background: #2a2a4e; padding-left: 4px; }
|
||||
.legend-item.dimmed { opacity: 0.35; }
|
||||
.legend-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||
.legend-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.legend-count { color: #666; font-size: 11px; }
|
||||
#stats { padding: 10px 14px; border-top: 1px solid #2a2a4e; font-size: 11px; color: #555; }
|
||||
#legend-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; padding: 4px 0; }
|
||||
#legend-controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 12px; color: #aaa; user-select: none; }
|
||||
#legend-controls label:hover { color: #e0e0e0; }
|
||||
.legend-cb, #select-all-cb { appearance: none; -webkit-appearance: none; width: 14px; height: 14px; border: 1.5px solid #3a3a5e; border-radius: 3px; background: #0f0f1a; cursor: pointer; position: relative; flex-shrink: 0; }
|
||||
.legend-cb:checked, #select-all-cb:checked { background: #4E79A7; border-color: #4E79A7; }
|
||||
.legend-cb:checked::after, #select-all-cb:checked::after { content: ''; position: absolute; left: 3.5px; top: 1px; width: 4px; height: 7px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
#select-all-cb:indeterminate { background: #4E79A7; border-color: #4E79A7; }
|
||||
#select-all-cb:indeterminate::after { content: ''; position: absolute; left: 2px; top: 5px; width: 8px; height: 2px; background: #fff; border: none; transform: none; }
|
||||
</style>"""
|
||||
|
||||
def _hyperedge_script(hyperedges_json: str) -> str:
|
||||
return f"""<script>
|
||||
// Render hyperedges as shaded regions
|
||||
const hyperedges = {hyperedges_json};
|
||||
// afterDrawing passes ctx already transformed to network coordinate space.
|
||||
// Draw node positions raw — no manual pan/zoom/DPR math needed.
|
||||
network.on('afterDrawing', function(ctx) {{
|
||||
hyperedges.forEach(h => {{
|
||||
const positions = h.nodes
|
||||
.map(nid => network.getPositions([nid])[nid])
|
||||
.filter(p => p !== undefined);
|
||||
if (positions.length < 2) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.12;
|
||||
ctx.fillStyle = '#6366f1';
|
||||
ctx.strokeStyle = '#6366f1';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
// Centroid and expanded hull in network coordinates
|
||||
const cx = positions.reduce((s, p) => s + p.x, 0) / positions.length;
|
||||
const cy = positions.reduce((s, p) => s + p.y, 0) / positions.length;
|
||||
const expanded = positions.map(p => ({{
|
||||
x: cx + (p.x - cx) * 1.15,
|
||||
y: cy + (p.y - cy) * 1.15
|
||||
}}));
|
||||
ctx.moveTo(expanded[0].x, expanded[0].y);
|
||||
expanded.slice(1).forEach(p => ctx.lineTo(p.x, p.y));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 0.4;
|
||||
ctx.stroke();
|
||||
// Label
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillStyle = '#4f46e5';
|
||||
ctx.font = 'bold 11px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(h.label, cx, cy - 5);
|
||||
ctx.restore();
|
||||
}});
|
||||
}});
|
||||
</script>"""
|
||||
|
||||
def _html_script(nodes_json: str, edges_json: str, legend_json: str) -> str:
|
||||
return f"""<script>
|
||||
const RAW_NODES = {nodes_json};
|
||||
const RAW_EDGES = {edges_json};
|
||||
const LEGEND = {legend_json};
|
||||
|
||||
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
|
||||
function esc(s) {{
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}}
|
||||
|
||||
// Build vis datasets
|
||||
const nodesDS = new vis.DataSet(RAW_NODES.map(n => ({{
|
||||
id: n.id, label: n.label, color: n.color, size: n.size,
|
||||
font: n.font, title: n.title,
|
||||
_community: n.community, _community_name: n.community_name,
|
||||
_source_file: n.source_file, _file_type: n.file_type, _degree: n.degree,
|
||||
}})));
|
||||
|
||||
const edgesDS = new vis.DataSet(RAW_EDGES.map((e, i) => ({{
|
||||
id: i, from: e.from, to: e.to,
|
||||
label: '',
|
||||
title: e.title,
|
||||
dashes: e.dashes,
|
||||
width: e.width,
|
||||
color: e.color,
|
||||
arrows: {{ to: {{ enabled: true, scaleFactor: 0.5 }} }},
|
||||
}})));
|
||||
|
||||
const container = document.getElementById('graph');
|
||||
const network = new vis.Network(container, {{ nodes: nodesDS, edges: edgesDS }}, {{
|
||||
physics: {{
|
||||
enabled: true,
|
||||
solver: 'forceAtlas2Based',
|
||||
forceAtlas2Based: {{
|
||||
gravitationalConstant: -60,
|
||||
centralGravity: 0.005,
|
||||
springLength: 120,
|
||||
springConstant: 0.08,
|
||||
damping: 0.4,
|
||||
avoidOverlap: 0.8,
|
||||
}},
|
||||
stabilization: {{ iterations: 200, fit: true }},
|
||||
}},
|
||||
interaction: {{
|
||||
hover: true,
|
||||
tooltipDelay: 100,
|
||||
hideEdgesOnDrag: true,
|
||||
navigationButtons: false,
|
||||
keyboard: false,
|
||||
}},
|
||||
nodes: {{ shape: 'dot', borderWidth: 1.5 }},
|
||||
edges: {{ smooth: {{ type: 'continuous', roundness: 0.2 }}, selectionWidth: 3 }},
|
||||
}});
|
||||
|
||||
network.once('stabilizationIterationsDone', () => {{
|
||||
network.setOptions({{ physics: {{ enabled: false }} }});
|
||||
}});
|
||||
|
||||
function showInfo(nodeId) {{
|
||||
const n = nodesDS.get(nodeId);
|
||||
if (!n) return;
|
||||
const neighborIds = network.getConnectedNodes(nodeId);
|
||||
const neighborItems = neighborIds.map(nid => {{
|
||||
const nb = nodesDS.get(nid);
|
||||
const color = nb ? nb.color.background : '#555';
|
||||
return `<span class="neighbor-link" style="border-left-color:${{esc(color)}}" onclick="focusNode(${{JSON.stringify(nid)}})">${{esc(nb ? nb.label : nid)}}</span>`;
|
||||
}}).join('');
|
||||
document.getElementById('info-content').innerHTML = `
|
||||
<div class="field"><b>${{esc(n.label)}}</b></div>
|
||||
<div class="field">Type: ${{esc(n._file_type || 'unknown')}}</div>
|
||||
<div class="field">Community: ${{esc(n._community_name)}}</div>
|
||||
<div class="field">Source: ${{esc(n._source_file || '-')}}</div>
|
||||
<div class="field">Degree: ${{n._degree}}</div>
|
||||
${{neighborIds.length ? `<div class="field" style="margin-top:8px;color:#aaa;font-size:11px">Neighbors (${{neighborIds.length}})</div><div id="neighbors-list">${{neighborItems}}</div>` : ''}}
|
||||
`;
|
||||
}}
|
||||
|
||||
function focusNode(nodeId) {{
|
||||
network.focus(nodeId, {{ scale: 1.4, animation: true }});
|
||||
network.selectNodes([nodeId]);
|
||||
showInfo(nodeId);
|
||||
}}
|
||||
|
||||
// Track hovered node — hover detection is more reliable than click params
|
||||
let hoveredNodeId = null;
|
||||
network.on('hoverNode', params => {{
|
||||
hoveredNodeId = params.node;
|
||||
container.style.cursor = 'pointer';
|
||||
}});
|
||||
network.on('blurNode', () => {{
|
||||
hoveredNodeId = null;
|
||||
container.style.cursor = 'default';
|
||||
}});
|
||||
container.addEventListener('click', () => {{
|
||||
if (hoveredNodeId !== null) {{
|
||||
showInfo(hoveredNodeId);
|
||||
network.selectNodes([hoveredNodeId]);
|
||||
}}
|
||||
}});
|
||||
network.on('click', params => {{
|
||||
if (params.nodes.length > 0) {{
|
||||
showInfo(params.nodes[0]);
|
||||
}} else if (hoveredNodeId === null) {{
|
||||
document.getElementById('info-content').innerHTML = '<span class="empty">Click a node to inspect it</span>';
|
||||
}}
|
||||
}});
|
||||
|
||||
const searchInput = document.getElementById('search');
|
||||
const searchResults = document.getElementById('search-results');
|
||||
searchInput.addEventListener('input', () => {{
|
||||
const q = searchInput.value.toLowerCase().trim();
|
||||
searchResults.innerHTML = '';
|
||||
if (!q) {{ searchResults.style.display = 'none'; return; }}
|
||||
const matches = RAW_NODES.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
|
||||
if (!matches.length) {{ searchResults.style.display = 'none'; return; }}
|
||||
searchResults.style.display = 'block';
|
||||
matches.forEach(n => {{
|
||||
const el = document.createElement('div');
|
||||
el.className = 'search-item';
|
||||
el.textContent = n.label;
|
||||
el.style.borderLeft = `3px solid ${{n.color.background}}`;
|
||||
el.style.paddingLeft = '8px';
|
||||
el.onclick = () => {{
|
||||
network.focus(n.id, {{ scale: 1.5, animation: true }});
|
||||
network.selectNodes([n.id]);
|
||||
showInfo(n.id);
|
||||
searchResults.style.display = 'none';
|
||||
searchInput.value = '';
|
||||
}};
|
||||
searchResults.appendChild(el);
|
||||
}});
|
||||
}});
|
||||
document.addEventListener('click', e => {{
|
||||
if (!searchResults.contains(e.target) && e.target !== searchInput)
|
||||
searchResults.style.display = 'none';
|
||||
}});
|
||||
|
||||
const hiddenCommunities = new Set();
|
||||
|
||||
const selectAllCb = document.getElementById('select-all-cb');
|
||||
|
||||
function updateSelectAllState() {{
|
||||
const total = LEGEND.length;
|
||||
const hidden = hiddenCommunities.size;
|
||||
selectAllCb.checked = hidden === 0;
|
||||
selectAllCb.indeterminate = hidden > 0 && hidden < total;
|
||||
}}
|
||||
|
||||
function toggleAllCommunities(hide) {{
|
||||
document.querySelectorAll('.legend-item').forEach(item => {{
|
||||
hide ? item.classList.add('dimmed') : item.classList.remove('dimmed');
|
||||
}});
|
||||
document.querySelectorAll('.legend-cb').forEach(cb => {{
|
||||
cb.checked = !hide;
|
||||
}});
|
||||
LEGEND.forEach(c => {{
|
||||
if (hide) hiddenCommunities.add(c.cid); else hiddenCommunities.delete(c.cid);
|
||||
}});
|
||||
const updates = RAW_NODES.map(n => ({{ id: n.id, hidden: hide }}));
|
||||
nodesDS.update(updates);
|
||||
updateSelectAllState();
|
||||
}}
|
||||
|
||||
const legendEl = document.getElementById('legend');
|
||||
LEGEND.forEach(c => {{
|
||||
const item = document.createElement('div');
|
||||
item.className = 'legend-item';
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'legend-cb';
|
||||
cb.checked = true;
|
||||
cb.addEventListener('change', (e) => {{
|
||||
e.stopPropagation();
|
||||
if (cb.checked) {{
|
||||
hiddenCommunities.delete(c.cid);
|
||||
item.classList.remove('dimmed');
|
||||
}} else {{
|
||||
hiddenCommunities.add(c.cid);
|
||||
item.classList.add('dimmed');
|
||||
}}
|
||||
const updates = RAW_NODES
|
||||
.filter(n => n.community === c.cid)
|
||||
.map(n => ({{ id: n.id, hidden: !cb.checked }}));
|
||||
nodesDS.update(updates);
|
||||
updateSelectAllState();
|
||||
}});
|
||||
item.innerHTML = `<div class="legend-dot" style="background:${{c.color}}"></div>
|
||||
<span class="legend-label">${{c.label}}</span>
|
||||
<span class="legend-count">${{c.count}}</span>`;
|
||||
item.prepend(cb);
|
||||
item.onclick = (e) => {{
|
||||
if (e.target === cb) return;
|
||||
cb.checked = !cb.checked;
|
||||
cb.dispatchEvent(new Event('change'));
|
||||
}};
|
||||
legendEl.appendChild(item);
|
||||
}});
|
||||
</script>"""
|
||||
|
||||
def to_html(
|
||||
G: nx.Graph,
|
||||
communities: dict[int, list[str]],
|
||||
output_path: str,
|
||||
community_labels: dict[int, str] | None = None,
|
||||
member_counts: dict[int, int] | None = None,
|
||||
node_limit: int | None = None,
|
||||
learning_overlay: dict | None = None,
|
||||
) -> None:
|
||||
"""Generate an interactive vis.js HTML visualization of the graph.
|
||||
|
||||
Features: node size by degree, click-to-inspect panel, search box,
|
||||
community filter, physics clustering by community, confidence-styled edges.
|
||||
Raises ValueError if graph exceeds MAX_NODES_FOR_VIZ.
|
||||
|
||||
If member_counts is provided (aggregated community view), node sizes are
|
||||
based on community member counts rather than graph degree.
|
||||
|
||||
If node_limit is set and the graph exceeds it, automatically builds an
|
||||
aggregated community-level meta-graph instead of raising ValueError.
|
||||
"""
|
||||
limit = node_limit if node_limit is not None else _viz_node_limit()
|
||||
if G.number_of_nodes() > limit:
|
||||
if node_limit is not None:
|
||||
# Build aggregated community meta-graph
|
||||
from collections import Counter as _Counter
|
||||
import networkx as _nx
|
||||
print(f"Graph has {G.number_of_nodes()} nodes (above {limit} limit). Building aggregated community view...")
|
||||
node_to_community = {nid: cid for cid, members in communities.items() for nid in members}
|
||||
meta = _nx.Graph()
|
||||
for cid, members in communities.items():
|
||||
meta.add_node(str(cid), label=(community_labels or {}).get(cid, f"Community {cid}"))
|
||||
edge_counts = _Counter()
|
||||
for u, v in G.edges():
|
||||
cu, cv = node_to_community.get(u), node_to_community.get(v)
|
||||
if cu is not None and cv is not None and cu != cv:
|
||||
edge_counts[(min(cu, cv), max(cu, cv))] += 1
|
||||
for (cu, cv), w in edge_counts.items():
|
||||
meta.add_edge(str(cu), str(cv), weight=w,
|
||||
relation=f"{w} cross-community edges", confidence="AGGREGATED")
|
||||
if meta.number_of_nodes() <= 1:
|
||||
print("Single community - aggregated view not useful. Skipping graph.html.")
|
||||
return
|
||||
meta_communities = {cid: [str(cid)] for cid in communities}
|
||||
mc = {cid: len(members) for cid, members in communities.items()}
|
||||
# Remap hyperedges from semantic node IDs to community IDs
|
||||
raw_hyperedges = G.graph.get("hyperedges", [])
|
||||
if raw_hyperedges:
|
||||
remapped = []
|
||||
for he in raw_hyperedges:
|
||||
he_members = he.get("nodes", [])
|
||||
comm_ids, seen = [], set()
|
||||
for nid in he_members:
|
||||
c = node_to_community.get(nid)
|
||||
if c is None:
|
||||
continue
|
||||
s = str(c)
|
||||
if s in seen:
|
||||
continue
|
||||
seen.add(s)
|
||||
comm_ids.append(s)
|
||||
if len(comm_ids) < 2:
|
||||
continue
|
||||
remapped.append({
|
||||
"id": he.get("id", ""),
|
||||
"label": he.get("label") or he.get("relation", "").replace("_", " "),
|
||||
"nodes": comm_ids,
|
||||
})
|
||||
meta.graph["hyperedges"] = remapped
|
||||
to_html(meta, meta_communities, output_path,
|
||||
community_labels=community_labels, member_counts=mc)
|
||||
print(f"graph.html written (aggregated: {meta.number_of_nodes()} community nodes, {meta.number_of_edges()} cross-community edges)")
|
||||
print("Tip: run with --obsidian for full node-level detail.")
|
||||
return
|
||||
raise ValueError(
|
||||
f"Graph has {G.number_of_nodes()} nodes - too large for HTML viz "
|
||||
f"(limit: {limit}). Use --no-viz, raise GRAPHIFY_VIZ_NODE_LIMIT, "
|
||||
f"or reduce input size."
|
||||
)
|
||||
|
||||
node_community = _node_community_map(communities)
|
||||
degree = dict(G.degree())
|
||||
max_deg = max(degree.values(), default=1) or 1
|
||||
max_mc = (max(member_counts.values(), default=1) or 1) if member_counts else 1
|
||||
|
||||
# Work-memory overlay (derived sidecar). When not passed explicitly, load it
|
||||
# best-effort from the sibling .graphify_learning.json next to the output
|
||||
# graph.html (which lives beside graph.json). Empty/missing => no learning
|
||||
# fields, so the un-annotated render is byte-identical to pre-feature.
|
||||
if learning_overlay is None:
|
||||
learning_overlay = {}
|
||||
try:
|
||||
from graphify.reflect import load_learning_overlay as _llo
|
||||
learning_overlay = _llo(Path(output_path))
|
||||
except Exception:
|
||||
learning_overlay = {}
|
||||
# Status -> ring color. preferred=green, contested=amber. Tentative gets no
|
||||
# ring (it's not yet trustworthy enough to highlight in the map).
|
||||
_RING = {"preferred": "#22c55e", "contested": "#f59e0b"}
|
||||
|
||||
# Build nodes list for vis.js
|
||||
vis_nodes = []
|
||||
for node_id, data in G.nodes(data=True):
|
||||
cid = node_community.get(node_id, 0)
|
||||
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
|
||||
label = sanitize_label(data.get("label", node_id))
|
||||
deg = degree.get(node_id, 1)
|
||||
if member_counts:
|
||||
mc = member_counts.get(cid, 1)
|
||||
size = 10 + 30 * (mc / max_mc)
|
||||
font_size = 12
|
||||
else:
|
||||
size = 10 + 30 * (deg / max_deg)
|
||||
# Only show label for high-degree nodes by default; others show on hover
|
||||
font_size = 12 if deg >= max_deg * 0.15 else 0
|
||||
node = {
|
||||
"id": node_id,
|
||||
"label": label,
|
||||
"color": {"background": color, "border": color, "highlight": {"background": "#ffffff", "border": color}},
|
||||
"size": round(size, 1),
|
||||
"font": {"size": font_size, "color": "#ffffff"},
|
||||
"title": _html.escape(label),
|
||||
"community": cid,
|
||||
"community_name": sanitize_label((community_labels or {}).get(cid, f"Community {cid}")),
|
||||
"source_file": sanitize_label(str(data.get("source_file") or "")),
|
||||
"file_type": data.get("file_type", ""),
|
||||
"degree": deg,
|
||||
}
|
||||
# Conditional learning fields — only present for annotated nodes, so
|
||||
# un-annotated output keeps the exact pre-feature node dict shape.
|
||||
entry = learning_overlay.get(str(node_id)) if learning_overlay else None
|
||||
if entry:
|
||||
status = sanitize_label(str(entry.get("status", "")))
|
||||
stale = bool(entry.get("stale"))
|
||||
node["learning_status"] = status
|
||||
node["learning_stale"] = stale
|
||||
ring = _RING.get(status)
|
||||
if ring:
|
||||
# Status-colored ring via the border; stale => desaturated +
|
||||
# dashed (vis.js supports per-node `shapeProperties.borderDashes`).
|
||||
if stale:
|
||||
ring = "#9ca3af"
|
||||
node["shapeProperties"] = {"borderDashes": [4, 4]}
|
||||
node["borderWidth"] = 3
|
||||
node["color"] = {
|
||||
"background": color, "border": ring,
|
||||
"highlight": {"background": "#ffffff", "border": ring},
|
||||
}
|
||||
# Lesson line appended to the hover title.
|
||||
if status == "contested":
|
||||
lesson = f"Lesson: contested (useful {entry.get('uses', 0)} / dead-end {entry.get('neg', 0)})"
|
||||
elif status == "preferred":
|
||||
lesson = f"Lesson: preferred source ({entry.get('uses', 0)} useful, score={entry.get('score', 0)})"
|
||||
else:
|
||||
lesson = f"Lesson: {status} ({entry.get('uses', 0)} useful)"
|
||||
if stale:
|
||||
lesson += " [code changed — re-verify]"
|
||||
node["title"] = _html.escape(label) + "\n" + _html.escape(sanitize_label(lesson))
|
||||
vis_nodes.append(node)
|
||||
|
||||
# Build edges list. Restore original edge direction from _src/_tgt
|
||||
# (stashed by build.py for exactly this reason): undirected NetworkX
|
||||
# canonicalizes endpoint order, which would otherwise flip the arrow
|
||||
# for `calls` and `rationale_for` in the rendered graph (#563).
|
||||
vis_edges = []
|
||||
for u, v, data in G.edges(data=True):
|
||||
confidence = data.get("confidence", "EXTRACTED")
|
||||
relation = data.get("relation", "")
|
||||
true_src = data.get("_src", u)
|
||||
true_tgt = data.get("_tgt", v)
|
||||
vis_edges.append({
|
||||
"from": true_src,
|
||||
"to": true_tgt,
|
||||
"label": relation,
|
||||
"title": _html.escape(f"{relation} [{confidence}]"),
|
||||
"dashes": confidence != "EXTRACTED",
|
||||
"width": 2 if confidence == "EXTRACTED" else 1,
|
||||
"color": {"opacity": 0.7 if confidence == "EXTRACTED" else 0.35},
|
||||
"confidence": confidence,
|
||||
})
|
||||
|
||||
# Build community legend data
|
||||
legend_data = []
|
||||
for cid in sorted((community_labels or {}).keys()):
|
||||
color = COMMUNITY_COLORS[cid % len(COMMUNITY_COLORS)]
|
||||
lbl = _html.escape(sanitize_label((community_labels or {}).get(cid, f"Community {cid}")))
|
||||
n = member_counts.get(cid, len(communities.get(cid, []))) if member_counts else len(communities.get(cid, []))
|
||||
legend_data.append({"cid": cid, "color": color, "label": lbl, "count": n})
|
||||
|
||||
# Escape </script> sequences so embedded JSON cannot break out of the script tag
|
||||
def _js_safe(obj) -> str:
|
||||
return json.dumps(obj).replace("</", "<\\/")
|
||||
|
||||
nodes_json = _js_safe(vis_nodes)
|
||||
edges_json = _js_safe(vis_edges)
|
||||
legend_json = _js_safe(legend_data)
|
||||
hyperedges_json = _js_safe(getattr(G, "graph", {}).get("hyperedges", []))
|
||||
title = _html.escape(sanitize_label(str(output_path)))
|
||||
stats = f"{G.number_of_nodes()} nodes · {G.number_of_edges()} edges · {len(communities)} communities"
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>graphify - {title}</title>
|
||||
<script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"
|
||||
integrity="sha384-Ux6phic9PEHJ38YtrijhkzyJ8yQlH8i/+buBR8s3mAZOJrP1gwyvAcIYl3GWtpX1"
|
||||
crossorigin="anonymous"></script>
|
||||
{_html_styles()}
|
||||
</head>
|
||||
<body>
|
||||
<div id="graph"></div>
|
||||
<div id="sidebar">
|
||||
<div id="search-wrap">
|
||||
<input id="search" type="text" placeholder="Search nodes..." autocomplete="off">
|
||||
<div id="search-results"></div>
|
||||
</div>
|
||||
<div id="info-panel">
|
||||
<h3>Node Info</h3>
|
||||
<div id="info-content"><span class="empty">Click a node to inspect it</span></div>
|
||||
</div>
|
||||
<div id="legend-wrap">
|
||||
<h3>Communities</h3>
|
||||
<div id="legend-controls">
|
||||
<label><input type="checkbox" id="select-all-cb" checked onchange="toggleAllCommunities(!this.checked)">Select All</label>
|
||||
</div>
|
||||
<div id="legend"></div>
|
||||
</div>
|
||||
<div id="stats">{stats}</div>
|
||||
</div>
|
||||
{_html_script(nodes_json, edges_json, legend_json)}
|
||||
{_hyperedge_script(hyperedges_json)}
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
Path(output_path).write_text(html, encoding="utf-8") # nosec
|
||||
+4941
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
# Migrating a language extractor out of extract.py
|
||||
|
||||
`graphify/extract.py` is being split into this package, one language per PR
|
||||
(upstream issue #1212). This is the playbook for porting ONE language. It is
|
||||
written so an AI agent can execute it in a single session.
|
||||
|
||||
## Status
|
||||
|
||||
| module | migrated |
|
||||
|---|---|
|
||||
| blade | yes |
|
||||
| zig | yes |
|
||||
| elixir | yes |
|
||||
| razor | yes |
|
||||
| dart | yes |
|
||||
| rust | yes |
|
||||
| go | yes |
|
||||
| powershell (ps1 + psd1 manifest) | yes |
|
||||
| fortran | yes |
|
||||
| sql | yes |
|
||||
| dm (dm/dmm/dmi/dmf) | yes |
|
||||
| bash | yes |
|
||||
| apex | yes |
|
||||
| terraform | yes |
|
||||
| sln | yes |
|
||||
| pascal_forms (dfm + lfm) | yes |
|
||||
| json_config | yes |
|
||||
| (config-driven core: python, js, java, c, cpp, csharp, kotlin, scala, php, lua, swift, groovy, vue, svelte, astro, xaml, groovy) | no — shared _extract_generic core, move as one batch |
|
||||
| (other bespoke: julia, verilog, markdown, objc, csproj, slnx, lazarus_package, pascal) | no |
|
||||
|
||||
Note: config-driven extractors (python, js, java, c, cpp, ruby, csharp,
|
||||
kotlin, scala, php, lua, swift, groovy) depend on the shared
|
||||
`_extract_generic` core (~1,300 lines). Do NOT port them one-by-one; the core
|
||||
must move first as its own coordinated batch. Pick a bespoke extractor.
|
||||
|
||||
## Invariants (non-negotiable)
|
||||
|
||||
1. **Verbatim moves only.** No renames, no docstring edits, no reformatting,
|
||||
no added annotations, no "improvements". Verify: save the block before
|
||||
cutting and confirm the pasted block is byte-identical.
|
||||
2. **One language per PR.** Small diffs keep review trivial and avoid
|
||||
conflicts with other in-flight ports.
|
||||
3. **Facade re-export is mandatory.** `extract.py` must keep exporting every
|
||||
moved name (`from graphify.extractors.<mod> import extract_<lang> # noqa: F401`
|
||||
in the marked migration block, kept alphabetical). Existing importers
|
||||
(`__main__.py`, `watch.py`, `pg_introspect.py`, tests) must not change.
|
||||
4. **Never import from `graphify.extract` inside this package.** Import
|
||||
direction is strictly extract.py -> extractors/. If you need a helper that
|
||||
lives in extract.py, classify it (below) and move it.
|
||||
5. **Zero test edits** outside `tests/test_extractors_registry.py`. The
|
||||
untouched language tests passing IS the proof of behavior preservation.
|
||||
|
||||
## Helper classification
|
||||
|
||||
For every `_name` your function references that is defined OUTSIDE it:
|
||||
|
||||
- run `grep -c '_name' graphify/extract.py` AFTER your candidate move;
|
||||
- remaining uses > 0 -> **shared**: move it to `base.py` and add it to the
|
||||
facade re-import in extract.py;
|
||||
- remaining uses = 0 -> **private**: move it into your language module.
|
||||
|
||||
Closures, constants, and `import` statements defined INSIDE your function
|
||||
move with it for free — leave them exactly where they are. Only add a
|
||||
module-header import for names the pasted code references at module scope
|
||||
that are not satisfied internally, and verify each header import is used.
|
||||
|
||||
## Pre-flight
|
||||
|
||||
1. Check upstream for conflicts: open PRs/issues mentioning your language,
|
||||
and churn: `git log --oneline --since="3 months ago" upstream/<default> | grep -i <lang>`.
|
||||
High churn -> pick another language.
|
||||
2. Confirm your extractor is bespoke (its `extract_<lang>` is a full function,
|
||||
not a 5-line `_extract_generic(path, LanguageConfig(...))` wrapper).
|
||||
3. Check whether tests/ exercises your language's behavior (grep for
|
||||
`test_<lang>`). If it has no behavioral tests, the byte-identity check in
|
||||
step 3 below is the ENTIRE proof of preservation — include the
|
||||
`git diff --color-moved` evidence in your PR description.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Append a failing test to `tests/test_extractors_registry.py`:
|
||||
module import + facade identity + registry identity (copy an existing
|
||||
`test_<lang>_migrated` as the template).
|
||||
2. `grep -n 'def extract_<lang>' graphify/extract.py`; the span ends at the
|
||||
line before the next top-level statement (`^def ` or `^_CONST`). Beware
|
||||
neighbors: top-level constants AFTER your function may belong to the NEXT
|
||||
function (e.g. `_CONFIG_JSON_*` sit after where extract_razor used to be
|
||||
but were never razor's).
|
||||
3. Save the span to a temp file. Create `graphify/extractors/<lang>.py` with
|
||||
module docstring (`"""<Lang> extractor. Moved verbatim from graphify/extract.py."""`),
|
||||
`from __future__ import annotations`, minimal stdlib imports, base imports,
|
||||
then paste the function. Verify byte-identity against the temp file.
|
||||
4. Delete the span from extract.py, leaving exactly two blank lines between
|
||||
the now-adjacent top-level definitions; add the facade re-import; add the
|
||||
registry entry in `__init__.py` (alphabetical); update the Status table
|
||||
above.
|
||||
5. `uv run pytest -q` -> 0 failures, no test file changed except the registry
|
||||
test. If ImportError/NameError: a helper was misclassified — go to
|
||||
Helper classification.
|
||||
6. One commit: `refactor(extract): move extract_<lang> to extractors/<lang>.py (verbatim)`.
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- Do not rewire dispatch, add classes, or add lazy imports — mechanism layers
|
||||
come later, by separate agreement (see #1212 discussion).
|
||||
- Do not port two languages in one PR "while you're at it".
|
||||
- Do not touch `__main__.py`.
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Per-language extractors, incrementally migrated out of graphify/extract.py.
|
||||
|
||||
Dispatch still flows through graphify.extract (the facade re-exports every
|
||||
moved name), so importing from graphify.extract keeps working unchanged.
|
||||
LANGUAGE_EXTRACTORS is the registry seed; wiring dispatch through it is a
|
||||
later, separate step. See MIGRATION.md for how to port another language.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from graphify.extractors.apex import extract_apex
|
||||
from graphify.extractors.bash import extract_bash
|
||||
from graphify.extractors.blade import extract_blade
|
||||
from graphify.extractors.dart import extract_dart
|
||||
from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm
|
||||
from graphify.extractors.elixir import extract_elixir
|
||||
from graphify.extractors.fortran import extract_fortran
|
||||
from graphify.extractors.go import extract_go
|
||||
from graphify.extractors.json_config import extract_json
|
||||
from graphify.extractors.julia import extract_julia
|
||||
from graphify.extractors.markdown import extract_markdown
|
||||
from graphify.extractors.objc import extract_objc
|
||||
from graphify.extractors.pascal import extract_pascal
|
||||
from graphify.extractors.pascal_forms import extract_delphi_form, extract_lazarus_form
|
||||
from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest
|
||||
from graphify.extractors.razor import extract_razor
|
||||
from graphify.extractors.rust import extract_rust
|
||||
from graphify.extractors.sln import extract_sln
|
||||
from graphify.extractors.sql import extract_sql
|
||||
from graphify.extractors.terraform import extract_terraform
|
||||
from graphify.extractors.verilog import extract_verilog
|
||||
from graphify.extractors.zig import extract_zig
|
||||
|
||||
LANGUAGE_EXTRACTORS: dict[str, Callable[[Path], dict]] = {
|
||||
"apex": extract_apex,
|
||||
"bash": extract_bash,
|
||||
"blade": extract_blade,
|
||||
"dart": extract_dart,
|
||||
"delphi_form": extract_delphi_form,
|
||||
"dm": extract_dm,
|
||||
"dmf": extract_dmf,
|
||||
"dmi": extract_dmi,
|
||||
"dmm": extract_dmm,
|
||||
"elixir": extract_elixir,
|
||||
"fortran": extract_fortran,
|
||||
"go": extract_go,
|
||||
"json": extract_json,
|
||||
"julia": extract_julia,
|
||||
"lazarus_form": extract_lazarus_form,
|
||||
"markdown": extract_markdown,
|
||||
"objc": extract_objc,
|
||||
"pascal": extract_pascal,
|
||||
"powershell": extract_powershell,
|
||||
"powershell_manifest": extract_powershell_manifest,
|
||||
"razor": extract_razor,
|
||||
"rust": extract_rust,
|
||||
"sln": extract_sln,
|
||||
"sql": extract_sql,
|
||||
"terraform": extract_terraform,
|
||||
"verilog": extract_verilog,
|
||||
"zig": extract_zig,
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Apex extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from graphify.extractors.base import _file_stem, _make_id
|
||||
|
||||
|
||||
def extract_apex(path: Path) -> dict:
|
||||
"""Extract classes, interfaces, enums, methods, and Salesforce constructs from
|
||||
Apex .cls and .trigger files using regex (no tree-sitter grammar on PyPI)."""
|
||||
import re as _re
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return {"nodes": [], "edges": []}
|
||||
|
||||
str_path = str(path)
|
||||
stem = _file_stem(path)
|
||||
file_nid = _make_id(str_path)
|
||||
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"label": label,
|
||||
"file_type": "code",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
})
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
confidence: str = "EXTRACTED") -> None:
|
||||
edges.append({
|
||||
"source": src,
|
||||
"target": tgt,
|
||||
"relation": relation,
|
||||
"confidence": confidence,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
lines = source.splitlines()
|
||||
|
||||
_ACCESS = r"(?:public|private|protected|global|webService)?"
|
||||
_SHARING = r"(?:\s+(?:with|without|inherited)\s+sharing)?"
|
||||
_MOD = r"(?:\s+(?:abstract|virtual|override|static|final|transient|testMethod))?"
|
||||
_ANNOTATION = r"(?:\s*@\w+(?:\s*\([^)]*\))?\s*)*"
|
||||
|
||||
cls_re = _re.compile(
|
||||
rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*class\s+(\w+)"
|
||||
rf"(?:\s+extends\s+(\w+))?(?:\s+implements\s+([\w,\s]+))?\s*\{{?",
|
||||
_re.IGNORECASE,
|
||||
)
|
||||
iface_re = _re.compile(
|
||||
rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*interface\s+(\w+)"
|
||||
rf"(?:\s+extends\s+([\w,\s]+))?\s*\{{?",
|
||||
_re.IGNORECASE,
|
||||
)
|
||||
enum_re = _re.compile(
|
||||
rf"^{_ANNOTATION}\s*{_ACCESS}{_SHARING}{_MOD}\s*enum\s+(\w+)\s*\{{?",
|
||||
_re.IGNORECASE,
|
||||
)
|
||||
trigger_re = _re.compile(
|
||||
r"^\s*trigger\s+(\w+)\s+on\s+(\w+)\s*\(",
|
||||
_re.IGNORECASE,
|
||||
)
|
||||
method_re = _re.compile(
|
||||
rf"^{_ANNOTATION}\s*{_ACCESS}{_MOD}\s*(?:static\s+)?[\w<>\[\]]+\s+(\w+)\s*\([^)]*\)\s*(?:throws\s+\w+\s*)?\{{?",
|
||||
_re.IGNORECASE,
|
||||
)
|
||||
annotation_re = _re.compile(r"@(\w+)", _re.IGNORECASE)
|
||||
soql_re = _re.compile(r"\[\s*SELECT\b[^\]]+FROM\s+(\w+)", _re.IGNORECASE)
|
||||
dml_re = _re.compile(r"\b(insert|update|delete|upsert|merge|undelete)\s+\w", _re.IGNORECASE)
|
||||
|
||||
_CONTROL_FLOW = frozenset({
|
||||
"if", "else", "for", "while", "do", "switch", "try", "catch",
|
||||
"finally", "return", "throw", "new", "void", "null",
|
||||
"true", "false", "this", "super", "class", "interface", "enum",
|
||||
"trigger", "on",
|
||||
})
|
||||
|
||||
current_class_nid: str | None = None
|
||||
pending_annotations: list[str] = []
|
||||
|
||||
for lineno, line_text in enumerate(lines, start=1):
|
||||
stripped = line_text.strip()
|
||||
|
||||
if stripped.startswith("@"):
|
||||
for m in annotation_re.finditer(stripped):
|
||||
pending_annotations.append(m.group(1).lower())
|
||||
continue
|
||||
|
||||
tm = trigger_re.match(stripped)
|
||||
if tm:
|
||||
trig_name, sobject = tm.group(1), tm.group(2)
|
||||
trig_nid = _make_id(stem, trig_name)
|
||||
add_node(trig_nid, trig_name, lineno)
|
||||
add_edge(file_nid, trig_nid, "contains", lineno)
|
||||
sob_nid = _make_id(sobject)
|
||||
if sob_nid not in seen_ids:
|
||||
add_node(sob_nid, sobject, lineno)
|
||||
add_edge(trig_nid, sob_nid, "uses", lineno, confidence="INFERRED")
|
||||
current_class_nid = trig_nid
|
||||
pending_annotations = []
|
||||
continue
|
||||
|
||||
cm = cls_re.match(stripped)
|
||||
if cm:
|
||||
class_name = cm.group(1)
|
||||
if class_name.lower() in _CONTROL_FLOW:
|
||||
pending_annotations = []
|
||||
continue
|
||||
class_nid = _make_id(stem, class_name)
|
||||
add_node(class_nid, class_name, lineno)
|
||||
add_edge(file_nid, class_nid, "contains", lineno)
|
||||
if cm.group(2):
|
||||
base = cm.group(2).strip()
|
||||
base_nid = _make_id(stem, base)
|
||||
if base_nid not in seen_ids:
|
||||
base_nid = _make_id(base)
|
||||
if base_nid not in seen_ids:
|
||||
add_node(base_nid, base, lineno)
|
||||
add_edge(class_nid, base_nid, "extends", lineno, confidence="INFERRED")
|
||||
if cm.group(3):
|
||||
for iface in cm.group(3).split(","):
|
||||
iface = iface.strip()
|
||||
if iface:
|
||||
iface_nid = _make_id(stem, iface)
|
||||
if iface_nid not in seen_ids:
|
||||
iface_nid = _make_id(iface)
|
||||
if iface_nid not in seen_ids:
|
||||
add_node(iface_nid, iface, lineno)
|
||||
add_edge(class_nid, iface_nid, "implements", lineno, confidence="INFERRED")
|
||||
current_class_nid = class_nid
|
||||
pending_annotations = []
|
||||
continue
|
||||
|
||||
im = iface_re.match(stripped)
|
||||
if im:
|
||||
iface_name = im.group(1)
|
||||
if iface_name.lower() in _CONTROL_FLOW:
|
||||
pending_annotations = []
|
||||
continue
|
||||
iface_nid = _make_id(stem, iface_name)
|
||||
add_node(iface_nid, iface_name, lineno)
|
||||
add_edge(file_nid if current_class_nid is None else current_class_nid,
|
||||
iface_nid, "contains", lineno)
|
||||
if im.group(2):
|
||||
for parent in im.group(2).split(","):
|
||||
parent = parent.strip()
|
||||
if parent:
|
||||
parent_nid = _make_id(stem, parent)
|
||||
if parent_nid not in seen_ids:
|
||||
parent_nid = _make_id(parent)
|
||||
if parent_nid not in seen_ids:
|
||||
add_node(parent_nid, parent, lineno)
|
||||
add_edge(iface_nid, parent_nid, "extends", lineno, confidence="INFERRED")
|
||||
pending_annotations = []
|
||||
continue
|
||||
|
||||
em = enum_re.match(stripped)
|
||||
if em:
|
||||
enum_name = em.group(1)
|
||||
if enum_name.lower() in _CONTROL_FLOW:
|
||||
pending_annotations = []
|
||||
continue
|
||||
enum_nid = _make_id(stem, enum_name)
|
||||
add_node(enum_nid, enum_name, lineno)
|
||||
add_edge(file_nid if current_class_nid is None else current_class_nid,
|
||||
enum_nid, "contains", lineno)
|
||||
pending_annotations = []
|
||||
continue
|
||||
|
||||
if current_class_nid is not None:
|
||||
mm = method_re.match(stripped)
|
||||
if mm:
|
||||
method_name = mm.group(1)
|
||||
if method_name.lower() not in _CONTROL_FLOW:
|
||||
method_nid = _make_id(current_class_nid, method_name)
|
||||
method_label = f".{method_name}()"
|
||||
add_node(method_nid, method_label, lineno)
|
||||
add_edge(current_class_nid, method_nid, "method", lineno)
|
||||
if "auraenabled" in pending_annotations or "invocablemethod" in pending_annotations:
|
||||
add_edge(file_nid, method_nid, "contains", lineno, confidence="INFERRED")
|
||||
pending_annotations = []
|
||||
continue
|
||||
|
||||
pending_annotations = []
|
||||
|
||||
for sm in soql_re.finditer(line_text):
|
||||
sobject = sm.group(1)
|
||||
sob_nid = _make_id(sobject)
|
||||
if sob_nid not in seen_ids:
|
||||
add_node(sob_nid, sobject, lineno)
|
||||
src = current_class_nid or file_nid
|
||||
add_edge(src, sob_nid, "uses", lineno, confidence="INFERRED")
|
||||
|
||||
for dm in dml_re.finditer(line_text):
|
||||
dml_op = dm.group(1).lower()
|
||||
dml_nid = _make_id(f"dml_{dml_op}")
|
||||
if dml_nid not in seen_ids:
|
||||
add_node(dml_nid, dml_op, lineno)
|
||||
src = current_class_nid or file_nid
|
||||
add_edge(src, dml_nid, "uses", lineno, confidence="INFERRED")
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -0,0 +1,66 @@
|
||||
# DO NOT import from graphify.extract here — direction is extract.py → extractors/ only.
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.ids import make_id
|
||||
|
||||
# Language built-in globals that AST may classify as call targets when used as
|
||||
# constructors or coercion functions (e.g. String(x), Number(x), Boolean(x)).
|
||||
# Without this filter they become god-nodes accumulating spurious edges from
|
||||
# every call site. Filter applied at same-file and cross-file resolution.
|
||||
# See issue #726.
|
||||
_LANGUAGE_BUILTIN_GLOBALS: frozenset[str] = frozenset({
|
||||
# JavaScript / TypeScript ECMAScript built-ins
|
||||
"String", "Number", "Boolean", "Object", "Array", "Symbol", "BigInt",
|
||||
"Date", "RegExp", "Error", "TypeError", "RangeError", "SyntaxError",
|
||||
"ReferenceError", "EvalError", "URIError",
|
||||
"Promise", "Map", "Set", "WeakMap", "WeakSet", "JSON", "Math",
|
||||
"Reflect", "Proxy", "Intl",
|
||||
"parseInt", "parseFloat", "isNaN", "isFinite",
|
||||
"encodeURIComponent", "decodeURIComponent", "encodeURI", "decodeURI",
|
||||
# Browser / Node common globals
|
||||
"URL", "URLSearchParams", "FormData", "Blob", "File",
|
||||
"Headers", "Request", "Response", "AbortController", "AbortSignal",
|
||||
"TextEncoder", "TextDecoder", "console",
|
||||
# Python built-in callables
|
||||
"str", "int", "float", "bool", "list", "dict", "set", "tuple", "bytes",
|
||||
"len", "range", "enumerate", "zip", "map", "filter", "sum", "min", "max",
|
||||
"print", "open", "isinstance", "type", "super", "sorted", "reversed",
|
||||
"any", "all", "abs", "round", "next", "iter", "hash", "id", "repr",
|
||||
"callable", "getattr", "setattr", "hasattr", "delattr", "vars", "dir",
|
||||
})
|
||||
|
||||
|
||||
def _make_id(*parts: str) -> str:
|
||||
return make_id(*parts)
|
||||
|
||||
|
||||
def _file_stem(path: Path) -> str:
|
||||
"""Stem used as the node-ID prefix for a file and its symbols.
|
||||
|
||||
The full path (extension dropped) is preserved as path segments; ``make_id``
|
||||
later collapses the separators to underscores. Using every segment — not just
|
||||
the immediate parent dir (#1504) — means same-named files in different
|
||||
directories get distinct IDs instead of colliding into one
|
||||
last-writer-wins node:
|
||||
|
||||
docs/v1/api/README.md -> docs/v1/api/README -> docs_v1_api_readme
|
||||
docs/v2/api/README.md -> docs/v2/api/README -> docs_v2_api_readme
|
||||
|
||||
Top-level files keep a bare stem (``setup.py`` -> ``setup``). When passed an
|
||||
absolute path the whole path is encoded; the extract() id-remap post-pass
|
||||
re-derives the canonical repo-relative form from ``source_file`` so the on-disk
|
||||
location can't leak into the persisted IDs (#502).
|
||||
|
||||
Returns "" for a path with no name (``Path('.')`` — a source_file that equals
|
||||
the scan root, so it has no per-file stem). Guarding here keeps
|
||||
``path.with_suffix("")`` from raising ``ValueError: '.' has an empty name`` and
|
||||
protects every caller, not just ``_semantic_id_remap`` (#1618)."""
|
||||
if not path.name:
|
||||
return ""
|
||||
return path.with_suffix("").as_posix()
|
||||
|
||||
|
||||
def _read_text(node, source: bytes) -> str:
|
||||
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Bash extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from graphify.extractors.base import _file_stem, _make_id, _read_text
|
||||
|
||||
|
||||
def extract_bash(path: Path) -> dict:
|
||||
"""Extract functions, source imports, and cross-function calls from a .sh file."""
|
||||
try:
|
||||
import tree_sitter_bash as tsbash
|
||||
from tree_sitter import Language, Parser
|
||||
except ImportError:
|
||||
return {"nodes": [], "edges": [], "error": "tree-sitter-bash not installed"}
|
||||
|
||||
try:
|
||||
language = Language(tsbash.language())
|
||||
parser = Parser(language)
|
||||
source = path.read_bytes()
|
||||
tree = parser.parse(source)
|
||||
root = tree.root_node
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
stem = _file_stem(path)
|
||||
str_path = str(path)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
function_bodies: list[tuple[str, Any]] = []
|
||||
defined_functions: set[str] = set()
|
||||
|
||||
from graphify.security import sanitize_metadata # module-level cached import
|
||||
|
||||
def add_node(nid: str, label: str, line: int, kind: str = "code") -> None:
|
||||
if nid and nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({"id": nid, "label": label, "file_type": "code",
|
||||
"source_file": str_path, "source_location": f"L{line}",
|
||||
"metadata": sanitize_metadata({"language": "bash", "kind": kind})}) # noqa: E501
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
confidence: str = "EXTRACTED", weight: float = 1.0,
|
||||
context: str | None = None) -> None:
|
||||
if not src or not tgt or src == tgt:
|
||||
return
|
||||
edge = {"source": src, "target": tgt, "relation": relation,
|
||||
"confidence": confidence, "source_file": str_path,
|
||||
"source_location": f"L{line}", "weight": weight}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
# file_nid is fully path-derived and never produced by _make_id(stem, func_name),
|
||||
# so appending "__entry" guarantees a distinct ID from any function node.
|
||||
entry_nid = file_nid + "__entry"
|
||||
add_node(file_nid, path.name, 1, kind="file")
|
||||
add_node(entry_nid, f"{path.name} script", 1, kind="bash_entrypoint")
|
||||
add_edge(file_nid, entry_nid, "contains", 1)
|
||||
|
||||
_BASH_SOURCE_COMMANDS = frozenset({"source", "."})
|
||||
_BASH_SCRIPT_RUNNERS = frozenset({"bash", "sh", "zsh", "ksh", "dash"})
|
||||
# Parent node types that mean a contained command is part of a substitution
|
||||
# or expansion, not a real function call. Token-level filtering misses
|
||||
# these because `$(build)` exposes `build` as a child command whose name
|
||||
# token has no metacharacters — only the parent does.
|
||||
_BASH_EXPANSION_PARENTS = frozenset({
|
||||
"command_substitution",
|
||||
"process_substitution",
|
||||
})
|
||||
|
||||
def text(node) -> str:
|
||||
return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace")
|
||||
|
||||
def is_inside_expansion(node) -> bool:
|
||||
parent = node.parent
|
||||
while parent is not None:
|
||||
if parent.type in _BASH_EXPANSION_PARENTS:
|
||||
return True
|
||||
parent = parent.parent
|
||||
return False
|
||||
|
||||
def literal(node) -> str | None:
|
||||
# Token-level filter: rejects names containing shell metacharacters.
|
||||
# Combined with `is_inside_expansion` for parent-context rejection.
|
||||
raw = text(node).strip()
|
||||
if not raw:
|
||||
return None
|
||||
if raw[0:1] in {"'", '"'} and raw[-1:] == raw[0]:
|
||||
raw = raw[1:-1]
|
||||
if any(token in raw for token in ("$", "`", "$(", "<(", ">", "|", ";", "&")):
|
||||
return None
|
||||
return raw
|
||||
|
||||
def _bash_func_name(node) -> str | None:
|
||||
"""Get the name from a function_definition node."""
|
||||
# bash grammar: function_definition has a word child (the name)
|
||||
for child in node.children:
|
||||
if child.type == "word":
|
||||
return literal(child)
|
||||
return None
|
||||
|
||||
def walk_calls(body_node, func_nid: str, seen_calls: set) -> None:
|
||||
if body_node is None:
|
||||
return
|
||||
for child in body_node.children:
|
||||
if child.type == "function_definition":
|
||||
# Skip nested function definitions — their bodies are walked
|
||||
# separately, so we don't attribute their calls to the
|
||||
# enclosing scope.
|
||||
continue
|
||||
if child.type == "command" and not is_inside_expansion(child):
|
||||
cmd_name_node = child.child_by_field_name("name")
|
||||
if cmd_name_node is None and child.children:
|
||||
cmd_name_node = child.children[0]
|
||||
if cmd_name_node:
|
||||
name = literal(cmd_name_node)
|
||||
# Defined-functions wins. Skip-lists for external commands
|
||||
# would create false negatives when a user defines a
|
||||
# function shadowing an external (`install`, `find`, etc.).
|
||||
if name and name in defined_functions:
|
||||
tgt = _make_id(stem, name)
|
||||
key = (func_nid, tgt)
|
||||
if tgt and key not in seen_calls:
|
||||
seen_calls.add(key)
|
||||
add_edge(func_nid, tgt, "calls",
|
||||
child.start_point[0] + 1,
|
||||
confidence="EXTRACTED", context="call")
|
||||
walk_calls(child, func_nid, seen_calls)
|
||||
|
||||
def walk(node, parent_nid: str) -> None:
|
||||
t = node.type
|
||||
if t == "function_definition":
|
||||
name = _bash_func_name(node)
|
||||
if name:
|
||||
fn_nid = _make_id(stem, name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(fn_nid, f"{name}()", line, kind="bash_function")
|
||||
add_edge(parent_nid, fn_nid, "defines", line)
|
||||
defined_functions.add(name)
|
||||
# find the compound_statement body
|
||||
body = None
|
||||
for child in node.children:
|
||||
if child.type == "compound_statement":
|
||||
body = child
|
||||
break
|
||||
function_bodies.append((fn_nid, body))
|
||||
# Recurse into the body so nested function definitions are discovered
|
||||
# and added to function_bodies for the second-pass walk_calls.
|
||||
if body is not None:
|
||||
walk(body, fn_nid)
|
||||
return
|
||||
|
||||
if t == "command":
|
||||
if is_inside_expansion(node):
|
||||
return
|
||||
cmd_name_node = node.child_by_field_name("name")
|
||||
if cmd_name_node is None and node.children:
|
||||
cmd_name_node = node.children[0]
|
||||
if cmd_name_node:
|
||||
cmd = literal(cmd_name_node)
|
||||
args = [c for c in node.children
|
||||
if c.type in ("word", "string", "concatenation")
|
||||
and c != cmd_name_node]
|
||||
if cmd in _BASH_SOURCE_COMMANDS and cmd not in defined_functions:
|
||||
# find the path argument (first word after command name)
|
||||
if args:
|
||||
raw = _read_text(args[0], source).strip().strip("'\"")
|
||||
line = node.start_point[0] + 1
|
||||
if raw.startswith((".", "/")):
|
||||
resolved = (path.parent / raw).resolve()
|
||||
# Only emit the edge if the target actually exists on
|
||||
# disk — prevents graph pollution from crafted paths
|
||||
# like `source ../../etc/passwd` that traverse outside
|
||||
# the project tree (B-1).
|
||||
if resolved.exists():
|
||||
tgt_nid = _make_id(str(resolved))
|
||||
add_edge(file_nid, tgt_nid, "imports_from", line,
|
||||
context="import")
|
||||
else:
|
||||
tgt_nid = _make_id(raw)
|
||||
if tgt_nid:
|
||||
add_edge(file_nid, tgt_nid, "imports", line,
|
||||
context="import")
|
||||
elif cmd and cmd not in defined_functions:
|
||||
raw = cmd if cmd.endswith(".sh") else None
|
||||
if cmd in _BASH_SCRIPT_RUNNERS and args:
|
||||
raw = literal(args[0])
|
||||
if raw and raw.endswith(".sh"):
|
||||
resolved = (path.parent / raw).resolve()
|
||||
if resolved.is_file():
|
||||
target_path = resolved
|
||||
if not path.is_absolute():
|
||||
try:
|
||||
target_path = resolved.relative_to(Path.cwd().resolve())
|
||||
except ValueError:
|
||||
pass
|
||||
caller_nid = entry_nid if parent_nid == file_nid else parent_nid
|
||||
add_edge(caller_nid, _make_id(str(target_path)) + "__entry",
|
||||
"calls", node.start_point[0] + 1,
|
||||
context="script_invocation")
|
||||
return
|
||||
|
||||
if t == "declaration_command":
|
||||
# export/declare/readonly VAR=value at program level
|
||||
if node.parent and node.parent.type == "program":
|
||||
for child in node.children:
|
||||
if child.type == "variable_assignment":
|
||||
var_node = child.child_by_field_name("name")
|
||||
if var_node:
|
||||
var = _read_text(var_node, source).strip()
|
||||
if var:
|
||||
var_nid = _make_id(stem, var)
|
||||
line = child.start_point[0] + 1
|
||||
add_node(var_nid, var, line)
|
||||
add_edge(file_nid, var_nid, "defines", line)
|
||||
return
|
||||
|
||||
for child in node.children:
|
||||
walk(child, parent_nid)
|
||||
|
||||
# Pre-pass: collect all defined function names so the source-command handler
|
||||
# in walk() can detect user-defined functions that shadow 'source' / '.'
|
||||
# regardless of definition order in the file.
|
||||
def _prescan_functions(node) -> None:
|
||||
if node.type == "function_definition":
|
||||
name = _bash_func_name(node)
|
||||
if name:
|
||||
defined_functions.add(name)
|
||||
for child in node.children:
|
||||
_prescan_functions(child)
|
||||
else:
|
||||
for child in node.children:
|
||||
_prescan_functions(child)
|
||||
|
||||
_prescan_functions(root)
|
||||
walk(root, file_nid)
|
||||
|
||||
# Second pass: cross-function calls
|
||||
top_seen: set = set()
|
||||
walk_calls(root, entry_nid, top_seen) # top-level calls attributed to the entrypoint
|
||||
for fn_nid, body in function_bodies:
|
||||
walk_calls(body, fn_nid, set())
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Laravel Blade template extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extractors.base import _make_id
|
||||
|
||||
|
||||
def extract_blade(path: Path) -> dict:
|
||||
"""Extract @include, <livewire:> components, and wire:click bindings from Blade templates."""
|
||||
import re
|
||||
try:
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return {"error": f"cannot read {path}"}
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
nodes = [{"id": file_nid, "label": path.name, "file_type": "code",
|
||||
"source_file": str(path), "source_location": None}]
|
||||
edges = []
|
||||
|
||||
# @include('path.to.partial') or @include("path.to.partial")
|
||||
for m in re.finditer(r"@include\(['\"]([^'\"]+)['\"]", src):
|
||||
tgt = m.group(1).replace(".", "/")
|
||||
tgt_nid = _make_id(tgt)
|
||||
if tgt_nid not in {n["id"] for n in nodes}:
|
||||
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
|
||||
"source_file": str(path), "source_location": None})
|
||||
edges.append({"source": file_nid, "target": tgt_nid, "relation": "includes",
|
||||
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
||||
"source_file": str(path), "source_location": None, "weight": 1.0})
|
||||
|
||||
# <livewire:component.name /> or <livewire:component.name>
|
||||
for m in re.finditer(r"<livewire:([\w.\-]+)", src):
|
||||
tgt_nid = _make_id(m.group(1))
|
||||
if tgt_nid not in {n["id"] for n in nodes}:
|
||||
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
|
||||
"source_file": str(path), "source_location": None})
|
||||
edges.append({"source": file_nid, "target": tgt_nid, "relation": "uses_component",
|
||||
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
||||
"source_file": str(path), "source_location": None, "weight": 1.0})
|
||||
|
||||
# wire:click="methodName"
|
||||
for m in re.finditer(r'wire:click=["\']([^"\']+)["\']', src):
|
||||
tgt_nid = _make_id(m.group(1))
|
||||
if tgt_nid not in {n["id"] for n in nodes}:
|
||||
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
|
||||
"source_file": str(path), "source_location": None})
|
||||
edges.append({"source": file_nid, "target": tgt_nid, "relation": "binds_method",
|
||||
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
||||
"source_file": str(path), "source_location": None, "weight": 1.0})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -0,0 +1,393 @@
|
||||
"""C# cross-file resolution.
|
||||
|
||||
The config-driven C# *extractor* (``extract_csharp`` → ``_extract_generic``)
|
||||
still lives in ``graphify/extract.py``; per ``extractors/MIGRATION.md`` the
|
||||
config-driven languages cannot be ported one-by-one until the shared
|
||||
``_extract_generic`` core moves as its own coordinated batch. This module is
|
||||
the C# home for the parts that *are* cleanly separable — today, the cross-file
|
||||
type-reference resolver below — and is where ``extract_csharp`` will land when
|
||||
the core migration happens.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
from pathlib import Path
|
||||
|
||||
from graphify.extractors.base import _make_id
|
||||
|
||||
|
||||
def _build_csharp_type_def_index(all_nodes: list[dict]) -> dict[tuple[str, str], str]:
|
||||
"""Return deterministic ``(namespace, name) -> node_id`` C# type definitions."""
|
||||
candidates: dict[tuple[str, str], list[dict]] = {}
|
||||
for node in all_nodes:
|
||||
if node.get("type") == "namespace":
|
||||
continue
|
||||
metadata = node.get("metadata") or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
if metadata.get("is_nested_type"):
|
||||
continue
|
||||
nid = node.get("id")
|
||||
label = node.get("label")
|
||||
if not (isinstance(nid, str) and nid and isinstance(label, str) and label):
|
||||
continue
|
||||
source_file = node.get("source_file")
|
||||
if (
|
||||
not isinstance(source_file, str)
|
||||
or not source_file.endswith(".cs")
|
||||
or node.get("file_type") != "code"
|
||||
):
|
||||
continue
|
||||
if label.endswith(")") or label.startswith(".") or "." in label:
|
||||
continue
|
||||
namespace = metadata.get("namespace", "")
|
||||
if not isinstance(namespace, str):
|
||||
namespace = ""
|
||||
candidates.setdefault((namespace, label), []).append(node)
|
||||
|
||||
return {
|
||||
key: sorted(
|
||||
nodes,
|
||||
key=lambda node: (
|
||||
str(node.get("source_file") or ""),
|
||||
str(node.get("source_location") or ""),
|
||||
str(node.get("id") or ""),
|
||||
),
|
||||
)[0]["id"]
|
||||
for key, nodes in candidates.items()
|
||||
}
|
||||
|
||||
|
||||
def _strip_trailing_csharp_generic_args(target_fqn: str) -> str:
|
||||
target_fqn = target_fqn.strip()
|
||||
if not target_fqn.endswith(">"):
|
||||
return target_fqn
|
||||
depth = 0
|
||||
for index in range(len(target_fqn) - 1, -1, -1):
|
||||
char = target_fqn[index]
|
||||
if char == ">":
|
||||
depth += 1
|
||||
elif char == "<":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return target_fqn[:index].strip()
|
||||
return target_fqn
|
||||
|
||||
|
||||
def _resolve_cross_file_csharp_imports(
|
||||
per_file: list[dict],
|
||||
paths: list[Path],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
) -> None:
|
||||
"""Re-point resolvable C# ``using`` import edges to canonical internal nodes.
|
||||
|
||||
Namespace imports resolve only to canonical C# namespace nodes. Alias imports
|
||||
resolve only when the alias target's prefix is a known canonical namespace and
|
||||
the simple type name exists in the shared C# type-definition index. ``using
|
||||
static`` and nested type aliases remain deliberate gaps because they need
|
||||
member/nested-type modeling beyond this import pass.
|
||||
"""
|
||||
_ = (per_file, paths)
|
||||
namespace_id_by_label: dict[str, str] = {}
|
||||
for node in sorted(
|
||||
all_nodes,
|
||||
key=lambda node: (
|
||||
str(node.get("source_file") or ""),
|
||||
str(node.get("source_location") or ""),
|
||||
str(node.get("id") or ""),
|
||||
),
|
||||
):
|
||||
if node.get("type") != "namespace":
|
||||
continue
|
||||
label = node.get("label")
|
||||
nid = node.get("id")
|
||||
if isinstance(label, str) and label and isinstance(nid, str) and nid:
|
||||
namespace_id_by_label.setdefault(label, nid)
|
||||
|
||||
type_def_index = _build_csharp_type_def_index(all_nodes)
|
||||
if not namespace_id_by_label and not type_def_index:
|
||||
return
|
||||
|
||||
repointed_from: set[str] = set()
|
||||
for edge in all_edges:
|
||||
if edge.get("relation") != "imports":
|
||||
continue
|
||||
metadata = edge.get("metadata") or {}
|
||||
if not isinstance(metadata, dict):
|
||||
continue
|
||||
using_kind = metadata.get("using_kind")
|
||||
target_fqn = metadata.get("target_fqn")
|
||||
if not using_kind or not isinstance(target_fqn, str) or not target_fqn:
|
||||
continue
|
||||
|
||||
resolved = None
|
||||
if using_kind == "namespace":
|
||||
resolved = namespace_id_by_label.get(target_fqn)
|
||||
elif using_kind == "alias":
|
||||
base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
|
||||
prefix, sep, name = base_fqn.rpartition(".")
|
||||
if sep and prefix in namespace_id_by_label:
|
||||
resolved = type_def_index.get((prefix, name))
|
||||
|
||||
old_target = edge.get("target")
|
||||
if resolved and resolved != old_target:
|
||||
edge["target"] = resolved
|
||||
if isinstance(old_target, str) and old_target:
|
||||
repointed_from.add(old_target)
|
||||
|
||||
if not repointed_from:
|
||||
return
|
||||
|
||||
still_referenced: set[str] = set()
|
||||
for edge in all_edges:
|
||||
still_referenced.add(edge.get("source"))
|
||||
still_referenced.add(edge.get("target"))
|
||||
all_nodes[:] = [
|
||||
node for node in all_nodes
|
||||
if node.get("id") not in repointed_from or node.get("id") in still_referenced
|
||||
]
|
||||
|
||||
|
||||
def _resolve_csharp_type_references(
|
||||
per_file: list[dict],
|
||||
paths: list[Path],
|
||||
all_nodes: list[dict],
|
||||
all_edges: list[dict],
|
||||
) -> None:
|
||||
"""Arbitrate all C# ``inherits``/``implements``/``references`` targets.
|
||||
|
||||
The extractor emits provisional same-file bindings and sourceless stubs. This
|
||||
pass is the single soundness gate: it uses only graph-stamped namespace/import
|
||||
facts, keeps a binding only when the referenced simple name resolves to one
|
||||
in-scope real type definition, and otherwise leaves the edge on a dangling stub.
|
||||
"""
|
||||
_ = (per_file, paths)
|
||||
|
||||
def _is_cs_file(value: object) -> bool:
|
||||
return isinstance(value, str) and value.endswith(".cs")
|
||||
|
||||
def _metadata(value: object) -> dict:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def _namespace(node: dict | None) -> str:
|
||||
metadata = _metadata((node or {}).get("metadata"))
|
||||
namespace = metadata.get("namespace", "")
|
||||
return namespace if isinstance(namespace, str) else ""
|
||||
|
||||
def _append_unique(items: list[str], value: str) -> None:
|
||||
if value not in items:
|
||||
items.append(value)
|
||||
|
||||
node_by_id = {
|
||||
node["id"]: node
|
||||
for node in all_nodes
|
||||
if isinstance(node.get("id"), str) and node.get("id")
|
||||
}
|
||||
type_def_index = _build_csharp_type_def_index(all_nodes)
|
||||
known_namespaces = {
|
||||
node.get("label")
|
||||
for node in all_nodes
|
||||
if node.get("type") == "namespace" and isinstance(node.get("label"), str)
|
||||
}
|
||||
|
||||
# Each using carries its lexical scope: ("file", None) applies file-wide;
|
||||
# ("namespace", scope_id) applies only where scope_id is in the ref's scope_chain.
|
||||
namespace_usings_by_file: dict[str, list[tuple[str, str, str | None]]] = {}
|
||||
aliases_by_file: dict[str, dict[str, list[tuple[str, str, str | None]]]] = {}
|
||||
|
||||
for edge in all_edges:
|
||||
if edge.get("relation") != "imports":
|
||||
continue
|
||||
source_node = node_by_id.get(edge.get("source"))
|
||||
if not (
|
||||
source_node
|
||||
and isinstance(source_node.get("label"), str)
|
||||
and source_node.get("label", "").endswith(".cs")
|
||||
):
|
||||
continue
|
||||
source_file = source_node.get("source_file")
|
||||
if not _is_cs_file(source_file):
|
||||
continue
|
||||
metadata = _metadata(edge.get("metadata"))
|
||||
target_fqn = metadata.get("target_fqn")
|
||||
if not isinstance(target_fqn, str) or not target_fqn:
|
||||
continue
|
||||
scope_kind = metadata.get("scope_kind") or "file"
|
||||
scope_id = metadata.get("scope_id")
|
||||
using_kind = metadata.get("using_kind")
|
||||
if using_kind == "namespace":
|
||||
entry = (target_fqn, scope_kind, scope_id)
|
||||
bucket = namespace_usings_by_file.setdefault(source_file, [])
|
||||
if entry not in bucket:
|
||||
bucket.append(entry)
|
||||
elif using_kind == "alias":
|
||||
alias = metadata.get("alias")
|
||||
if isinstance(alias, str) and alias:
|
||||
entry = (target_fqn, scope_kind, scope_id)
|
||||
bucket = aliases_by_file.setdefault(source_file, {}).setdefault(alias, [])
|
||||
if entry not in bucket:
|
||||
bucket.append(entry)
|
||||
|
||||
def _scope_chain(node: dict) -> list[str]:
|
||||
chain = _metadata(node.get("metadata")).get("scope_chain")
|
||||
return chain if isinstance(chain, list) else []
|
||||
|
||||
def _using_in_scope(scope_kind: str, scope_id: str | None, source_node: dict) -> bool:
|
||||
if scope_kind == "file":
|
||||
return True
|
||||
return scope_id is not None and scope_id in _scope_chain(source_node)
|
||||
|
||||
def _scopes_for(source_node: dict, source_file: str) -> list[str]:
|
||||
scopes: list[str] = []
|
||||
_append_unique(scopes, _namespace(source_node))
|
||||
_append_unique(scopes, "")
|
||||
for namespace, scope_kind, scope_id in namespace_usings_by_file.get(source_file, []):
|
||||
if _using_in_scope(scope_kind, scope_id, source_node):
|
||||
_append_unique(scopes, namespace)
|
||||
return scopes
|
||||
|
||||
def _resolve_alias(label: str, source_node: dict, source_file: str) -> str | None:
|
||||
hits = set()
|
||||
for target_fqn, scope_kind, scope_id in aliases_by_file.get(source_file, {}).get(label, []):
|
||||
if not _using_in_scope(scope_kind, scope_id, source_node):
|
||||
continue
|
||||
base_fqn = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
|
||||
namespace, sep, simple_name = base_fqn.rpartition(".")
|
||||
if not sep:
|
||||
simple_name = namespace
|
||||
namespace = ""
|
||||
if not simple_name:
|
||||
continue
|
||||
hit = type_def_index.get((namespace, simple_name))
|
||||
if hit:
|
||||
hits.add(hit)
|
||||
return next(iter(hits)) if len(hits) == 1 else None
|
||||
|
||||
def _resolve_label(label: str, source_node: dict, source_file: str) -> str | None:
|
||||
if label in aliases_by_file.get(source_file, {}):
|
||||
return _resolve_alias(label, source_node, source_file)
|
||||
candidates: list[str] = []
|
||||
for namespace in _scopes_for(source_node, source_file):
|
||||
hit = type_def_index.get((namespace, label))
|
||||
if hit and hit not in candidates:
|
||||
candidates.append(hit)
|
||||
return candidates[0] if len(candidates) == 1 else None
|
||||
|
||||
def _resolve_qualified(label: str, qualifier: object, source_node: dict, source_file: str) -> str | None:
|
||||
# Sound qualified resolution: an in-scope alias for Q shadows the namespace Q. For a qualified
|
||||
# ref Q.label, look up (alias_target_namespace, label). If no in-scope alias, fall through to an
|
||||
# exact known namespace. Dangle on ambiguity / no hit / unknown qualifier.
|
||||
if not isinstance(qualifier, str) or not qualifier:
|
||||
return None
|
||||
in_scope = [
|
||||
entry for entry in aliases_by_file.get(source_file, {}).get(qualifier, [])
|
||||
if _using_in_scope(entry[1], entry[2], source_node)
|
||||
]
|
||||
if in_scope:
|
||||
hits = set()
|
||||
for target_fqn, _scope_kind, _scope_id in in_scope:
|
||||
alias_ns = _strip_trailing_csharp_generic_args(html.unescape(target_fqn))
|
||||
hit = type_def_index.get((alias_ns, label))
|
||||
if hit:
|
||||
hits.add(hit)
|
||||
return next(iter(hits)) if len(hits) == 1 else None
|
||||
if qualifier in known_namespaces:
|
||||
return type_def_index.get((qualifier, label))
|
||||
return None
|
||||
|
||||
def _is_placeholder(node: dict | None) -> bool:
|
||||
return bool(node) and not node.get("source_file")
|
||||
|
||||
def _is_csharp_relevant_target(node: dict) -> bool:
|
||||
if node.get("type") == "namespace":
|
||||
return True
|
||||
source_file = node.get("source_file")
|
||||
return not source_file or _is_cs_file(source_file)
|
||||
|
||||
def _label_for_type_ref_target(target_node: dict, source_file: str) -> str | None:
|
||||
label = target_node.get("label")
|
||||
if not isinstance(label, str) or not label:
|
||||
return None
|
||||
if not label.endswith(".cs"):
|
||||
return label
|
||||
|
||||
stem = label[:-3]
|
||||
for alias in aliases_by_file.get(source_file, {}):
|
||||
if alias.lower() == stem.lower() or _make_id(alias) == _make_id(stem):
|
||||
return alias
|
||||
return stem or None
|
||||
|
||||
def _dangling_stub_id(label: str, current_target: object) -> str:
|
||||
current = node_by_id.get(current_target)
|
||||
if _is_placeholder(current) and current.get("label") == label:
|
||||
return str(current_target)
|
||||
|
||||
for node in all_nodes:
|
||||
nid = node.get("id")
|
||||
if (
|
||||
isinstance(nid, str)
|
||||
and node.get("label") == label
|
||||
and _is_placeholder(node)
|
||||
):
|
||||
return nid
|
||||
|
||||
stem = _make_id(label)
|
||||
stub_id = stem
|
||||
if stub_id in node_by_id:
|
||||
stub_id = _make_id("csharp_type_ref", label)
|
||||
suffix = 2
|
||||
while stub_id in node_by_id:
|
||||
stub_id = _make_id("csharp_type_ref", label, str(suffix))
|
||||
suffix += 1
|
||||
node = {
|
||||
"id": stub_id,
|
||||
"label": label,
|
||||
"file_type": "code",
|
||||
"source_file": "",
|
||||
"source_location": "",
|
||||
}
|
||||
all_nodes.append(node)
|
||||
node_by_id[stub_id] = node
|
||||
return stub_id
|
||||
|
||||
REPOINT_RELATIONS = {"implements", "inherits", "references"}
|
||||
repointed_from: set[str] = set()
|
||||
for edge in all_edges:
|
||||
if edge.get("relation") not in REPOINT_RELATIONS:
|
||||
continue
|
||||
source_file = edge.get("source_file")
|
||||
if not _is_cs_file(source_file):
|
||||
continue
|
||||
source_node = node_by_id.get(edge.get("source"))
|
||||
target_node = node_by_id.get(edge.get("target"))
|
||||
if not source_node or not target_node:
|
||||
continue
|
||||
if not _is_csharp_relevant_target(target_node):
|
||||
continue
|
||||
metadata = _metadata(edge.get("metadata"))
|
||||
label = metadata.get("ref_token") or _label_for_type_ref_target(target_node, source_file)
|
||||
if not label:
|
||||
continue
|
||||
if metadata.get("qualified"):
|
||||
resolved = _resolve_qualified(label, metadata.get("ref_qualifier"), source_node, source_file)
|
||||
else:
|
||||
resolved = _resolve_label(label, source_node, source_file)
|
||||
target = edge.get("target")
|
||||
desired = resolved or _dangling_stub_id(label, target)
|
||||
if desired != target:
|
||||
edge["target"] = desired
|
||||
if isinstance(target, str) and _is_placeholder(target_node):
|
||||
repointed_from.add(target)
|
||||
|
||||
if not repointed_from:
|
||||
return
|
||||
|
||||
still_referenced: set[str] = set()
|
||||
for edge in all_edges:
|
||||
still_referenced.add(edge.get("source"))
|
||||
still_referenced.add(edge.get("target"))
|
||||
all_nodes[:] = [
|
||||
node for node in all_nodes
|
||||
if node.get("id") not in repointed_from or node.get("id") in still_referenced
|
||||
]
|
||||
@@ -0,0 +1,528 @@
|
||||
"""Dart extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from pathlib import Path
|
||||
from graphify.extractors.base import _file_stem, _make_id
|
||||
|
||||
|
||||
def extract_dart(path: Path) -> dict:
|
||||
"""Extract classes, mixins, functions, imports, generic calls, and annotations from a .dart file using regex."""
|
||||
try:
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return {"error": f"cannot read {path}"}
|
||||
|
||||
# Remove inline and multi-line comments while leaving string literals untouched to prevent stripping URLs/paths inside strings
|
||||
comment_string_pattern = re.compile(
|
||||
r'"""(?:\\.|[\s\S])*?"""'
|
||||
r"|'''(?:\\.|[\s\S])*?'''"
|
||||
r'|"(?:\\.|[^"\\])*"'
|
||||
r"|'(?:\\.|[^'\\])*'"
|
||||
r"|/\*[\s\S]*?\*/"
|
||||
r"|//[^\n]*"
|
||||
)
|
||||
def _comment_replace(match: re.Match) -> str:
|
||||
token = match.group(0)
|
||||
if token.startswith("/"):
|
||||
return ""
|
||||
return token
|
||||
src_clean = comment_string_pattern.sub(_comment_replace, src)
|
||||
|
||||
stem = _file_stem(path)
|
||||
file_nid = _make_id(str(path))
|
||||
|
||||
# Check if this is a part-of file and redirect to parent
|
||||
part_of_match = re.search(r"^\s*part\s+of\s+['\"]([^'\"]+)['\"]", src_clean, re.MULTILINE)
|
||||
is_part = False
|
||||
if part_of_match:
|
||||
parent_ref = part_of_match.group(1)
|
||||
if parent_ref.endswith(".dart"):
|
||||
try:
|
||||
parent_path = (path.parent / parent_ref).resolve()
|
||||
if parent_path.exists():
|
||||
stem = _file_stem(parent_path)
|
||||
file_nid = _make_id(str(parent_path))
|
||||
is_part = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
nodes = []
|
||||
if not is_part:
|
||||
nodes.append({"id": file_nid, "label": path.name, "file_type": "code",
|
||||
"source_file": str(path), "source_location": None})
|
||||
edges = []
|
||||
defined: set[str] = set()
|
||||
|
||||
def add_node(nid: str, label: str, ftype: str = "code", source_file: str | None = str(path)) -> None:
|
||||
if nid not in defined:
|
||||
nodes.append({"id": nid, "label": label, "file_type": ftype,
|
||||
"source_file": source_file, "source_location": None})
|
||||
defined.add(nid)
|
||||
|
||||
def add_edge(src_id: str, tgt_id: str, relation: str, weight: float = 1.0, context: str | None = None) -> None:
|
||||
edge = {"source": src_id, "target": tgt_id, "relation": relation,
|
||||
"confidence": "EXTRACTED", "confidence_score": 1.0,
|
||||
"source_file": str(path), "source_location": None, "weight": weight}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
def _split_types(text: str) -> list[str]:
|
||||
parts = []
|
||||
current = []
|
||||
depth = 0
|
||||
for char in text:
|
||||
if char == "<":
|
||||
depth += 1
|
||||
current.append(char)
|
||||
elif char == ">":
|
||||
depth -= 1
|
||||
current.append(char)
|
||||
elif char == "," and depth == 0:
|
||||
parts.append("".join(current).strip())
|
||||
current = []
|
||||
else:
|
||||
current.append(char)
|
||||
if current:
|
||||
parts.append("".join(current).strip())
|
||||
return [p for p in parts if p]
|
||||
|
||||
def _find_matching_brace(text: str, start_pos: int) -> int:
|
||||
brace_count = 0
|
||||
in_double_quote = False
|
||||
in_single_quote = False
|
||||
escape = False
|
||||
|
||||
first_brace = text.find("{", start_pos)
|
||||
if first_brace == -1:
|
||||
return len(text)
|
||||
|
||||
brace_count = 1
|
||||
i = first_brace + 1
|
||||
n = len(text)
|
||||
while i < n:
|
||||
char = text[i]
|
||||
if escape:
|
||||
escape = False
|
||||
i += 1
|
||||
continue
|
||||
if char == "\\":
|
||||
escape = True
|
||||
i += 1
|
||||
continue
|
||||
if text[i:i+3] == '"""' and not in_single_quote:
|
||||
i += 3
|
||||
end = text.find('"""', i)
|
||||
i = end + 3 if end != -1 else n
|
||||
continue
|
||||
if text[i:i+3] == "'''" and not in_double_quote:
|
||||
i += 3
|
||||
end = text.find("'''", i)
|
||||
i = end + 3 if end != -1 else n
|
||||
continue
|
||||
if char == '"' and not in_single_quote:
|
||||
in_double_quote = not in_double_quote
|
||||
elif char == "'" and not in_double_quote:
|
||||
in_single_quote = not in_single_quote
|
||||
elif not in_double_quote and not in_single_quote:
|
||||
if char == "{":
|
||||
brace_count += 1
|
||||
elif char == "}":
|
||||
brace_count -= 1
|
||||
if brace_count == 0:
|
||||
return i + 1
|
||||
i += 1
|
||||
return len(text)
|
||||
|
||||
# 1. Classes, mixins, and enums declarations (with inheritance, mixins, interfaces, and generics)
|
||||
# Supports multiple combined modifiers (e.g., abstract base class, mixin class) without capturing "class" as a name
|
||||
class_pattern = r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)"
|
||||
for m in re.finditer(class_pattern, src_clean, re.MULTILINE):
|
||||
class_name = m.group(1)
|
||||
class_nid = _make_id(stem, class_name)
|
||||
add_node(class_nid, class_name)
|
||||
add_edge(file_nid, class_nid, "defines")
|
||||
|
||||
# Manually parse extends/on, with, and implements in header to handle nested generics brackets balanced
|
||||
start_idx = m.end()
|
||||
rest = src_clean[start_idx : start_idx + 500]
|
||||
|
||||
# Skip class generic parameters
|
||||
if rest.lstrip().startswith("<"):
|
||||
offset = rest.find("<")
|
||||
depth = 1
|
||||
i = offset + 1
|
||||
while i < len(rest) and depth > 0:
|
||||
if rest[i] == "<": depth += 1
|
||||
elif rest[i] == ">": depth -= 1
|
||||
i += 1
|
||||
rest = rest[i:]
|
||||
|
||||
# Skip primary constructor (e.g. extension type MyExt(int id))
|
||||
if rest.lstrip().startswith("("):
|
||||
offset = rest.find("(")
|
||||
depth = 1
|
||||
i = offset + 1
|
||||
while i < len(rest) and depth > 0:
|
||||
if rest[i] == "(": depth += 1
|
||||
elif rest[i] == ")": depth -= 1
|
||||
i += 1
|
||||
rest = rest[i:]
|
||||
|
||||
header_end = rest.find("{")
|
||||
if header_end == -1:
|
||||
header_end = rest.find(";")
|
||||
if header_end == -1:
|
||||
header_end = len(rest)
|
||||
header = rest[:header_end]
|
||||
|
||||
base_class = None
|
||||
generics = None
|
||||
mixins_list = []
|
||||
interfaces_list = []
|
||||
|
||||
# Parse extends or on
|
||||
extends_m = re.search(r"^\s*(?:extends|on)\s+([a-zA-Z0-9_.]+)", header)
|
||||
if extends_m:
|
||||
base_class = extends_m.group(1)
|
||||
rest_header = header[extends_m.end():]
|
||||
if rest_header.strip().startswith("<"):
|
||||
start_idx = rest_header.find("<")
|
||||
depth = 1
|
||||
i = start_idx + 1
|
||||
while i < len(rest_header) and depth > 0:
|
||||
if rest_header[i] == "<":
|
||||
depth += 1
|
||||
elif rest_header[i] == ">":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
generics = rest_header[start_idx + 1 : i]
|
||||
break
|
||||
i += 1
|
||||
if generics is not None:
|
||||
header = rest_header[i + 1:]
|
||||
else:
|
||||
header = rest_header
|
||||
else:
|
||||
header = rest_header
|
||||
|
||||
# Parse with
|
||||
with_m = re.search(r"^\s*with\s+", header)
|
||||
if with_m:
|
||||
rest_header = header[with_m.end():]
|
||||
impl_idx = rest_header.find("implements")
|
||||
if impl_idx != -1:
|
||||
mixins_str = rest_header[:impl_idx]
|
||||
header = rest_header[impl_idx:]
|
||||
else:
|
||||
mixins_str = rest_header
|
||||
header = ""
|
||||
mixins_list = _split_types(mixins_str)
|
||||
|
||||
# Parse implements
|
||||
impl_m = re.search(r"^\s*implements\s+", header)
|
||||
if impl_m:
|
||||
interfaces_list = _split_types(header[impl_m.end():])
|
||||
|
||||
# Map extends inheritance relation
|
||||
if base_class:
|
||||
base_nid = _make_id(base_class)
|
||||
add_node(base_nid, base_class, source_file=None)
|
||||
add_edge(class_nid, base_nid, "inherits")
|
||||
|
||||
# Map generic type arguments (e.g. MyBloc extends Bloc<MyEvent, MyState>)
|
||||
if generics:
|
||||
for gen in _split_types(generics):
|
||||
gen_clean = gen.split("<")[0].strip()
|
||||
if gen_clean not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
|
||||
gen_nid = _make_id(gen_clean)
|
||||
add_node(gen_nid, gen_clean, source_file=None)
|
||||
add_edge(class_nid, gen_nid, "references")
|
||||
|
||||
# Map mixins
|
||||
for mixin in mixins_list:
|
||||
mixin_clean = mixin.split("<")[0].strip()
|
||||
mixin_nid = _make_id(mixin_clean)
|
||||
add_node(mixin_nid, mixin_clean, source_file=None)
|
||||
add_edge(class_nid, mixin_nid, "mixes_in")
|
||||
|
||||
# Map interfaces
|
||||
for interface in interfaces_list:
|
||||
interface_clean = interface.split("<")[0].strip()
|
||||
interface_nid = _make_id(interface_clean)
|
||||
add_node(interface_nid, interface_clean, source_file=None)
|
||||
add_edge(class_nid, interface_nid, "implements")
|
||||
|
||||
# Extract class body for precise framework dependencies and event handling
|
||||
start_idx = m.start()
|
||||
brace_pos = src_clean.find("{", start_idx)
|
||||
semi_pos = src_clean.find(";", start_idx)
|
||||
|
||||
has_body = brace_pos != -1
|
||||
if has_body and semi_pos != -1 and semi_pos < brace_pos:
|
||||
has_body = False
|
||||
|
||||
if has_body:
|
||||
end_pos = _find_matching_brace(src_clean, start_idx)
|
||||
class_body = src_clean[brace_pos:end_pos]
|
||||
|
||||
# Bloc event registration: on<MyEvent>()
|
||||
for em in re.finditer(r"\bon<(\w+)>\s*\(", class_body):
|
||||
event_name = em.group(1)
|
||||
event_nid = _make_id(event_name)
|
||||
add_node(event_nid, event_name, source_file=None)
|
||||
add_edge(class_nid, event_nid, "calls", context="bloc_event")
|
||||
|
||||
# Bloc state emissions: emit(MyState) or yield MyState
|
||||
for sm in re.finditer(r"\b(?:emit|yield)\s*\(?\s*(?:const\s+)?([A-Z]\w*)\b", class_body):
|
||||
state_name = sm.group(1)
|
||||
if state_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
|
||||
state_nid = _make_id(state_name)
|
||||
add_node(state_nid, state_name, source_file=None)
|
||||
add_edge(class_nid, state_nid, "calls", context="emit_state")
|
||||
|
||||
# Bloc event additions: widget.add(MyEvent()) or bloc.add(MyEvent())
|
||||
for am in re.finditer(r"\b(?:\w*[Bb]loc\w*|context\.read<\w+>\(\))\.add\(\s*(?:const\s+)?([A-Z]\w*)\b", class_body):
|
||||
event_name = am.group(1)
|
||||
if event_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
|
||||
event_nid = _make_id(event_name)
|
||||
add_node(event_nid, event_name, source_file=None)
|
||||
add_edge(class_nid, event_nid, "calls", context="bloc_add_event")
|
||||
|
||||
# Riverpod provider references: ref.watch(provider)
|
||||
for rm in re.finditer(r"\bref\.(?:watch|read|listen)\s*\(\s*(\w+)\b", class_body):
|
||||
provider_name = rm.group(1)
|
||||
provider_nid = _make_id(provider_name)
|
||||
add_node(provider_nid, provider_name, source_file=None)
|
||||
add_edge(class_nid, provider_nid, "references", context="riverpod_reference")
|
||||
|
||||
# Widget to Bloc references: BlocBuilder<MyBloc, ...>
|
||||
for bm in re.finditer(r"\bBloc(?:Builder|Listener|Consumer|Provider|Selector)\s*<\s*([a-zA-Z0-9_]+)\b", class_body):
|
||||
bloc_name = bm.group(1)
|
||||
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
|
||||
bloc_nid = _make_id(bloc_name)
|
||||
add_node(bloc_nid, bloc_name, source_file=None)
|
||||
add_edge(class_nid, bloc_nid, "references", context="bloc_widget_binding")
|
||||
|
||||
# context.read<MyBloc>() or BlocProvider.of<MyBloc>(context)
|
||||
for lm in re.finditer(r"\b(?:read|watch|select|of)\s*<([a-zA-Z0-9_]+)>", class_body):
|
||||
bloc_name = lm.group(1)
|
||||
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
|
||||
bloc_nid = _make_id(bloc_name)
|
||||
add_node(bloc_nid, bloc_name, source_file=None)
|
||||
add_edge(class_nid, bloc_nid, "references", context="bloc_lookup")
|
||||
|
||||
# 2. Annotations mapping (class, mixin, enum, or function level annotations)
|
||||
# Support: @riverpod, @Riverpod(...), @injectable, @singleton, @RoutePage(), @HiveType(typeId: 0), @RestApi()
|
||||
# Matches `@annotation` and links it to the next class/mixin/enum/function declaration in the file
|
||||
annotation_pattern = r"@(\w+)(?:\([^)]*\))?"
|
||||
for am in re.finditer(annotation_pattern, src_clean):
|
||||
annotation_name = am.group(1)
|
||||
if annotation_name in {"override", "deprecated", "required", "protected", "mustCallSuper"}:
|
||||
continue
|
||||
annotation_pos = am.end()
|
||||
intervening_text = src_clean[annotation_pos : annotation_pos + 300]
|
||||
|
||||
class_m = re.search(r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)", intervening_text, re.MULTILINE)
|
||||
func_m = re.search(r"^\s*(?:factory\s+|static\s+|async\s+|external\s+|abstract\s+)?(?:\([^)]+\)|[a-zA-Z0-9_<>,.?]+)(?:\s+[a-zA-Z0-9_<>,.?]+){0,3}\s+(\w+)\s*\(", intervening_text, re.MULTILINE)
|
||||
|
||||
target_nid = None
|
||||
target_name = None
|
||||
target_type = None
|
||||
|
||||
if class_m and func_m:
|
||||
if class_m.start() < func_m.start():
|
||||
target_name = class_m.group(1)
|
||||
target_type = "class"
|
||||
target_nid = _make_id(stem, target_name)
|
||||
else:
|
||||
target_name = func_m.group(1)
|
||||
target_type = "function"
|
||||
target_nid = _make_id(stem, target_name)
|
||||
elif class_m:
|
||||
target_name = class_m.group(1)
|
||||
target_type = "class"
|
||||
target_nid = _make_id(stem, target_name)
|
||||
elif func_m:
|
||||
target_name = func_m.group(1)
|
||||
target_type = "function"
|
||||
target_nid = _make_id(stem, target_name)
|
||||
|
||||
if target_nid and target_name:
|
||||
actual_intervening = intervening_text[:min(class_m.start() if class_m else 300, func_m.start() if func_m else 300)]
|
||||
if ";" not in actual_intervening and "}" not in actual_intervening and "{" not in actual_intervening:
|
||||
annotation_nid = _make_id("annotation", annotation_name.lower())
|
||||
add_node(annotation_nid, f"@{annotation_name}", ftype="concept", source_file=None)
|
||||
add_edge(target_nid, annotation_nid, "configures")
|
||||
|
||||
# Riverpod specific provider generation mapping (supports camelCase class and functional providers)
|
||||
if annotation_name.lower() == "riverpod":
|
||||
if target_type == "class":
|
||||
provider_name = target_name[0].lower() + target_name[1:] + "Provider" if len(target_name) > 1 else target_name.lower() + "Provider"
|
||||
else:
|
||||
provider_name = target_name + "Provider"
|
||||
provider_nid = _make_id(provider_name)
|
||||
add_node(provider_nid, provider_name, ftype="concept", source_file=str(path))
|
||||
add_edge(target_nid, provider_nid, "defines", context="riverpod_provider")
|
||||
|
||||
# 2.5 Typedefs (Type Aliases)
|
||||
typedef_pattern = r"^\s*typedef\s+(\w+)\s*(?:<[^>]+>)?\s*=\s*([a-zA-Z0-9_<>,.?\s]+);"
|
||||
for m in re.finditer(typedef_pattern, src_clean, re.MULTILINE):
|
||||
typedef_name = m.group(1)
|
||||
target_type = m.group(2).split("<")[0].split(".")[-1].strip()
|
||||
if target_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void", "Function"}:
|
||||
typedef_nid = _make_id(stem, typedef_name)
|
||||
add_node(typedef_nid, typedef_name)
|
||||
add_edge(file_nid, typedef_nid, "defines")
|
||||
target_nid = _make_id(target_type)
|
||||
add_node(target_nid, target_type, source_file=None)
|
||||
add_edge(typedef_nid, target_nid, "references", context="typedef")
|
||||
|
||||
# 3. Extensions (extension MyExt on MyClass)
|
||||
ext_pattern = r"^\s{0,4}extension\s+(\w+)?(?:<[^>]+>)?\s+on\s+(\w+)"
|
||||
for m in re.finditer(ext_pattern, src_clean, re.MULTILINE):
|
||||
ext_name = m.group(1) or f"{stem}_anonymous_extension"
|
||||
target_class = m.group(2)
|
||||
|
||||
ext_nid = _make_id(stem, ext_name)
|
||||
label = m.group(1) or f"Extension on {target_class}"
|
||||
add_node(ext_nid, label)
|
||||
add_edge(file_nid, ext_nid, "defines")
|
||||
|
||||
target_nid = _make_id(target_class)
|
||||
add_node(target_nid, target_class, source_file=None)
|
||||
add_edge(ext_nid, target_nid, "extends")
|
||||
|
||||
# 4. Top-level and class-level variable declarations (generic variables, records, late, and destructuring)
|
||||
# Restrict indentation to 0-2 spaces to avoid matching local variables inside functions or switch expressions
|
||||
var_pattern = r"^\s{0,2}(?:late\s+)?(?:(?:final|const|var)\s+)?(?:\([^)]+\)\s+|([a-zA-Z0-9_<>,.?]+(?:\s+[a-zA-Z0-9_<>,.?]+){0,3})\s+)?(?:(\w+)|(?:\w+\s*)?\(([^)]+)\))\s*(?:=|$|;)"
|
||||
for m in re.finditer(var_pattern, src_clean, re.MULTILINE):
|
||||
var_type = m.group(1)
|
||||
single_name = m.group(2)
|
||||
destructured_names = m.group(3)
|
||||
|
||||
if not re.match(r"^\s*(?:late|final|const|var)\b", m.group(0)) and not var_type:
|
||||
continue
|
||||
|
||||
if single_name:
|
||||
if single_name not in {"if", "for", "while", "switch", "catch", "return"}:
|
||||
var_nid = _make_id(stem, single_name)
|
||||
add_node(var_nid, single_name)
|
||||
add_edge(file_nid, var_nid, "defines")
|
||||
|
||||
if var_type and var_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void"}:
|
||||
clean_type = var_type.split("<")[0].split(".")[-1].strip()
|
||||
type_nid = _make_id(clean_type)
|
||||
add_node(type_nid, clean_type, source_file=None)
|
||||
add_edge(file_nid, type_nid, "references", context="variable_type")
|
||||
elif destructured_names:
|
||||
for name in [n.strip() for n in destructured_names.split(",") if n.strip()]:
|
||||
if ":" in name:
|
||||
name = name.split(":")[-1].strip()
|
||||
if re.match(r"^[a-zA-Z_]\w*$", name) and not re.match(r"^[A-Z]", name):
|
||||
if name not in {"if", "for", "while", "switch", "catch", "return"}:
|
||||
var_nid = _make_id(stem, name)
|
||||
add_node(var_nid, name)
|
||||
add_edge(file_nid, var_nid, "defines")
|
||||
|
||||
# 5. Top-level and member functions/methods (supports typed/generic/record return types and Riverpod/Bloc references)
|
||||
# Restrict indentation to 0-2 spaces to avoid matching nested local functions or methods inside multiline switch statements
|
||||
method_pattern = r"^\s{0,2}(?:factory\s+|static\s+|async\s+|external\s+|abstract\s+)?(?:\([^)]+\)|[a-zA-Z0-9_<>,.?]+)(?:\s+[a-zA-Z0-9_<>,.?]+){0,3}\s+(\w+(?:\.\w+)?)\s*\("
|
||||
for m in re.finditer(method_pattern, src_clean, re.MULTILINE):
|
||||
raw_name = m.group(1)
|
||||
name = raw_name.split(".")[-1]
|
||||
if name in {"if", "for", "while", "switch", "catch", "return", "void", "dynamic", "final", "const", "get", "set"}:
|
||||
continue
|
||||
if re.match(r"^[A-Z]", name):
|
||||
continue
|
||||
nid = _make_id(stem, name)
|
||||
add_node(nid, name)
|
||||
add_edge(file_nid, nid, "defines")
|
||||
|
||||
# Get function body using matching brace to extract Riverpod reference patterns
|
||||
start_idx = m.start()
|
||||
brace_pos = src_clean.find("{", start_idx)
|
||||
semi_pos = src_clean.find(";", start_idx)
|
||||
arrow_pos = src_clean.find("=>", start_idx)
|
||||
|
||||
has_body = brace_pos != -1
|
||||
if has_body and semi_pos != -1 and semi_pos < brace_pos:
|
||||
has_body = False
|
||||
if has_body and arrow_pos != -1 and arrow_pos < brace_pos:
|
||||
has_body = False
|
||||
|
||||
if has_body:
|
||||
end_pos = _find_matching_brace(src_clean, start_idx)
|
||||
func_body = src_clean[brace_pos:end_pos]
|
||||
|
||||
# Extract Riverpod provider references: ref.watch(provider)
|
||||
for rm in re.finditer(r"\bref\.(?:watch|read|listen)\s*\(\s*(\w+)\b", func_body):
|
||||
provider_name = rm.group(1)
|
||||
provider_nid = _make_id(provider_name)
|
||||
add_node(provider_nid, provider_name, source_file=None)
|
||||
add_edge(nid, provider_nid, "references", context="riverpod_reference")
|
||||
|
||||
# Extract Bloc event additions: widget.add(MyEvent()) or bloc.add(MyEvent())
|
||||
for am in re.finditer(r"\b(?:\w*[Bb]loc\w*|context\.read<\w+>\(\))\.add\(\s*(?:const\s+)?([A-Z]\w*)\b", func_body):
|
||||
event_name = am.group(1)
|
||||
if event_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
|
||||
event_nid = _make_id(event_name)
|
||||
add_node(event_nid, event_name, source_file=None)
|
||||
add_edge(nid, event_nid, "calls", context="bloc_add_event")
|
||||
|
||||
# context.read<MyBloc>() or BlocProvider.of<MyBloc>(context)
|
||||
for lm in re.finditer(r"\b(?:read|watch|select|of)\s*<([a-zA-Z0-9_]+)>", func_body):
|
||||
bloc_name = lm.group(1)
|
||||
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
|
||||
bloc_nid = _make_id(bloc_name)
|
||||
add_node(bloc_nid, bloc_name, source_file=None)
|
||||
add_edge(nid, bloc_nid, "references", context="bloc_lookup")
|
||||
|
||||
# Universal Navigation Patters (GoRouter, AutoRoute, Navigator)
|
||||
for nm in re.finditer(r"\b(?:go|push|goNamed|pushNamed|replace|replaceNamed)\s*\(\s*(?:context\s*,\s*)?['\"]([a-zA-Z0-9_/?=&%-]+)['\"]", func_body):
|
||||
route_path = nm.group(1)
|
||||
route_nid = _make_id("route", route_path.replace("/", "_").replace("?", "_").replace("=", "_").replace("&", "_"))
|
||||
add_node(route_nid, f"Route {route_path}", ftype="concept", source_file=None)
|
||||
add_edge(nid, route_nid, "navigates", context="route_path")
|
||||
|
||||
for cm in re.finditer(r"\b(?:go|push|goNamed|pushNamed|replace|replaceNamed)\s*\(\s*(?:context\s*,\s*)?([A-Z][a-zA-Z0-9_]*\.[a-zA-Z0-9_]+)", func_body):
|
||||
route_const = cm.group(1)
|
||||
route_nid = _make_id("route", route_const.replace(".", "_"))
|
||||
add_node(route_nid, route_const, ftype="concept", source_file=None)
|
||||
add_edge(nid, route_nid, "navigates", context="route_const")
|
||||
|
||||
for om in re.finditer(r"\b(?:push|replace)\s*\(\s*(?:context\s*,\s*)?.*?\b([A-Z]\w*(?:Route|Screen|Page))\b", func_body):
|
||||
route_class = om.group(1)
|
||||
route_nid = _make_id(route_class)
|
||||
add_node(route_nid, route_class, source_file=None)
|
||||
add_edge(nid, route_nid, "navigates", context="route_object")
|
||||
|
||||
# 6. Imports and Exports
|
||||
for m in re.finditer(r"""^\s*import\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE):
|
||||
pkg = m.group(1)
|
||||
tgt_nid = _make_id(pkg)
|
||||
add_node(tgt_nid, pkg, source_file=None)
|
||||
add_edge(file_nid, tgt_nid, "imports")
|
||||
|
||||
for m in re.finditer(r"""^\s*export\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE):
|
||||
pkg = m.group(1)
|
||||
tgt_nid = _make_id(pkg)
|
||||
add_node(tgt_nid, pkg, source_file=None)
|
||||
add_edge(file_nid, tgt_nid, "exports")
|
||||
|
||||
# 7. Generic Invocations / Type Lookups (Universal Dependency Lookup)
|
||||
# Matches any method call with type parameters: methodName<Type>() or object.methodName<Type>()
|
||||
# Automatically extracts GetIt, Injectable, Riverpod, Provider, BlocProvider, and InheritedWidget type lookups!
|
||||
generic_call_pattern = r"\b\w+<([a-zA-Z0-9_.]+(?:<[a-zA-Z0-9_.,\s<>]+>)?)\s*>\s*\("
|
||||
type_blacklist = {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "Future", "Stream", "void"}
|
||||
for m in re.finditer(generic_call_pattern, src_clean):
|
||||
type_name = m.group(1).split(".")[-1].strip()
|
||||
clean_name = type_name.split("<")[0].strip()
|
||||
if clean_name not in type_blacklist:
|
||||
target_nid = _make_id(clean_name)
|
||||
add_node(target_nid, clean_name, source_file=None)
|
||||
add_edge(file_nid, target_nid, "references", context="type_lookup")
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Dm extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from graphify.extractors.base import _file_stem, _make_id, _read_text
|
||||
|
||||
|
||||
def extract_dm(path: Path) -> dict:
|
||||
"""Extract types, procs, includes, and calls from a .dm/.dme file."""
|
||||
try:
|
||||
import tree_sitter_dm as tsdm
|
||||
from tree_sitter import Language, Parser
|
||||
except ImportError:
|
||||
return {"nodes": [], "edges": [], "error": "tree-sitter-dm not installed"}
|
||||
try:
|
||||
language = Language(tsdm.language())
|
||||
parser = Parser(language)
|
||||
source = path.read_bytes()
|
||||
tree = parser.parse(source)
|
||||
root = tree.root_node
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
stem = _file_stem(path)
|
||||
str_path = str(path)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
function_bodies: list[tuple[str, Any, "str | None"]] = []
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid and nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({"id": nid, "label": label, "file_type": "code",
|
||||
"source_file": str_path, "source_location": f"L{line}"})
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
confidence: str = "EXTRACTED", weight: float = 1.0,
|
||||
context: str | None = None) -> None:
|
||||
if not src or not tgt or src == tgt:
|
||||
return
|
||||
edge: dict = {"source": src, "target": tgt, "relation": relation,
|
||||
"confidence": confidence, "source_file": str_path,
|
||||
"source_location": f"L{line}", "weight": weight}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
def _type_path_text(node) -> str:
|
||||
return _read_text(node, source).strip()
|
||||
|
||||
def _ensure_type(path_text: str, line: int) -> str:
|
||||
nid = _make_id(stem, path_text)
|
||||
add_node(nid, path_text, line)
|
||||
return nid
|
||||
|
||||
def _find_child(node, type_name: str):
|
||||
for c in node.children:
|
||||
if c.type == type_name:
|
||||
return c
|
||||
return None
|
||||
|
||||
def _read_include_path(file_node) -> str:
|
||||
if file_node is None:
|
||||
return ""
|
||||
if file_node.type == "string_literal":
|
||||
parts = []
|
||||
for c in file_node.children:
|
||||
if c.type == "string_content":
|
||||
parts.append(_read_text(c, source))
|
||||
return "".join(parts)
|
||||
return _read_text(file_node, source).strip("'\"")
|
||||
|
||||
def walk(node, parent_type_path: "str | None" = None,
|
||||
parent_type_nid: "str | None" = None) -> None:
|
||||
t = node.type
|
||||
line = node.start_point[0] + 1
|
||||
|
||||
if t == "preproc_include":
|
||||
file_node = node.child_by_field_name("file")
|
||||
raw = _read_include_path(file_node)
|
||||
if raw:
|
||||
norm = raw.replace("\\", "/").lstrip("./")
|
||||
resolved = (path.parent / norm).resolve()
|
||||
edge: dict = {
|
||||
"source": file_nid,
|
||||
"target": _make_id(str(resolved)) if resolved.exists() else _make_id(norm),
|
||||
"relation": "imports_from" if resolved.exists() else "imports",
|
||||
"context": "import",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
}
|
||||
if not resolved.exists():
|
||||
edge["external"] = True
|
||||
edges.append(edge)
|
||||
return
|
||||
|
||||
if t == "type_definition":
|
||||
tp_node = _find_child(node, "type_path")
|
||||
if tp_node is None:
|
||||
return
|
||||
type_path_str = _type_path_text(tp_node)
|
||||
type_nid = _ensure_type(type_path_str, line)
|
||||
add_edge(file_nid, type_nid, "contains", line)
|
||||
body = _find_child(node, "type_body")
|
||||
if body is not None:
|
||||
for c in body.children:
|
||||
walk(c, parent_type_path=type_path_str, parent_type_nid=type_nid)
|
||||
return
|
||||
|
||||
if t in ("type_body_intended", "type_body_braced"):
|
||||
for c in node.children:
|
||||
walk(c, parent_type_path, parent_type_nid)
|
||||
return
|
||||
|
||||
if t in ("type_proc_definition", "type_proc_override"):
|
||||
if parent_type_nid is None or parent_type_path is None:
|
||||
return
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node is None:
|
||||
return
|
||||
proc_name = _read_text(name_node, source)
|
||||
proc_nid = _make_id(stem, parent_type_path, proc_name)
|
||||
add_node(proc_nid, f"{parent_type_path}/{proc_name}()", line)
|
||||
add_edge(parent_type_nid, proc_nid, "method", line)
|
||||
block = _find_child(node, "block")
|
||||
if block is not None:
|
||||
function_bodies.append((proc_nid, block, parent_type_path))
|
||||
return
|
||||
|
||||
if t in ("proc_definition", "proc_override"):
|
||||
tp_node = _find_child(node, "type_path")
|
||||
owner_path: "str | None" = None
|
||||
owner_nid: "str | None" = None
|
||||
if tp_node is not None:
|
||||
owner_path = _type_path_text(tp_node)
|
||||
owner_nid = _ensure_type(owner_path, line)
|
||||
add_edge(file_nid, owner_nid, "contains", line)
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node is None:
|
||||
return
|
||||
proc_name = _read_text(name_node, source)
|
||||
if owner_path and owner_nid:
|
||||
proc_nid = _make_id(stem, owner_path, proc_name)
|
||||
add_node(proc_nid, f"{owner_path}/{proc_name}()", line)
|
||||
add_edge(owner_nid, proc_nid, "method", line)
|
||||
else:
|
||||
proc_nid = _make_id(stem, proc_name)
|
||||
add_node(proc_nid, f"{proc_name}()", line)
|
||||
add_edge(file_nid, proc_nid, "contains", line)
|
||||
block = _find_child(node, "block")
|
||||
if block is not None:
|
||||
function_bodies.append((proc_nid, block, owner_path))
|
||||
return
|
||||
|
||||
if t in ("operator_override", "type_operator_override"):
|
||||
return
|
||||
|
||||
for child in node.children:
|
||||
walk(child, parent_type_path, parent_type_nid)
|
||||
|
||||
walk(root)
|
||||
|
||||
label_to_nids: dict[str, list[str]] = {}
|
||||
path_to_nids: dict[str, list[str]] = {}
|
||||
for n in nodes:
|
||||
label = n["label"].strip("()")
|
||||
last = label.rsplit("/", 1)[-1] if "/" in label else label
|
||||
if last:
|
||||
label_to_nids.setdefault(last.lower(), []).append(n["id"])
|
||||
if label.startswith("/"):
|
||||
path_to_nids.setdefault(label.lower(), []).append(n["id"])
|
||||
|
||||
seen_call_pairs: set[tuple[str, str]] = set()
|
||||
raw_calls: list[dict] = []
|
||||
|
||||
def _emit_call(caller_nid: str, callee: str, line: int, is_member: bool) -> None:
|
||||
candidates = label_to_nids.get(callee.lower(), [])
|
||||
tgt_nid = candidates[0] if len(candidates) == 1 else None
|
||||
if tgt_nid and tgt_nid != caller_nid:
|
||||
pair = (caller_nid, tgt_nid)
|
||||
if pair in seen_call_pairs:
|
||||
return
|
||||
seen_call_pairs.add(pair)
|
||||
edges.append({
|
||||
"source": caller_nid, "target": tgt_nid, "relation": "calls",
|
||||
"context": "call", "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "source_location": f"L{line}", "weight": 1.0,
|
||||
})
|
||||
else:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid, "callee": callee,
|
||||
"is_member_call": is_member, "source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
})
|
||||
|
||||
def walk_calls(body_node, caller_nid: str) -> None:
|
||||
if body_node is None:
|
||||
return
|
||||
t = body_node.type
|
||||
if t in ("proc_definition", "proc_override", "type_proc_definition",
|
||||
"type_proc_override", "type_definition"):
|
||||
return
|
||||
if t == "call_expression":
|
||||
name_node = body_node.child_by_field_name("name")
|
||||
if name_node is not None:
|
||||
callee = _read_text(name_node, source)
|
||||
if callee and callee != "..":
|
||||
_emit_call(caller_nid, callee, body_node.start_point[0] + 1,
|
||||
is_member=False)
|
||||
elif t == "field_proc_expression":
|
||||
proc_field = body_node.child_by_field_name("proc")
|
||||
if proc_field is not None:
|
||||
callee = _read_text(proc_field, source)
|
||||
if callee:
|
||||
_emit_call(caller_nid, callee, body_node.start_point[0] + 1,
|
||||
is_member=True)
|
||||
elif t == "new_expression":
|
||||
tp_node = _find_child(body_node, "type_path")
|
||||
if tp_node is not None:
|
||||
target_text = _type_path_text(tp_node)
|
||||
candidates = path_to_nids.get(target_text.lower(), [])
|
||||
tgt_nid = candidates[0] if len(candidates) == 1 else None
|
||||
if tgt_nid and tgt_nid != caller_nid:
|
||||
pair = (caller_nid, tgt_nid)
|
||||
if pair not in seen_call_pairs:
|
||||
seen_call_pairs.add(pair)
|
||||
edges.append({
|
||||
"source": caller_nid, "target": tgt_nid,
|
||||
"relation": "instantiates", "context": "call",
|
||||
"confidence": "EXTRACTED", "source_file": str_path,
|
||||
"source_location": f"L{body_node.start_point[0] + 1}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
for child in body_node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
for proc_nid, block, _owner_path in function_bodies:
|
||||
walk_calls(block, proc_nid)
|
||||
|
||||
return {"nodes": nodes, "edges": edges, "raw_calls": raw_calls}
|
||||
|
||||
def _read_dmi_description(data: bytes) -> str:
|
||||
"""Pull the BYOND metadata text out of a .dmi PNG, or empty string on failure."""
|
||||
import struct
|
||||
import zlib as _zlib
|
||||
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ""
|
||||
i = 8
|
||||
while i + 8 <= len(data):
|
||||
length = struct.unpack(">I", data[i:i + 4])[0]
|
||||
chunk_type = data[i + 4:i + 8]
|
||||
payload = data[i + 8:i + 8 + length]
|
||||
if chunk_type in (b"tEXt", b"zTXt"):
|
||||
try:
|
||||
null = payload.index(b"\x00")
|
||||
except ValueError:
|
||||
return ""
|
||||
keyword = payload[:null]
|
||||
if keyword == b"Description":
|
||||
if chunk_type == b"zTXt":
|
||||
return _zlib.decompressobj().decompress(payload[null + 2:], max_length=1024 * 1024).decode("utf-8", errors="replace")
|
||||
return payload[null + 1:].decode("utf-8", errors="replace")
|
||||
i += 8 + length + 4
|
||||
return ""
|
||||
|
||||
def extract_dmi(path: Path) -> dict:
|
||||
"""Extract icon state names from a .dmi (BYOND PNG icon sheet)."""
|
||||
try:
|
||||
data = path.read_bytes()
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
str_path = str(path)
|
||||
stem = _file_stem(path)
|
||||
file_nid = _make_id(str(path))
|
||||
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
|
||||
"source_file": str_path, "source_location": "L1"}]
|
||||
edges: list[dict] = []
|
||||
seen: set[str] = {file_nid}
|
||||
|
||||
description = _read_dmi_description(data)
|
||||
if not description:
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
line_no = 0
|
||||
for raw_line in description.splitlines():
|
||||
line_no += 1
|
||||
stripped = raw_line.strip()
|
||||
if not stripped.startswith("state ="):
|
||||
continue
|
||||
value = stripped.split("=", 1)[1].strip()
|
||||
if value.startswith('"') and value.endswith('"') and len(value) >= 2:
|
||||
state_name = value[1:-1]
|
||||
else:
|
||||
state_name = value
|
||||
if not state_name:
|
||||
continue
|
||||
nid = _make_id(stem, "state", state_name)
|
||||
if nid in seen:
|
||||
continue
|
||||
seen.add(nid)
|
||||
nodes.append({"id": nid, "label": f'"{state_name}"', "file_type": "code",
|
||||
"source_file": str_path, "source_location": f"L{line_no}"})
|
||||
edges.append({"source": file_nid, "target": nid, "relation": "contains",
|
||||
"confidence": "EXTRACTED", "source_file": str_path,
|
||||
"source_location": f"L{line_no}", "weight": 1.0})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
_DMM_GRID_RE = re.compile(r"^\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*\)\s*=", re.MULTILINE)
|
||||
|
||||
def _split_dmm_tile(body: str) -> list[str]:
|
||||
out: list[str] = []
|
||||
buf: list[str] = []
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for ch in body:
|
||||
if escape:
|
||||
buf.append(ch)
|
||||
escape = False
|
||||
continue
|
||||
if in_string:
|
||||
buf.append(ch)
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
continue
|
||||
if ch == '"':
|
||||
in_string = True
|
||||
buf.append(ch)
|
||||
elif ch in "({[":
|
||||
depth += 1
|
||||
buf.append(ch)
|
||||
elif ch in ")}]":
|
||||
depth -= 1
|
||||
buf.append(ch)
|
||||
elif ch == "," and depth == 0:
|
||||
out.append("".join(buf).strip())
|
||||
buf = []
|
||||
else:
|
||||
buf.append(ch)
|
||||
tail = "".join(buf).strip()
|
||||
if tail:
|
||||
out.append(tail)
|
||||
return out
|
||||
|
||||
def _dmm_type_path(entry: str) -> str:
|
||||
brace = entry.find("{")
|
||||
if brace != -1:
|
||||
entry = entry[:brace]
|
||||
return entry.strip()
|
||||
|
||||
def extract_dmm(path: Path) -> dict:
|
||||
"""Extract type-path references from a .dmm map file's tile dictionary."""
|
||||
try:
|
||||
if path.stat().st_size > 50 * 1024 * 1024:
|
||||
return {"nodes": [], "edges": [], "error": "file too large (>50 MB)"}
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
str_path = str(path)
|
||||
file_nid = _make_id(str(path))
|
||||
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
|
||||
"source_file": str_path, "source_location": "L1"}]
|
||||
edges: list[dict] = []
|
||||
|
||||
grid_match = _DMM_GRID_RE.search(text)
|
||||
dict_text = text[:grid_match.start()] if grid_match else text
|
||||
|
||||
seen_targets: set[str] = set()
|
||||
buf: list[str] = []
|
||||
open_line = 0
|
||||
depth = 0
|
||||
in_string = False
|
||||
escape = False
|
||||
for line_idx, line in enumerate(dict_text.splitlines(), start=1):
|
||||
for ch in line:
|
||||
if escape:
|
||||
escape = False
|
||||
elif in_string:
|
||||
if ch == "\\":
|
||||
escape = True
|
||||
elif ch == '"':
|
||||
in_string = False
|
||||
elif ch == '"':
|
||||
in_string = True
|
||||
elif ch == "(":
|
||||
if depth == 0:
|
||||
open_line = line_idx
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
buf.append(ch)
|
||||
buf.append("\n")
|
||||
if depth == 0 and buf:
|
||||
chunk = "".join(buf)
|
||||
buf = []
|
||||
lp = chunk.find("(")
|
||||
rp = chunk.rfind(")")
|
||||
if lp == -1 or rp == -1 or rp <= lp:
|
||||
continue
|
||||
inner = chunk[lp + 1:rp]
|
||||
for entry in _split_dmm_tile(inner):
|
||||
tpath = _dmm_type_path(entry)
|
||||
if not tpath.startswith("/"):
|
||||
continue
|
||||
tgt = _make_id(tpath)
|
||||
if tgt in seen_targets:
|
||||
continue
|
||||
seen_targets.add(tgt)
|
||||
edges.append({"source": file_nid, "target": tgt, "relation": "uses",
|
||||
"context": "map", "confidence": "EXTRACTED",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{open_line}", "weight": 1.0})
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
|
||||
_DMF_WINDOW_RE = re.compile(r'^\s*window\s+"([^"]+)"\s*$')
|
||||
|
||||
_DMF_ELEM_RE = re.compile(r'^\s*elem\s+"([^"]+)"\s*$')
|
||||
|
||||
_DMF_TYPE_RE = re.compile(r'^\s*type\s*=\s*(\S+)\s*$')
|
||||
|
||||
def extract_dmf(path: Path) -> dict:
|
||||
"""Extract windows and controls from a .dmf interface file."""
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
str_path = str(path)
|
||||
stem = _file_stem(path)
|
||||
file_nid = _make_id(str(path))
|
||||
nodes: list[dict] = [{"id": file_nid, "label": path.name, "file_type": "code",
|
||||
"source_file": str_path, "source_location": "L1"}]
|
||||
edges: list[dict] = []
|
||||
seen: set[str] = {file_nid}
|
||||
|
||||
current_window_nid: str | None = None
|
||||
current_elem_nid: str | None = None
|
||||
current_elem_name: str | None = None
|
||||
|
||||
for line_idx, line in enumerate(text.splitlines(), start=1):
|
||||
m = _DMF_WINDOW_RE.match(line)
|
||||
if m:
|
||||
name = m.group(1)
|
||||
nid = _make_id(stem, "window", name)
|
||||
if nid not in seen:
|
||||
seen.add(nid)
|
||||
nodes.append({"id": nid, "label": f'window "{name}"', "file_type": "code",
|
||||
"source_file": str_path, "source_location": f"L{line_idx}"})
|
||||
edges.append({"source": file_nid, "target": nid, "relation": "contains",
|
||||
"confidence": "EXTRACTED", "source_file": str_path,
|
||||
"source_location": f"L{line_idx}", "weight": 1.0})
|
||||
current_window_nid = nid
|
||||
current_elem_nid = None
|
||||
current_elem_name = None
|
||||
continue
|
||||
m = _DMF_ELEM_RE.match(line)
|
||||
if m and current_window_nid is not None:
|
||||
name = m.group(1)
|
||||
nid = _make_id(stem, "elem", current_window_nid, name)
|
||||
if nid not in seen:
|
||||
seen.add(nid)
|
||||
nodes.append({"id": nid, "label": f'elem "{name}"', "file_type": "code",
|
||||
"source_file": str_path, "source_location": f"L{line_idx}"})
|
||||
edges.append({"source": current_window_nid, "target": nid,
|
||||
"relation": "contains", "confidence": "EXTRACTED",
|
||||
"source_file": str_path, "source_location": f"L{line_idx}",
|
||||
"weight": 1.0})
|
||||
current_elem_nid = nid
|
||||
current_elem_name = name
|
||||
continue
|
||||
m = _DMF_TYPE_RE.match(line)
|
||||
if m and current_elem_nid is not None and current_elem_name is not None:
|
||||
ctype = m.group(1)
|
||||
for n in nodes:
|
||||
if n["id"] == current_elem_nid and " [" not in n["label"]:
|
||||
n["label"] = f'elem "{current_elem_name}" [{ctype}]'
|
||||
break
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Elixir extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id
|
||||
|
||||
|
||||
def extract_elixir(path: Path) -> dict:
|
||||
"""Extract modules, functions, imports, and calls from a .ex/.exs file."""
|
||||
try:
|
||||
import tree_sitter_elixir as tselixir
|
||||
from tree_sitter import Language, Parser
|
||||
except ImportError:
|
||||
return {"nodes": [], "edges": [], "error": "tree_sitter_elixir not installed"}
|
||||
|
||||
try:
|
||||
language = Language(tselixir.language())
|
||||
parser = Parser(language)
|
||||
source = path.read_bytes()
|
||||
tree = parser.parse(source)
|
||||
root = tree.root_node
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
stem = _file_stem(path)
|
||||
str_path = str(path)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
function_bodies: list[tuple[str, Any]] = []
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({"id": nid, "label": label, "file_type": "code",
|
||||
"source_file": str_path, "source_location": f"L{line}"})
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
confidence: str = "EXTRACTED", weight: float = 1.0,
|
||||
context: str | None = None) -> None:
|
||||
edge = {"source": src, "target": tgt, "relation": relation,
|
||||
"confidence": confidence, "source_file": str_path,
|
||||
"source_location": f"L{line}", "weight": weight}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
_IMPORT_KEYWORDS = frozenset({"alias", "import", "require", "use"})
|
||||
|
||||
def _get_alias_text(node) -> str | None:
|
||||
for child in node.children:
|
||||
if child.type == "alias":
|
||||
return source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
|
||||
return None
|
||||
|
||||
def _get_alias_modules(node) -> list[str]:
|
||||
"""Every module named by an alias/import/require/use argument.
|
||||
|
||||
Handles the single form (``alias Foo.Bar`` -> ``["Foo.Bar"]``) and the
|
||||
multi-alias brace form (``alias Foo.{Bar, Baz}`` ->
|
||||
``["Foo.Bar", "Foo.Baz"]``), which the grammar represents as a ``dot``
|
||||
node holding the base alias and a trailing ``tuple`` of member aliases.
|
||||
"""
|
||||
def _text(n) -> str:
|
||||
return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace")
|
||||
|
||||
for child in node.children:
|
||||
if child.type == "alias":
|
||||
return [_text(child)]
|
||||
if child.type == "dot":
|
||||
base = None
|
||||
tuple_node = None
|
||||
for sub in child.children:
|
||||
if sub.type == "alias" and base is None:
|
||||
base = _text(sub)
|
||||
elif sub.type == "tuple":
|
||||
tuple_node = sub
|
||||
if base and tuple_node is not None:
|
||||
members = [_text(m) for m in tuple_node.children if m.type == "alias"]
|
||||
if members:
|
||||
return [f"{base}.{m}" for m in members]
|
||||
return [_text(child)]
|
||||
return []
|
||||
|
||||
def walk(node, parent_module_nid: str | None = None) -> None:
|
||||
if node.type != "call":
|
||||
for child in node.children:
|
||||
walk(child, parent_module_nid)
|
||||
return
|
||||
|
||||
identifier_node = None
|
||||
arguments_node = None
|
||||
do_block_node = None
|
||||
for child in node.children:
|
||||
if child.type == "identifier":
|
||||
identifier_node = child
|
||||
elif child.type == "arguments":
|
||||
arguments_node = child
|
||||
elif child.type == "do_block":
|
||||
do_block_node = child
|
||||
|
||||
if identifier_node is None:
|
||||
for child in node.children:
|
||||
walk(child, parent_module_nid)
|
||||
return
|
||||
|
||||
keyword = source[identifier_node.start_byte:identifier_node.end_byte].decode("utf-8", errors="replace")
|
||||
line = node.start_point[0] + 1
|
||||
|
||||
if keyword == "defmodule":
|
||||
module_name = _get_alias_text(arguments_node) if arguments_node else None
|
||||
if not module_name:
|
||||
return
|
||||
module_nid = _make_id(stem, module_name)
|
||||
add_node(module_nid, module_name, line)
|
||||
add_edge(file_nid, module_nid, "contains", line)
|
||||
if do_block_node:
|
||||
for child in do_block_node.children:
|
||||
walk(child, parent_module_nid=module_nid)
|
||||
return
|
||||
|
||||
if keyword in ("def", "defp"):
|
||||
func_name = None
|
||||
if arguments_node:
|
||||
for child in arguments_node.children:
|
||||
if child.type == "call":
|
||||
for sub in child.children:
|
||||
if sub.type == "identifier":
|
||||
func_name = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace")
|
||||
break
|
||||
elif child.type == "identifier":
|
||||
func_name = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
|
||||
break
|
||||
if not func_name:
|
||||
return
|
||||
container = parent_module_nid or file_nid
|
||||
func_nid = _make_id(container, func_name)
|
||||
add_node(func_nid, f"{func_name}()", line)
|
||||
if parent_module_nid:
|
||||
add_edge(parent_module_nid, func_nid, "method", line)
|
||||
else:
|
||||
add_edge(file_nid, func_nid, "contains", line)
|
||||
if do_block_node:
|
||||
function_bodies.append((func_nid, do_block_node))
|
||||
return
|
||||
|
||||
if keyword in _IMPORT_KEYWORDS and arguments_node:
|
||||
for module_name in _get_alias_modules(arguments_node):
|
||||
tgt_nid = _make_id(module_name)
|
||||
add_edge(file_nid, tgt_nid, "imports", line, context="import")
|
||||
return
|
||||
|
||||
for child in node.children:
|
||||
walk(child, parent_module_nid)
|
||||
|
||||
walk(root)
|
||||
|
||||
label_to_nid: dict[str, str] = {}
|
||||
for n in nodes:
|
||||
normalised = n["label"].strip("()").lstrip(".")
|
||||
label_to_nid[normalised] = n["id"]
|
||||
|
||||
seen_call_pairs: set[tuple[str, str]] = set()
|
||||
raw_calls: list[dict] = []
|
||||
_SKIP_KEYWORDS = frozenset({
|
||||
"def", "defp", "defmodule", "defmacro", "defmacrop",
|
||||
"defstruct", "defprotocol", "defimpl", "defguard",
|
||||
"alias", "import", "require", "use",
|
||||
"if", "unless", "case", "cond", "with", "for",
|
||||
})
|
||||
|
||||
def walk_calls(node, caller_nid: str) -> None:
|
||||
if node.type != "call":
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
return
|
||||
for child in node.children:
|
||||
if child.type == "identifier":
|
||||
kw = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
|
||||
if kw in _SKIP_KEYWORDS:
|
||||
for c in node.children:
|
||||
walk_calls(c, caller_nid)
|
||||
return
|
||||
break
|
||||
callee_name: str | None = None
|
||||
is_member_call: bool = False
|
||||
for child in node.children:
|
||||
if child.type == "dot":
|
||||
is_member_call = True
|
||||
dot_text = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
|
||||
parts = dot_text.rstrip(".").split(".")
|
||||
if parts:
|
||||
callee_name = parts[-1]
|
||||
break
|
||||
if child.type == "identifier":
|
||||
callee_name = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace")
|
||||
break
|
||||
if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS:
|
||||
tgt_nid = label_to_nid.get(callee_name)
|
||||
if tgt_nid and tgt_nid != caller_nid:
|
||||
pair = (caller_nid, tgt_nid)
|
||||
if pair not in seen_call_pairs:
|
||||
seen_call_pairs.add(pair)
|
||||
add_edge(caller_nid, tgt_nid, "calls",
|
||||
node.start_point[0] + 1, confidence="EXTRACTED", weight=1.0,
|
||||
context="call")
|
||||
else:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": callee_name,
|
||||
"is_member_call": is_member_call,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
for caller_nid, body in function_bodies:
|
||||
walk_calls(body, caller_nid)
|
||||
|
||||
clean_edges = [e for e in edges if e["source"] in seen_ids and
|
||||
(e["target"] in seen_ids or e["relation"] == "imports")]
|
||||
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls, "input_tokens": 0, "output_tokens": 0}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,309 @@
|
||||
"""Fortran extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from graphify.extractors.base import _file_stem, _make_id, _read_text
|
||||
|
||||
|
||||
_FORTRAN_CPP_EXTS = {".F", ".F90", ".F95", ".F03", ".F08"}
|
||||
|
||||
def _cpp_preprocess(path: Path) -> bytes:
|
||||
"""Run cpp -w -P on a capital-F Fortran file and return preprocessed bytes.
|
||||
|
||||
Falls back to raw file bytes if cpp is not available. Capital-F extensions
|
||||
conventionally require C preprocessor expansion (#ifdef MPI, #define REAL8, etc.)
|
||||
before parsing.
|
||||
|
||||
Security (F-007): we pass `-nostdinc` and `-I /dev/null` so a malicious
|
||||
source file containing `#include "/home/victim/.ssh/id_rsa"` (or any other
|
||||
include directive) cannot inline arbitrary host files into the output that
|
||||
we then ship to an LLM. Without these flags `cpp` happily resolves any
|
||||
relative or absolute include path it can read, which is a corpus-side
|
||||
file-exfiltration vector.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
if not shutil.which("cpp"):
|
||||
return path.read_bytes()
|
||||
try:
|
||||
# Pass an absolute path so a corpus file named like "-I/etc/x.F90" cannot
|
||||
# be parsed by cpp as an option (cpp does not accept a "--" end-of-options
|
||||
# terminator). An absolute path always begins with "/".
|
||||
result = subprocess.run(
|
||||
["cpp", "-w", "-P", "-nostdinc", "-I", "/dev/null", str(path.resolve())],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout:
|
||||
return result.stdout
|
||||
except Exception:
|
||||
pass
|
||||
return path.read_bytes()
|
||||
|
||||
def extract_fortran(path: Path) -> dict:
|
||||
"""Extract programs, modules, subroutines, functions, use statements, and calls from Fortran files.
|
||||
|
||||
Capital-F extensions (.F, .F90, etc.) are run through the C preprocessor before
|
||||
parsing so #ifdef/#define macros are resolved.
|
||||
"""
|
||||
try:
|
||||
import tree_sitter_fortran as tsfortran
|
||||
from tree_sitter import Language, Parser
|
||||
except ImportError:
|
||||
return {"nodes": [], "edges": [], "error": "tree-sitter-fortran not installed"}
|
||||
|
||||
try:
|
||||
language = Language(tsfortran.language())
|
||||
parser = Parser(language)
|
||||
source = _cpp_preprocess(path) if path.suffix in _FORTRAN_CPP_EXTS else path.read_bytes()
|
||||
tree = parser.parse(source)
|
||||
root = tree.root_node
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
stem = _file_stem(path)
|
||||
str_path = str(path)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
scope_bodies: list[tuple[str, object]] = []
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"label": label,
|
||||
"file_type": "code",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
})
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
confidence: str = "EXTRACTED", weight: float = 1.0,
|
||||
context: str | None = None) -> None:
|
||||
edge = {
|
||||
"source": src,
|
||||
"target": tgt,
|
||||
"relation": relation,
|
||||
"confidence": confidence,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": weight,
|
||||
}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
def _fortran_name(stmt_node) -> str | None:
|
||||
"""Extract name from a *_statement node. Fortran is case-insensitive; lowercase."""
|
||||
for child in stmt_node.children:
|
||||
if child.type in ("name", "identifier"):
|
||||
return _read_text(child, source).lower()
|
||||
return None
|
||||
|
||||
def ensure_named_node(name: str, line: int) -> str:
|
||||
nid = _make_id(stem, name)
|
||||
if nid in seen_ids:
|
||||
return nid
|
||||
nid = _make_id(name)
|
||||
if nid not in seen_ids:
|
||||
# The name isn't defined in this file, so this is a cross-file reference
|
||||
# (e.g. a `Thing` type annotation imported from another module). Emit a
|
||||
# SOURCELESS stub — like the inheritance-base path below — so the
|
||||
# corpus-level rewire can collapse it onto the real definition. A sourced
|
||||
# stub here makes _disambiguate_colliding_node_ids bake the referencing
|
||||
# file's path (with extension) into the id and blocks the rewire, which is
|
||||
# the phantom-duplicate-node bug (#1402).
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"label": name,
|
||||
"file_type": "code",
|
||||
"source_file": "",
|
||||
"source_location": "",
|
||||
"origin_file": str_path,
|
||||
})
|
||||
return nid
|
||||
|
||||
def emit_signature_refs(scope_node, fn_nid: str, is_function: bool) -> None:
|
||||
"""Emit references[parameter_type] / references[return_type] edges for
|
||||
a subroutine/function based on its variable_declaration siblings."""
|
||||
stmt_type = "function_statement" if is_function else "subroutine_statement"
|
||||
stmt = next((c for c in scope_node.children if c.type == stmt_type), None)
|
||||
if stmt is None:
|
||||
return
|
||||
param_names: set[str] = set()
|
||||
params_node = next((c for c in stmt.children if c.type == "parameters"), None)
|
||||
if params_node is not None:
|
||||
for c in params_node.children:
|
||||
if c.type == "identifier":
|
||||
param_names.add(_read_text(c, source).lower())
|
||||
result_name: str | None = None
|
||||
if is_function:
|
||||
result_node = next((c for c in stmt.children if c.type == "function_result"), None)
|
||||
if result_node is not None:
|
||||
res_id = next((c for c in result_node.children if c.type == "identifier"), None)
|
||||
if res_id is not None:
|
||||
result_name = _read_text(res_id, source).lower()
|
||||
else:
|
||||
# implicit result variable: same name as the function
|
||||
result_name = _fortran_name(stmt)
|
||||
for child in scope_node.children:
|
||||
if child.type != "variable_declaration":
|
||||
continue
|
||||
derived = next((c for c in child.children if c.type == "derived_type"), None)
|
||||
if derived is None:
|
||||
continue
|
||||
type_name_node = next((c for c in derived.children if c.type == "type_name"), None)
|
||||
if type_name_node is None:
|
||||
continue
|
||||
type_name = _read_text(type_name_node, source).lower()
|
||||
for var in child.children:
|
||||
if var.type != "identifier":
|
||||
continue
|
||||
var_name = _read_text(var, source).lower()
|
||||
var_line = var.start_point[0] + 1
|
||||
if var_name in param_names:
|
||||
tgt = ensure_named_node(type_name, var_line)
|
||||
if tgt != fn_nid:
|
||||
add_edge(fn_nid, tgt, "references", var_line, context="parameter_type")
|
||||
elif is_function and var_name == result_name:
|
||||
tgt = ensure_named_node(type_name, var_line)
|
||||
if tgt != fn_nid:
|
||||
add_edge(fn_nid, tgt, "references", var_line, context="return_type")
|
||||
|
||||
def walk_calls(node, scope_nid: str) -> None:
|
||||
if node is None:
|
||||
return
|
||||
t = node.type
|
||||
if t in ("subroutine", "function", "module", "program", "internal_procedures"):
|
||||
return
|
||||
# call FOO(args) — tree-sitter-fortran uses subroutine_call
|
||||
if t == "subroutine_call":
|
||||
name_node = next((c for c in node.children if c.type == "identifier"), None)
|
||||
if name_node:
|
||||
callee = _read_text(name_node, source).lower()
|
||||
target_nid = _make_id(stem, callee)
|
||||
add_edge(scope_nid, target_nid, "calls", node.start_point[0] + 1,
|
||||
confidence="EXTRACTED", context="call")
|
||||
# x = compute(args) — function invocations are `call_expression`, which
|
||||
# shares Fortran's `name(...)` syntax with array indexing. Only emit a
|
||||
# call edge when the callee resolves to a procedure defined in this file
|
||||
# (an array variable produces no matching node), so array accesses can't
|
||||
# fabricate spurious `calls` edges.
|
||||
elif t == "call_expression":
|
||||
name_node = next((c for c in node.children if c.type == "identifier"), None)
|
||||
if name_node:
|
||||
callee = _read_text(name_node, source).lower()
|
||||
target_nid = _make_id(stem, callee)
|
||||
if target_nid in seen_ids and target_nid != scope_nid:
|
||||
add_edge(scope_nid, target_nid, "calls", node.start_point[0] + 1,
|
||||
confidence="EXTRACTED", context="call")
|
||||
for child in node.children:
|
||||
walk_calls(child, scope_nid)
|
||||
|
||||
def walk(node, scope_nid: str) -> None:
|
||||
t = node.type
|
||||
|
||||
if t == "program":
|
||||
stmt = next((c for c in node.children if c.type == "program_statement"), None)
|
||||
name = _fortran_name(stmt) if stmt else None
|
||||
if name:
|
||||
nid = _make_id(stem, name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(nid, name, line)
|
||||
add_edge(file_nid, nid, "defines", line)
|
||||
scope_bodies.append((nid, node))
|
||||
for child in node.children:
|
||||
walk(child, nid)
|
||||
return
|
||||
|
||||
if t == "module":
|
||||
stmt = next((c for c in node.children if c.type == "module_statement"), None)
|
||||
name = _fortran_name(stmt) if stmt else None
|
||||
if name:
|
||||
nid = _make_id(stem, name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(nid, name, line)
|
||||
add_edge(file_nid, nid, "defines", line)
|
||||
for child in node.children:
|
||||
walk(child, nid)
|
||||
return
|
||||
|
||||
# subroutines/functions inside a module live under internal_procedures
|
||||
if t == "internal_procedures":
|
||||
for child in node.children:
|
||||
walk(child, scope_nid)
|
||||
return
|
||||
|
||||
if t == "derived_type_definition":
|
||||
stmt = next((c for c in node.children if c.type == "derived_type_statement"), None)
|
||||
if stmt is not None:
|
||||
name_node = next((c for c in stmt.children if c.type == "type_name"), None)
|
||||
if name_node is not None:
|
||||
type_name = _read_text(name_node, source).lower()
|
||||
type_nid = _make_id(stem, type_name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(type_nid, type_name, line)
|
||||
add_edge(scope_nid, type_nid, "defines", line)
|
||||
return
|
||||
|
||||
if t == "subroutine":
|
||||
stmt = next((c for c in node.children if c.type == "subroutine_statement"), None)
|
||||
name = _fortran_name(stmt) if stmt else None
|
||||
if name:
|
||||
nid = _make_id(stem, name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(nid, f"{name}()", line)
|
||||
add_edge(scope_nid, nid, "defines", line)
|
||||
scope_bodies.append((nid, node))
|
||||
emit_signature_refs(node, nid, is_function=False)
|
||||
for child in node.children:
|
||||
walk(child, nid)
|
||||
return
|
||||
|
||||
if t == "function":
|
||||
stmt = next((c for c in node.children if c.type == "function_statement"), None)
|
||||
name = _fortran_name(stmt) if stmt else None
|
||||
if name:
|
||||
nid = _make_id(stem, name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(nid, f"{name}()", line)
|
||||
add_edge(scope_nid, nid, "defines", line)
|
||||
scope_bodies.append((nid, node))
|
||||
emit_signature_refs(node, nid, is_function=True)
|
||||
for child in node.children:
|
||||
walk(child, nid)
|
||||
return
|
||||
|
||||
if t == "use_statement":
|
||||
line = node.start_point[0] + 1
|
||||
# tree-sitter-fortran uses module_name node for the used module
|
||||
name_node = next((c for c in node.children if c.type in ("module_name", "name", "identifier")), None)
|
||||
if name_node:
|
||||
mod_name = _read_text(name_node, source).lower()
|
||||
imp_nid = _make_id(mod_name)
|
||||
add_node(imp_nid, mod_name, line)
|
||||
add_edge(scope_nid, imp_nid, "imports", line, context="use")
|
||||
return
|
||||
|
||||
for child in node.children:
|
||||
walk(child, scope_nid)
|
||||
|
||||
walk(root, file_nid)
|
||||
|
||||
_stmt_headers = {
|
||||
"subroutine_statement", "function_statement",
|
||||
"program_statement", "module_statement",
|
||||
}
|
||||
for scope_nid, body_node in scope_bodies:
|
||||
for child in body_node.children:
|
||||
if child.type not in _stmt_headers:
|
||||
walk_calls(child, scope_nid)
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Go extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from graphify.extractors.base import _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text
|
||||
|
||||
|
||||
_GO_PREDECLARED_TYPES = frozenset({
|
||||
"bool", "byte", "complex64", "complex128", "error", "float32", "float64",
|
||||
"int", "int8", "int16", "int32", "int64", "rune", "string",
|
||||
"uint", "uint8", "uint16", "uint32", "uint64", "uintptr", "any", "comparable",
|
||||
})
|
||||
|
||||
def _go_collect_type_refs(node, source: bytes, generic: bool, out: list[tuple[str, str]]) -> None:
|
||||
"""Walk a Go type expression; append (name, role) tuples."""
|
||||
if node is None:
|
||||
return
|
||||
t = node.type
|
||||
if t == "type_identifier":
|
||||
text = _read_text(node, source)
|
||||
if text and text not in _GO_PREDECLARED_TYPES:
|
||||
out.append((text, "generic_arg" if generic else "type"))
|
||||
return
|
||||
if t == "qualified_type":
|
||||
text = _read_text(node, source).rsplit(".", 1)[-1]
|
||||
if text and text not in _GO_PREDECLARED_TYPES:
|
||||
out.append((text, "generic_arg" if generic else "type"))
|
||||
return
|
||||
if t == "generic_type":
|
||||
type_field = node.child_by_field_name("type")
|
||||
if type_field is not None:
|
||||
sub: list[tuple[str, str]] = []
|
||||
_go_collect_type_refs(type_field, source, generic, sub)
|
||||
out.extend(sub)
|
||||
for c in node.children:
|
||||
if c.type == "type_arguments":
|
||||
for arg in c.children:
|
||||
if arg.is_named:
|
||||
_go_collect_type_refs(arg, source, True, out)
|
||||
return
|
||||
if t in ("pointer_type", "slice_type", "array_type", "map_type",
|
||||
"channel_type", "parenthesized_type"):
|
||||
for c in node.children:
|
||||
if c.is_named:
|
||||
_go_collect_type_refs(c, source, generic, out)
|
||||
return
|
||||
if node.is_named:
|
||||
for c in node.children:
|
||||
if c.is_named:
|
||||
_go_collect_type_refs(c, source, generic, out)
|
||||
|
||||
def extract_go(path: Path) -> dict:
|
||||
"""Extract functions, methods, type declarations, and imports from a .go file."""
|
||||
try:
|
||||
import tree_sitter_go as tsgo
|
||||
from tree_sitter import Language, Parser
|
||||
except ImportError:
|
||||
return {"nodes": [], "edges": [], "error": "tree-sitter-go not installed"}
|
||||
|
||||
try:
|
||||
language = Language(tsgo.language())
|
||||
parser = Parser(language)
|
||||
source = path.read_bytes()
|
||||
tree = parser.parse(source)
|
||||
root = tree.root_node
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
stem = _file_stem(path)
|
||||
# Use directory name as package scope so methods on the same type across
|
||||
# multiple files in a package share one canonical type node.
|
||||
pkg_scope = path.parent.name or stem
|
||||
str_path = str(path)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
function_bodies: list[tuple[str, object]] = []
|
||||
go_imported_pkgs: set[str] = set() # local names of imported packages
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"label": label,
|
||||
"file_type": "code",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
})
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
confidence: str = "EXTRACTED", weight: float = 1.0,
|
||||
context: str | None = None) -> None:
|
||||
edge = {
|
||||
"source": src,
|
||||
"target": tgt,
|
||||
"relation": relation,
|
||||
"confidence": confidence,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": weight,
|
||||
}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
def ensure_named_node(name: str, line: int) -> str:
|
||||
nid = _make_id(pkg_scope, name)
|
||||
if nid in seen_ids:
|
||||
return nid
|
||||
nid = _make_id(name)
|
||||
if nid not in seen_ids:
|
||||
# The name isn't declared in this file, so this is a cross-file reference
|
||||
# (e.g. a type defined in another file of the package). Emit a SOURCELESS
|
||||
# stub — like the inheritance-base path in the other extractors — so the
|
||||
# corpus-level rewire can collapse it onto the real definition. A sourced
|
||||
# stub here makes _disambiguate_colliding_node_ids bake the referencing
|
||||
# file's path (with extension) into the id and blocks the rewire, which is
|
||||
# the phantom-duplicate-node bug (#1402).
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"label": name,
|
||||
"file_type": "code",
|
||||
"source_file": "",
|
||||
"source_location": "",
|
||||
"origin_file": str_path,
|
||||
})
|
||||
return nid
|
||||
|
||||
def emit_go_method_refs(func_node, func_nid: str, line: int) -> None:
|
||||
params = func_node.child_by_field_name("parameters")
|
||||
if params is not None:
|
||||
for p in params.children:
|
||||
if p.type != "parameter_declaration":
|
||||
continue
|
||||
type_node = p.child_by_field_name("type")
|
||||
refs: list[tuple[str, str]] = []
|
||||
_go_collect_type_refs(type_node, source, False, refs)
|
||||
for ref_name, role in refs:
|
||||
ctx = "generic_arg" if role == "generic_arg" else "parameter_type"
|
||||
tgt = ensure_named_node(ref_name, line)
|
||||
if tgt != func_nid:
|
||||
add_edge(func_nid, tgt, "references", line, context=ctx)
|
||||
result = func_node.child_by_field_name("result")
|
||||
if result is not None:
|
||||
if result.type == "parameter_list":
|
||||
for p in result.children:
|
||||
if p.type != "parameter_declaration":
|
||||
continue
|
||||
type_node = p.child_by_field_name("type")
|
||||
if type_node is None:
|
||||
for c in p.children:
|
||||
if c.is_named:
|
||||
type_node = c
|
||||
break
|
||||
refs = []
|
||||
_go_collect_type_refs(type_node, source, False, refs)
|
||||
for ref_name, role in refs:
|
||||
ctx = "generic_arg" if role == "generic_arg" else "return_type"
|
||||
tgt = ensure_named_node(ref_name, line)
|
||||
if tgt != func_nid:
|
||||
add_edge(func_nid, tgt, "references", line, context=ctx)
|
||||
else:
|
||||
refs = []
|
||||
_go_collect_type_refs(result, source, False, refs)
|
||||
for ref_name, role in refs:
|
||||
ctx = "generic_arg" if role == "generic_arg" else "return_type"
|
||||
tgt = ensure_named_node(ref_name, line)
|
||||
if tgt != func_nid:
|
||||
add_edge(func_nid, tgt, "references", line, context=ctx)
|
||||
|
||||
def walk(node) -> None:
|
||||
t = node.type
|
||||
|
||||
if t == "function_declaration":
|
||||
name_node = node.child_by_field_name("name")
|
||||
if name_node:
|
||||
func_name = _read_text(name_node, source)
|
||||
line = node.start_point[0] + 1
|
||||
func_nid = _make_id(stem, func_name)
|
||||
add_node(func_nid, f"{func_name}()", line)
|
||||
add_edge(file_nid, func_nid, "contains", line)
|
||||
emit_go_method_refs(node, func_nid, line)
|
||||
body = node.child_by_field_name("body")
|
||||
if body:
|
||||
function_bodies.append((func_nid, body))
|
||||
return
|
||||
|
||||
if t == "method_declaration":
|
||||
receiver = node.child_by_field_name("receiver")
|
||||
receiver_type: str | None = None
|
||||
if receiver:
|
||||
for param in receiver.children:
|
||||
if param.type == "parameter_declaration":
|
||||
type_node = param.child_by_field_name("type")
|
||||
if type_node:
|
||||
receiver_type = _read_text(type_node, source).lstrip("*").strip()
|
||||
break
|
||||
name_node = node.child_by_field_name("name")
|
||||
if not name_node:
|
||||
return
|
||||
method_name = _read_text(name_node, source)
|
||||
line = node.start_point[0] + 1
|
||||
|
||||
if receiver_type:
|
||||
parent_nid = _make_id(pkg_scope, receiver_type)
|
||||
add_node(parent_nid, receiver_type, line)
|
||||
method_nid = _make_id(parent_nid, method_name)
|
||||
add_node(method_nid, f".{method_name}()", line)
|
||||
add_edge(parent_nid, method_nid, "method", line)
|
||||
else:
|
||||
method_nid = _make_id(stem, method_name)
|
||||
add_node(method_nid, f"{method_name}()", line)
|
||||
add_edge(file_nid, method_nid, "contains", line)
|
||||
|
||||
emit_go_method_refs(node, method_nid, line)
|
||||
body = node.child_by_field_name("body")
|
||||
if body:
|
||||
function_bodies.append((method_nid, body))
|
||||
return
|
||||
|
||||
if t == "type_declaration":
|
||||
for child in node.children:
|
||||
if child.type != "type_spec":
|
||||
continue
|
||||
name_node = child.child_by_field_name("name")
|
||||
if not name_node:
|
||||
continue
|
||||
type_name = _read_text(name_node, source)
|
||||
line = child.start_point[0] + 1
|
||||
type_nid = _make_id(pkg_scope, type_name)
|
||||
add_node(type_nid, type_name, line)
|
||||
add_edge(file_nid, type_nid, "contains", line)
|
||||
# Type body: struct fields (with embeds) or interface embedding.
|
||||
type_body = None
|
||||
for tc in child.children:
|
||||
if tc.type in ("struct_type", "interface_type"):
|
||||
type_body = tc
|
||||
break
|
||||
if type_body is None:
|
||||
continue
|
||||
if type_body.type == "struct_type":
|
||||
for fdl in type_body.children:
|
||||
if fdl.type != "field_declaration_list":
|
||||
continue
|
||||
for field in fdl.children:
|
||||
if field.type != "field_declaration":
|
||||
continue
|
||||
has_name = any(
|
||||
fc.type == "field_identifier" for fc in field.children
|
||||
)
|
||||
type_node = field.child_by_field_name("type")
|
||||
if type_node is None:
|
||||
for fc in field.children:
|
||||
if fc.is_named and fc.type != "field_identifier":
|
||||
type_node = fc
|
||||
break
|
||||
refs: list[tuple[str, str]] = []
|
||||
_go_collect_type_refs(type_node, source, False, refs)
|
||||
for ref_name, role in refs:
|
||||
tgt = ensure_named_node(ref_name, field.start_point[0] + 1)
|
||||
if tgt == type_nid:
|
||||
continue
|
||||
if not has_name and role == "type":
|
||||
add_edge(type_nid, tgt, "embeds",
|
||||
field.start_point[0] + 1)
|
||||
else:
|
||||
ctx = "generic_arg" if role == "generic_arg" else "field"
|
||||
add_edge(type_nid, tgt, "references",
|
||||
field.start_point[0] + 1, context=ctx)
|
||||
elif type_body.type == "interface_type":
|
||||
for elem in type_body.children:
|
||||
if elem.type != "type_elem":
|
||||
continue
|
||||
refs = []
|
||||
for sub in elem.children:
|
||||
if sub.is_named:
|
||||
_go_collect_type_refs(sub, source, False, refs)
|
||||
for ref_name, role in refs:
|
||||
tgt = ensure_named_node(ref_name, elem.start_point[0] + 1)
|
||||
if tgt == type_nid:
|
||||
continue
|
||||
if role == "type":
|
||||
add_edge(type_nid, tgt, "embeds",
|
||||
elem.start_point[0] + 1)
|
||||
else:
|
||||
add_edge(type_nid, tgt, "references",
|
||||
elem.start_point[0] + 1, context="generic_arg")
|
||||
return
|
||||
|
||||
if t == "import_declaration":
|
||||
for child in node.children:
|
||||
if child.type == "import_spec_list":
|
||||
for spec in child.children:
|
||||
if spec.type == "import_spec":
|
||||
path_node = spec.child_by_field_name("path")
|
||||
if path_node:
|
||||
raw = _read_text(path_node, source).strip('"')
|
||||
# Prefix with go_pkg_ so stdlib names (e.g. "context")
|
||||
# don't collide with local files of the same basename.
|
||||
tgt_nid = _make_id("go", "pkg", raw)
|
||||
add_edge(file_nid, tgt_nid, "imports_from", spec.start_point[0] + 1, context="import")
|
||||
# Track local name (alias or last path segment)
|
||||
alias = spec.child_by_field_name("name")
|
||||
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
|
||||
if local_name and local_name != "_" and local_name != ".":
|
||||
go_imported_pkgs.add(local_name)
|
||||
elif child.type == "import_spec":
|
||||
path_node = child.child_by_field_name("path")
|
||||
if path_node:
|
||||
raw = _read_text(path_node, source).strip('"')
|
||||
tgt_nid = _make_id("go", "pkg", raw)
|
||||
add_edge(file_nid, tgt_nid, "imports_from", child.start_point[0] + 1, context="import")
|
||||
alias = child.child_by_field_name("name")
|
||||
local_name = _read_text(alias, source) if alias else raw.split("/")[-1]
|
||||
if local_name and local_name != "_" and local_name != ".":
|
||||
go_imported_pkgs.add(local_name)
|
||||
return
|
||||
|
||||
for child in node.children:
|
||||
walk(child)
|
||||
|
||||
walk(root)
|
||||
|
||||
label_to_nid: dict[str, str] = {}
|
||||
for n in nodes:
|
||||
raw = n["label"]
|
||||
normalised = raw.strip("()").lstrip(".")
|
||||
label_to_nid[normalised] = n["id"]
|
||||
|
||||
seen_call_pairs: set[tuple[str, str]] = set()
|
||||
raw_calls: list[dict] = []
|
||||
|
||||
def walk_calls(node, caller_nid: str) -> None:
|
||||
if node.type in ("function_declaration", "method_declaration"):
|
||||
return
|
||||
if node.type == "call_expression":
|
||||
func_node = node.child_by_field_name("function")
|
||||
callee_name: str | None = None
|
||||
is_member_call: bool = False
|
||||
if func_node:
|
||||
if func_node.type == "identifier":
|
||||
callee_name = _read_text(func_node, source)
|
||||
elif func_node.type == "selector_expression":
|
||||
field = func_node.child_by_field_name("field")
|
||||
operand = func_node.child_by_field_name("operand")
|
||||
receiver_name = _read_text(operand, source) if operand else ""
|
||||
# Package-qualified call (e.g. fmt.Println) → allow cross-file resolution.
|
||||
# Receiver method call (e.g. s.logger.Log) → skip, no import evidence.
|
||||
is_member_call = receiver_name not in go_imported_pkgs
|
||||
if field:
|
||||
callee_name = _read_text(field, source)
|
||||
if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS:
|
||||
tgt_nid = label_to_nid.get(callee_name)
|
||||
if tgt_nid and tgt_nid != caller_nid:
|
||||
pair = (caller_nid, tgt_nid)
|
||||
if pair not in seen_call_pairs:
|
||||
seen_call_pairs.add(pair)
|
||||
line = node.start_point[0] + 1
|
||||
edges.append({
|
||||
"source": caller_nid,
|
||||
"target": tgt_nid,
|
||||
"relation": "calls",
|
||||
"context": "call",
|
||||
"confidence": "EXTRACTED",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": 1.0,
|
||||
})
|
||||
elif callee_name:
|
||||
raw_calls.append({
|
||||
"caller_nid": caller_nid,
|
||||
"callee": callee_name,
|
||||
"is_member_call": is_member_call,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{node.start_point[0] + 1}",
|
||||
})
|
||||
for child in node.children:
|
||||
walk_calls(child, caller_nid)
|
||||
|
||||
for caller_nid, body_node in function_bodies:
|
||||
walk_calls(body_node, caller_nid)
|
||||
|
||||
valid_ids = seen_ids
|
||||
clean_edges = []
|
||||
for edge in edges:
|
||||
src, tgt = edge["source"], edge["target"]
|
||||
if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from")):
|
||||
clean_edges.append(edge)
|
||||
|
||||
return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls}
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Json_config extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from graphify.extractors.base import _file_stem, _make_id, _read_text
|
||||
|
||||
|
||||
_CONFIG_JSON_NAMES = frozenset({
|
||||
"package.json", "tsconfig.json", "jsconfig.json", "composer.json",
|
||||
"deno.json", "deno.jsonc", "bower.json", "manifest.json",
|
||||
"app.json", "now.json", "vercel.json", "angular.json", "nest-cli.json",
|
||||
"biome.json", "biome.jsonc", "renovate.json", ".babelrc", ".babelrc.json",
|
||||
".eslintrc.json", ".prettierrc.json", ".prettierrc", "babel.config.json",
|
||||
})
|
||||
|
||||
_CONFIG_JSON_KEYS = frozenset({
|
||||
"dependencies", "devDependencies", "peerDependencies",
|
||||
"optionalDependencies", "bundleDependencies", "bundledDependencies",
|
||||
"extends", "$ref", "$schema", "compilerOptions",
|
||||
})
|
||||
|
||||
def _is_config_json(path: Path, obj_node, source: bytes) -> bool:
|
||||
"""True if a .json file is a recognized config/manifest worth AST-extracting.
|
||||
|
||||
Matches by filename first (cheap), then falls back to a top-level key probe
|
||||
so arbitrarily-named config files (e.g. ``api.tsconfig.json``,
|
||||
``foo.eslintrc.json``) are still picked up. Returns False for data JSON so it
|
||||
is skipped by the structural pass (#1224)."""
|
||||
name = path.name.casefold()
|
||||
if name in _CONFIG_JSON_NAMES:
|
||||
return True
|
||||
# Common compound config names: *.eslintrc.json, *.prettierrc.json, etc.
|
||||
if name.endswith((".eslintrc.json", ".prettierrc.json", ".babelrc.json",
|
||||
"tsconfig.json", "jsconfig.json")):
|
||||
return True
|
||||
# Top-level key probe: scan the root object's immediate keys (no deep walk).
|
||||
for top_key in obj_node.children:
|
||||
if top_key.type != "pair":
|
||||
continue
|
||||
key_node = top_key.child_by_field_name("key")
|
||||
if key_node is None:
|
||||
continue
|
||||
kc = key_node.child_by_field_name("string_content")
|
||||
text = _read_text(kc, source) if kc else _read_text(key_node, source).strip('"\'')
|
||||
if text in _CONFIG_JSON_KEYS:
|
||||
return True
|
||||
return False
|
||||
|
||||
def extract_json(path: Path) -> dict:
|
||||
"""Extract structure and dependency edges from a *config/manifest* .json file.
|
||||
|
||||
Data-shaped JSON (eval fixtures, datasets, GeoJSON, API response dumps) is
|
||||
deliberately skipped — AST-walking it produced hundreds of orphan key-nodes
|
||||
and duplicate communities that swamped real structure (#1224). Recognition
|
||||
is by filename (package.json, tsconfig.json, …) or a top-level key probe
|
||||
(dependencies / extends / $ref / $schema / compilerOptions)."""
|
||||
_JSON_MAX_BYTES = 1_048_576 # 1 MiB — skip large fixture dumps / GeoJSON blobs
|
||||
|
||||
try:
|
||||
import tree_sitter_json as tsjson
|
||||
from tree_sitter import Language, Parser
|
||||
except ImportError:
|
||||
return {"nodes": [], "edges": [], "error": "tree-sitter-json not installed"}
|
||||
|
||||
try:
|
||||
# Bounded read instead of stat()+read() to eliminate TOCTOU (J-1):
|
||||
# read one byte beyond the limit so we can detect oversized files even
|
||||
# if the file grows between stat and read.
|
||||
with path.open("rb") as _f:
|
||||
source = _f.read(_JSON_MAX_BYTES + 1)
|
||||
if len(source) > _JSON_MAX_BYTES:
|
||||
return {"nodes": [], "edges": [], "error": "json file too large to index"}
|
||||
language = Language(tsjson.language())
|
||||
parser = Parser(language)
|
||||
tree = parser.parse(source)
|
||||
root = tree.root_node
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
stem = _file_stem(path)
|
||||
str_path = str(path)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
# Keys whose string values become imports (package.json dep blocks)
|
||||
_DEP_KEYS = frozenset({
|
||||
"dependencies", "devDependencies", "peerDependencies",
|
||||
"optionalDependencies", "bundleDependencies", "bundledDependencies",
|
||||
})
|
||||
|
||||
def add_node(nid: str, label: str, line: int, file_type: str = "code") -> None:
|
||||
if nid and nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({"id": nid, "label": label, "file_type": file_type,
|
||||
"source_file": str_path, "source_location": f"L{line}"})
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
context: str | None = None) -> None:
|
||||
if not src or not tgt or src == tgt:
|
||||
return
|
||||
edge = {"source": src, "target": tgt, "relation": relation,
|
||||
"confidence": "EXTRACTED", "source_file": str_path,
|
||||
"source_location": f"L{line}", "weight": 1.0}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
def _key_text(pair_node) -> str | None:
|
||||
"""Extract the string content of a pair's key."""
|
||||
key_node = pair_node.child_by_field_name("key")
|
||||
if key_node is None:
|
||||
return None
|
||||
if key_node.type == "string":
|
||||
content = key_node.child_by_field_name("string_content")
|
||||
if content:
|
||||
return _read_text(content, source)
|
||||
# fallback: strip surrounding quotes
|
||||
raw = _read_text(key_node, source)
|
||||
return raw.strip('"\'')
|
||||
return _read_text(key_node, source)
|
||||
|
||||
def _val_node(pair_node):
|
||||
return pair_node.child_by_field_name("value")
|
||||
|
||||
def walk_object(obj_node, parent_nid: str, parent_key: str | None,
|
||||
depth: int, pair_count: list) -> None:
|
||||
if depth > 6:
|
||||
return
|
||||
for child in obj_node.children:
|
||||
if child.type != "pair":
|
||||
continue
|
||||
if pair_count[0] >= 500: # check per-pair so the cap is honoured exactly (J-3)
|
||||
return
|
||||
pair_count[0] += 1
|
||||
key = _key_text(child)
|
||||
if not key:
|
||||
continue
|
||||
key_nid = _make_id(stem, *(([parent_key] if parent_key else []) + [key]))
|
||||
if not key_nid:
|
||||
continue
|
||||
line = child.start_point[0] + 1
|
||||
add_node(key_nid, key, line)
|
||||
add_edge(parent_nid, key_nid, "contains", line)
|
||||
|
||||
val = _val_node(child)
|
||||
if val is None:
|
||||
continue
|
||||
|
||||
if val.type == "object":
|
||||
walk_object(val, key_nid, key, depth + 1, pair_count)
|
||||
|
||||
elif val.type == "array":
|
||||
# For "extends" arrays (tsconfig, eslint): each string element.
|
||||
# Prefix with "ref_" so external refs don't collide with real
|
||||
# code/file node IDs that share the same collapsed _make_id (J-4).
|
||||
for item in val.children:
|
||||
if item.type == "string":
|
||||
content = item.child_by_field_name("string_content")
|
||||
ref = _read_text(content, source) if content else _read_text(item, source).strip('"\'')
|
||||
if ref:
|
||||
ref_nid = _make_id("ref", ref)
|
||||
if ref_nid:
|
||||
add_node(ref_nid, ref, line, file_type="concept")
|
||||
add_edge(key_nid, ref_nid, "extends", line, context="import")
|
||||
|
||||
elif val.type == "string":
|
||||
content = val.child_by_field_name("string_content")
|
||||
val_text = _read_text(content, source) if content else _read_text(val, source).strip('"\'')
|
||||
|
||||
if key == "extends" and val_text:
|
||||
# Namespace external refs to avoid ID collision with file nodes (J-4)
|
||||
ref_nid = _make_id("ref", val_text)
|
||||
if ref_nid:
|
||||
add_node(ref_nid, val_text, line, file_type="concept")
|
||||
add_edge(file_nid, ref_nid, "extends", line, context="import")
|
||||
|
||||
elif key == "$ref" and val_text:
|
||||
# Namespace $ref values to prevent edge hijacking into code nodes (J-4)
|
||||
ref_nid = _make_id("ref", val_text)
|
||||
if ref_nid:
|
||||
add_edge(parent_nid, ref_nid, "references", line)
|
||||
|
||||
elif parent_key in _DEP_KEYS and val_text:
|
||||
dep_nid = _make_id(key)
|
||||
if dep_nid:
|
||||
add_node(dep_nid, key, line, file_type="concept")
|
||||
add_edge(key_nid, dep_nid, "imports", line, context="import")
|
||||
|
||||
# Entry: find root document → object
|
||||
doc = root
|
||||
if doc.type == "document" and doc.child_count > 0:
|
||||
doc = doc.children[0]
|
||||
if doc.type == "object":
|
||||
# Only AST-extract recognized config/manifest JSON. Data JSON (fixtures,
|
||||
# datasets, GeoJSON, API dumps) is skipped so it doesn't explode into
|
||||
# orphan key-nodes (#1224); it's left to the LLM semantic pass.
|
||||
if not _is_config_json(path, doc, source):
|
||||
return {"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"}
|
||||
walk_object(doc, file_nid, None, 0, [0])
|
||||
else:
|
||||
# Top-level array or scalar => data JSON, never a config/manifest.
|
||||
return {"nodes": [], "edges": [], "skipped": "data json (non-object root)"}
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -0,0 +1,275 @@
|
||||
"""julia — moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
from graphify.extractors.base import _file_stem, _make_id, _read_text
|
||||
from graphify.extractors.engine import _semantic_reference_edge
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def extract_julia(path: Path) -> dict:
|
||||
"""Extract modules, structs, functions, imports, and calls from a .jl file."""
|
||||
try:
|
||||
import tree_sitter_julia as tsjulia
|
||||
from tree_sitter import Language, Parser
|
||||
except ImportError:
|
||||
return {"nodes": [], "edges": [], "error": "tree-sitter-julia not installed"}
|
||||
|
||||
try:
|
||||
language = Language(tsjulia.language())
|
||||
parser = Parser(language)
|
||||
source = path.read_bytes()
|
||||
tree = parser.parse(source)
|
||||
root = tree.root_node
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
stem = _file_stem(path)
|
||||
str_path = str(path)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
function_bodies: list[tuple[str, object]] = []
|
||||
|
||||
def add_node(nid: str, label: str, line: int) -> None:
|
||||
if nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"label": label,
|
||||
"file_type": "code",
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
})
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
confidence: str = "EXTRACTED", weight: float = 1.0,
|
||||
context: str | None = None) -> None:
|
||||
edge = {
|
||||
"source": src,
|
||||
"target": tgt,
|
||||
"relation": relation,
|
||||
"confidence": confidence,
|
||||
"source_file": str_path,
|
||||
"source_location": f"L{line}",
|
||||
"weight": weight,
|
||||
}
|
||||
if context:
|
||||
edge["context"] = context
|
||||
edges.append(edge)
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
def ensure_named_node(name: str, line: int) -> str:
|
||||
nid = _make_id(stem, name)
|
||||
if nid in seen_ids:
|
||||
return nid
|
||||
nid = _make_id(name)
|
||||
if nid not in seen_ids:
|
||||
# The name isn't defined in this file, so this is a cross-file reference
|
||||
# (e.g. a `Thing` type annotation imported from another module). Emit a
|
||||
# SOURCELESS stub — like the inheritance-base path below — so the
|
||||
# corpus-level rewire can collapse it onto the real definition. A sourced
|
||||
# stub here makes _disambiguate_colliding_node_ids bake the referencing
|
||||
# file's path (with extension) into the id and blocks the rewire, which is
|
||||
# the phantom-duplicate-node bug (#1402).
|
||||
seen_ids.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"label": name,
|
||||
"file_type": "code",
|
||||
"source_file": "",
|
||||
"source_location": "",
|
||||
"origin_file": str_path,
|
||||
})
|
||||
return nid
|
||||
|
||||
def _func_name_from_signature(sig_node) -> str | None:
|
||||
"""Extract function name from a Julia signature node (call_expression > identifier)."""
|
||||
for child in sig_node.children:
|
||||
if child.type == "call_expression":
|
||||
callee = child.children[0] if child.children else None
|
||||
if callee and callee.type == "identifier":
|
||||
return _read_text(callee, source)
|
||||
return None
|
||||
|
||||
def walk_calls(body_node, func_nid: str) -> None:
|
||||
if body_node is None:
|
||||
return
|
||||
t = body_node.type
|
||||
if t in ("function_definition", "short_function_definition"):
|
||||
return
|
||||
if t == "call_expression" and body_node.children:
|
||||
callee = body_node.children[0]
|
||||
# Direct call: foo(...)
|
||||
if callee.type == "identifier":
|
||||
callee_name = _read_text(callee, source)
|
||||
target_nid = _make_id(stem, callee_name)
|
||||
add_edge(func_nid, target_nid, "calls", body_node.start_point[0] + 1,
|
||||
confidence="EXTRACTED", context="call")
|
||||
# Method call: obj.method(...)
|
||||
elif callee.type == "field_expression" and len(callee.children) >= 3:
|
||||
method_node = callee.children[-1]
|
||||
method_name = _read_text(method_node, source)
|
||||
target_nid = _make_id(stem, method_name)
|
||||
add_edge(func_nid, target_nid, "calls", body_node.start_point[0] + 1,
|
||||
confidence="EXTRACTED", context="call")
|
||||
for child in body_node.children:
|
||||
walk_calls(child, func_nid)
|
||||
|
||||
def walk(node, scope_nid: str) -> None:
|
||||
t = node.type
|
||||
|
||||
# Module
|
||||
if t == "module_definition":
|
||||
name_node = next((c for c in node.children if c.type == "identifier"), None)
|
||||
if name_node:
|
||||
mod_name = _read_text(name_node, source)
|
||||
mod_nid = _make_id(stem, mod_name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(mod_nid, mod_name, line)
|
||||
add_edge(file_nid, mod_nid, "defines", line)
|
||||
for child in node.children:
|
||||
walk(child, mod_nid)
|
||||
return
|
||||
|
||||
# Struct (struct / mutable struct — both map to struct_definition in tree-sitter-julia)
|
||||
if t == "struct_definition":
|
||||
# type_head may contain: identifier (simple) or binary_expression (Foo <: Bar)
|
||||
type_head = next((c for c in node.children if c.type == "type_head"), None)
|
||||
if not type_head:
|
||||
return
|
||||
struct_name: str | None = None
|
||||
super_name: str | None = None
|
||||
bin_expr = next((c for c in type_head.children if c.type == "binary_expression"), None)
|
||||
if bin_expr:
|
||||
identifiers = [c for c in bin_expr.children if c.type == "identifier"]
|
||||
if identifiers:
|
||||
struct_name = _read_text(identifiers[0], source)
|
||||
if len(identifiers) >= 2:
|
||||
super_name = _read_text(identifiers[-1], source)
|
||||
else:
|
||||
name_node = next((c for c in type_head.children if c.type == "identifier"), None)
|
||||
if name_node:
|
||||
struct_name = _read_text(name_node, source)
|
||||
if not struct_name:
|
||||
return
|
||||
struct_nid = _make_id(stem, struct_name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(struct_nid, struct_name, line)
|
||||
add_edge(scope_nid, struct_nid, "defines", line)
|
||||
if super_name:
|
||||
add_edge(struct_nid, ensure_named_node(super_name, line),
|
||||
"inherits", line, confidence="EXTRACTED")
|
||||
# Field types: each `name::Type` lowers to a typed_expression child of struct_definition
|
||||
for child in node.children:
|
||||
if child.type == "typed_expression":
|
||||
type_ids = [c for c in child.children if c.type == "identifier"]
|
||||
if len(type_ids) >= 2:
|
||||
field_line = child.start_point[0] + 1
|
||||
type_name = _read_text(type_ids[-1], source)
|
||||
type_nid = ensure_named_node(type_name, field_line)
|
||||
edges.append(_semantic_reference_edge(
|
||||
struct_nid, type_nid, "field", str_path, field_line))
|
||||
return
|
||||
|
||||
# Abstract type
|
||||
if t == "abstract_definition":
|
||||
# type_head > identifier
|
||||
type_head = next((c for c in node.children if c.type == "type_head"), None)
|
||||
if type_head:
|
||||
name_node = next((c for c in type_head.children if c.type == "identifier"), None)
|
||||
if name_node:
|
||||
abs_name = _read_text(name_node, source)
|
||||
abs_nid = _make_id(stem, abs_name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(abs_nid, abs_name, line)
|
||||
add_edge(scope_nid, abs_nid, "defines", line)
|
||||
return
|
||||
|
||||
# Function: function foo(...) ... end
|
||||
if t == "function_definition":
|
||||
sig_node = next((c for c in node.children if c.type == "signature"), None)
|
||||
if sig_node:
|
||||
func_name = _func_name_from_signature(sig_node)
|
||||
if func_name:
|
||||
func_nid = _make_id(stem, func_name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(func_nid, f"{func_name}()", line)
|
||||
add_edge(scope_nid, func_nid, "defines", line)
|
||||
function_bodies.append((func_nid, node))
|
||||
return
|
||||
|
||||
# Short function: foo(x) = expr
|
||||
if t == "assignment":
|
||||
lhs = node.children[0] if node.children else None
|
||||
if lhs and lhs.type == "call_expression" and lhs.children:
|
||||
callee = lhs.children[0]
|
||||
if callee.type == "identifier":
|
||||
func_name = _read_text(callee, source)
|
||||
func_nid = _make_id(stem, func_name)
|
||||
line = node.start_point[0] + 1
|
||||
add_node(func_nid, f"{func_name}()", line)
|
||||
add_edge(scope_nid, func_nid, "defines", line)
|
||||
# Only walk the RHS (index 2 after lhs and operator) to avoid self-loops
|
||||
rhs = node.children[-1] if len(node.children) >= 3 else None
|
||||
if rhs:
|
||||
function_bodies.append((func_nid, rhs))
|
||||
return
|
||||
|
||||
# Using / Import
|
||||
if t in ("using_statement", "import_statement"):
|
||||
line = node.start_point[0] + 1
|
||||
|
||||
def _julia_mod_name(n):
|
||||
# identifier (`Foo`), scoped_identifier (`Base.Threads`), or
|
||||
# import_path (relative `..Sibling`) -> the module name. Only bare
|
||||
# identifiers were handled, so qualified/relative imports — and the
|
||||
# scoped package of a `selected_import` — were silently dropped.
|
||||
if n.type == "import_path":
|
||||
ids = [c for c in n.children if c.type == "identifier"]
|
||||
return _read_text(ids[-1], source) if ids else None
|
||||
if n.type in ("identifier", "scoped_identifier"):
|
||||
return _read_text(n, source)
|
||||
return None
|
||||
|
||||
def _emit_import(name):
|
||||
if not name:
|
||||
return
|
||||
imp_nid = _make_id(name)
|
||||
add_node(imp_nid, name, line)
|
||||
add_edge(scope_nid, imp_nid, "imports", line, context="import")
|
||||
|
||||
for child in node.children:
|
||||
if child.type in ("identifier", "scoped_identifier", "import_path"):
|
||||
_emit_import(_julia_mod_name(child))
|
||||
elif child.type == "selected_import":
|
||||
# `import Base.Threads: nthreads` — the package (first named
|
||||
# child) may itself be a scoped_identifier/import_path.
|
||||
pkg = next(
|
||||
(c for c in child.children
|
||||
if c.type in ("identifier", "scoped_identifier", "import_path")),
|
||||
None,
|
||||
)
|
||||
if pkg is not None:
|
||||
_emit_import(_julia_mod_name(pkg))
|
||||
return
|
||||
|
||||
for child in node.children:
|
||||
walk(child, scope_nid)
|
||||
|
||||
walk(root, file_nid)
|
||||
|
||||
for func_nid, body_node in function_bodies:
|
||||
# For function_definition nodes, walk children directly to avoid
|
||||
# the boundary check returning early on the top-level node itself.
|
||||
# Skip the "signature" child — it contains the function's own call_expression
|
||||
# which would create a self-loop.
|
||||
if body_node.type == "function_definition":
|
||||
for child in body_node.children:
|
||||
if child.type != "signature":
|
||||
walk_calls(child, func_nid)
|
||||
else:
|
||||
walk_calls(body_node, func_nid)
|
||||
|
||||
return {"nodes": nodes, "edges": edges}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Markdown extractor. Moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import os
|
||||
|
||||
from pathlib import Path
|
||||
from graphify.extractors.base import _file_stem, _make_id
|
||||
|
||||
|
||||
_MD_INLINE_LINK_RE = re.compile(r'(?<!\!)\[[^\]]*\]\(\s*<?([^)\s>]+)>?(?:\s+[^)]*)?\)')
|
||||
|
||||
_MD_REF_DEF_RE = re.compile(r'^\s{0,3}\[[^\]]+\]:\s*<?([^\s>]+)>?')
|
||||
|
||||
_MD_WIKILINK_RE = re.compile(r'(?<!\!)\[\[([^\]|#]+)(?:[#|][^\]]*)?\]\]')
|
||||
|
||||
_MD_LINKABLE_EXTS = {".md", ".mdx", ".qmd", ".markdown", ".rst", ".txt"}
|
||||
|
||||
def _resolve_markdown_link(raw: str, source_dir: Path) -> "Path | None":
|
||||
"""Resolve a markdown link target to the absolute path of a sibling document.
|
||||
|
||||
Returns the resolved (normalized, not necessarily existing) path when the
|
||||
target is a *local* relative/absolute file-path link to a document, or None
|
||||
when it should be skipped: external URLs (http/https/mailto/protocol-
|
||||
relative/data), pure in-page anchors (``#section``), and links to non-doc
|
||||
file types (code/assets are handled by their own extractors).
|
||||
|
||||
The anchor fragment (``#section``) and query (``?x=1``) are stripped before
|
||||
resolution so ``./repo.md#setup`` resolves to the same node as ``./repo.md``.
|
||||
Extension-less targets (typical of wikilinks) are treated as sibling ``.md``.
|
||||
"""
|
||||
target = raw.strip()
|
||||
if not target:
|
||||
return None
|
||||
# Drop anchor / query so #section links still resolve to the target doc.
|
||||
target = target.split("#", 1)[0].split("?", 1)[0].strip()
|
||||
if not target:
|
||||
return None
|
||||
low = target.lower()
|
||||
if "://" in target or low.startswith(("mailto:", "tel:", "//", "data:")):
|
||||
return None
|
||||
suffix = Path(target).suffix.lower()
|
||||
if suffix == "":
|
||||
target = target + ".md"
|
||||
suffix = ".md"
|
||||
if suffix not in _MD_LINKABLE_EXTS:
|
||||
return None
|
||||
candidate = Path(target)
|
||||
if not candidate.is_absolute():
|
||||
candidate = source_dir / candidate
|
||||
return Path(os.path.normpath(str(candidate)))
|
||||
|
||||
def extract_markdown(path: Path) -> dict:
|
||||
"""Extract structural nodes and edges from a Markdown file.
|
||||
|
||||
Produces nodes for:
|
||||
- The file itself
|
||||
- Each heading (# / ## / ### etc.)
|
||||
|
||||
Produces edges for:
|
||||
- file --contains--> heading
|
||||
- parent heading --contains--> child heading (nesting by level)
|
||||
- heading --references--> other node (when backtick `Name` matches a known pattern)
|
||||
- file --references--> linked document, for inline ``[text](./other.md)``,
|
||||
reference-style ``[label]: ./other.md`` and ``[[wikilink]]`` links, so a
|
||||
hub doc (``index.md`` / ``table-of-contents.md``) becomes a real hub node
|
||||
instead of an under-connected orphan (#1376). The target node ID is built
|
||||
from the resolved target path with the same recipe as the target file's
|
||||
own node, so the edge merges into that node (no ghost node). External
|
||||
URLs, in-page anchors, images and non-document targets are skipped.
|
||||
|
||||
Fenced code blocks (``` ... ```) are skipped during parsing so their
|
||||
contents don't get treated as headings, but no node is emitted for
|
||||
them — they were always orphans (only a single contains edge to the
|
||||
parent doc) and inflated the disconnected-component count (#1077).
|
||||
|
||||
No tree-sitter dependency — pure line-by-line parsing.
|
||||
"""
|
||||
try:
|
||||
source = path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
return {"nodes": [], "edges": [], "error": str(e)}
|
||||
|
||||
stem = _file_stem(path)
|
||||
str_path = str(path)
|
||||
nodes: list[dict] = []
|
||||
edges: list[dict] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
def add_node(nid: str, label: str, line: int, file_type: str = "document") -> None:
|
||||
if nid not in seen_ids:
|
||||
seen_ids.add(nid)
|
||||
nodes.append({"id": nid, "label": label, "file_type": file_type,
|
||||
"source_file": str_path, "source_location": f"L{line}"})
|
||||
|
||||
def add_edge(src: str, tgt: str, relation: str, line: int,
|
||||
confidence: str = "EXTRACTED", weight: float = 1.0) -> None:
|
||||
edges.append({"source": src, "target": tgt, "relation": relation,
|
||||
"confidence": confidence, "source_file": str_path,
|
||||
"source_location": f"L{line}", "weight": weight})
|
||||
|
||||
file_nid = _make_id(str(path))
|
||||
add_node(file_nid, path.name, 1)
|
||||
|
||||
source_dir = path.parent
|
||||
# Dedup link edges by resolved target node so a hub doc that links to the
|
||||
# same sibling many times yields one edge, not N (keeps weights meaningful).
|
||||
linked_targets: set[str] = set()
|
||||
|
||||
def add_link(raw: str, line: int) -> None:
|
||||
resolved = _resolve_markdown_link(raw, source_dir)
|
||||
if resolved is None:
|
||||
return
|
||||
# Build the target ID with the SAME recipe as the target file's own
|
||||
# node (_make_id(str(path)) at extract time, canonicalized to
|
||||
# _file_node_id(rel) by the extract() post-pass). Using the absolute
|
||||
# resolved path means both endpoints get remapped identically, so the
|
||||
# edge merges into the existing doc node instead of spawning a ghost.
|
||||
tgt_nid = _make_id(str(resolved))
|
||||
if tgt_nid == file_nid or tgt_nid in linked_targets:
|
||||
return
|
||||
linked_targets.add(tgt_nid)
|
||||
add_edge(file_nid, tgt_nid, "references", line)
|
||||
|
||||
# Track heading stack for nesting: [(level, nid), ...]
|
||||
heading_stack: list[tuple[int, str]] = []
|
||||
in_code_block = False
|
||||
|
||||
lines = source.splitlines()
|
||||
for line_num_0, line_text in enumerate(lines):
|
||||
line_num = line_num_0 + 1
|
||||
|
||||
# Skip over fenced code blocks so their contents are not parsed as
|
||||
# headings, but do not emit nodes/edges for them (#1077).
|
||||
stripped = line_text.strip()
|
||||
if stripped.startswith("```"):
|
||||
in_code_block = not in_code_block
|
||||
continue
|
||||
|
||||
if in_code_block:
|
||||
continue
|
||||
|
||||
# Markdown links -> document references (#1376). Scanned on every
|
||||
# non-fenced line (including heading lines, which the heading branch
|
||||
# below `continue`s past) so links anywhere in the doc are captured.
|
||||
for m in _MD_INLINE_LINK_RE.finditer(line_text):
|
||||
add_link(m.group(1), line_num)
|
||||
for m in _MD_WIKILINK_RE.finditer(line_text):
|
||||
add_link(m.group(1), line_num)
|
||||
ref_def = _MD_REF_DEF_RE.match(line_text)
|
||||
if ref_def:
|
||||
add_link(ref_def.group(1), line_num)
|
||||
|
||||
# Detect headings: # Heading, ## Heading, etc.
|
||||
heading_match = re.match(r'^(#{1,6})\s+(.+)', line_text)
|
||||
if heading_match:
|
||||
level = len(heading_match.group(1))
|
||||
title = heading_match.group(2).strip()
|
||||
h_nid = _make_id(stem, title)
|
||||
# Avoid duplicate heading IDs by appending line number
|
||||
if h_nid in seen_ids:
|
||||
h_nid = _make_id(stem, title, str(line_num))
|
||||
add_node(h_nid, title, line_num)
|
||||
|
||||
# Pop headings at same or deeper level
|
||||
while heading_stack and heading_stack[-1][0] >= level:
|
||||
heading_stack.pop()
|
||||
|
||||
# Connect to parent heading or file
|
||||
parent = heading_stack[-1][1] if heading_stack else file_nid
|
||||
add_edge(parent, h_nid, "contains", line_num)
|
||||
|
||||
heading_stack.append((level, h_nid))
|
||||
continue
|
||||
|
||||
return {"nodes": nodes, "edges": edges, "input_tokens": 0, "output_tokens": 0}
|
||||
@@ -0,0 +1,119 @@
|
||||
"""models — moved verbatim from graphify/extract.py."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
_WORKSPACE_PACKAGE_CACHE: dict[str, dict[str, Path]] = {}
|
||||
|
||||
_JS_CACHE_BYPASS_SUFFIXES = {".js", ".jsx", ".mjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".svelte"}
|
||||
|
||||
@dataclass
|
||||
class LanguageConfig:
|
||||
ts_module: str # e.g. "tree_sitter_python"
|
||||
ts_language_fn: str = "language" # attr to call: e.g. tslang.language()
|
||||
|
||||
class_types: frozenset = frozenset()
|
||||
function_types: frozenset = frozenset()
|
||||
import_types: frozenset = frozenset()
|
||||
call_types: frozenset = frozenset()
|
||||
static_prop_types: frozenset = frozenset()
|
||||
helper_fn_names: frozenset = frozenset()
|
||||
container_bind_methods: frozenset = frozenset()
|
||||
event_listener_properties: frozenset = frozenset()
|
||||
|
||||
# Name extraction
|
||||
name_field: str = "name"
|
||||
name_fallback_child_types: tuple = ()
|
||||
|
||||
# Body detection
|
||||
body_field: str = "body"
|
||||
body_fallback_child_types: tuple = () # e.g. ("declaration_list", "compound_statement")
|
||||
|
||||
# Call name extraction
|
||||
call_function_field: str = "function" # field on call node for callee
|
||||
call_accessor_node_types: frozenset = frozenset() # member/attribute nodes
|
||||
call_accessor_field: str = "attribute" # field on accessor for method name
|
||||
call_accessor_object_field: str = "" # field on accessor for the receiver/object
|
||||
|
||||
# Stop recursion at these types in walk_calls
|
||||
function_boundary_types: frozenset = frozenset()
|
||||
|
||||
# Import handler: called for import nodes instead of generic handling
|
||||
import_handler: Callable | None = None
|
||||
|
||||
# Optional custom name resolver for functions (C, C++ declarator unwrapping)
|
||||
resolve_function_name_fn: Callable | None = None
|
||||
|
||||
# Extra label formatting for functions: if True, functions get "name()" label
|
||||
function_label_parens: bool = True
|
||||
|
||||
# Extra walk hook called after generic dispatch (for JS arrow functions, C# namespaces, etc.)
|
||||
extra_walk_fn: Callable | None = None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SymbolDeclarationFact:
|
||||
file_path: Path
|
||||
name: str
|
||||
line: int
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SymbolImportFact:
|
||||
file_path: Path
|
||||
local_name: str
|
||||
target_path: Path
|
||||
imported_name: str
|
||||
line: int
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SymbolAliasFact:
|
||||
file_path: Path
|
||||
alias: str
|
||||
target_name: str
|
||||
line: int
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SymbolExportFact:
|
||||
file_path: Path
|
||||
exported_name: str
|
||||
line: int
|
||||
local_name: str | None = None
|
||||
target_path: Path | None = None
|
||||
target_name: str | None = None
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _StarExportFact:
|
||||
file_path: Path
|
||||
target_path: Path
|
||||
line: int
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _NamespaceExportFact:
|
||||
file_path: Path
|
||||
exported_name: str
|
||||
target_path: Path
|
||||
line: int
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SymbolUseFact:
|
||||
file_path: Path
|
||||
source_id: str
|
||||
local_name: str
|
||||
relation: str
|
||||
context: str
|
||||
line: int
|
||||
|
||||
@dataclass
|
||||
class _SymbolResolutionFacts:
|
||||
declarations: list[_SymbolDeclarationFact] = field(default_factory=list)
|
||||
imports: list[_SymbolImportFact] = field(default_factory=list)
|
||||
aliases: list[_SymbolAliasFact] = field(default_factory=list)
|
||||
exports: list[_SymbolExportFact] = field(default_factory=list)
|
||||
star_exports: list[_StarExportFact] = field(default_factory=list)
|
||||
namespace_exports: list[_NamespaceExportFact] = field(default_factory=list)
|
||||
uses: list[_SymbolUseFact] = field(default_factory=list)
|
||||
# File-to-file submodule imports from `from pkg import submod` (#1146).
|
||||
# Each entry is (importing_file, submodule_file, line).
|
||||
module_imports: list[tuple[Path, Path, int]] = field(default_factory=list)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user