docs: refresh ARCHITECTURE.md module table + add a doc-parity test (keeps the #2558 'an' fix)
This commit is contained in:
committed by
safishamsi
parent
39beeb9b4b
commit
c1e41ea90e
+37
-20
@@ -5,31 +5,48 @@ graphify is a Claude Code skill backed by a Python library. The skill orchestrat
|
|||||||
## Pipeline
|
## Pipeline
|
||||||
|
|
||||||
```
|
```
|
||||||
detect() → extract() → build_graph() → cluster() → analyze() → report() → export()
|
detect() → extract() → build() → cluster() → analyze helpers → report.generate() → export.to_*()
|
||||||
```
|
```
|
||||||
|
|
||||||
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/`.
|
Each stage lives in its own module and they communicate through plain Python dicts and NetworkX graphs - no shared state, no side effects outside `graphify-out/`. Most stages are a single function; `analyze.py` and `export.py` are sets of sibling functions rather than one entry point.
|
||||||
|
|
||||||
## Module responsibilities
|
## Module responsibilities
|
||||||
|
|
||||||
| Module | Function | Input → Output |
|
Signatures below are the real ones - `tests/test_architecture_doc.py` imports every symbol named here, so this table cannot drift from the code.
|
||||||
|--------|----------|----------------|
|
|
||||||
| `detect.py` | `collect_files(root)` | directory → `[Path]` filtered list |
|
| Module | Entry point(s) | Input → Output |
|
||||||
| `extract.py` | `extract(path)` | file path → `{nodes, edges}` dict |
|
|--------|----------------|----------------|
|
||||||
| `build.py` | `build_graph(extractions)` | list of extraction dicts → `nx.Graph` |
|
| `detect.py` | `detect(root)` | directory → scan summary dict: `files` grouped by category, plus `total_files`, `total_words`, `warning`, `scan_root`, … |
|
||||||
| `cluster.py` | `cluster(G)` | graph → graph with `community` attr on each node |
|
| `extract.py` | `extract(paths, *, root=None, ...)`, `collect_files(target)` | **list** of file paths → `{nodes, edges}` dict. `collect_files` expands a directory into that list, and lives here, not in `detect.py` |
|
||||||
| `analyze.py` | `analyze(G)` | graph → analysis dict (god nodes, surprises, questions) |
|
| `build.py` | `build(extractions)`, `build_from_json(extraction)` | extraction dict(s) → `nx.Graph` |
|
||||||
| `report.py` | `render_report(G, analysis)` | graph + analysis → GRAPH_REPORT.md string |
|
| `cluster.py` | `cluster(G)` | graph → `{community_id: [node_id, ...]}` (the graph is not mutated) |
|
||||||
| `export.py` | `export(G, out_dir, ...)` | graph → Obsidian vault, graph.json, graph.html, graph.svg |
|
| `analyze.py` | `god_nodes(G)`, `surprising_connections(G)`, `suggest_questions(G, communities, community_labels)`, `find_import_cycles(G)`, `graph_diff(G_old, G_new)` | graph → one list/dict per analysis. There is no single `analyze()` entry point |
|
||||||
|
| `report.py` | `generate(G, communities, cohesion_scores, community_labels, ...)` | graph + analysis → GRAPH_REPORT.md string |
|
||||||
|
| `export.py` | `to_json`, `to_html`, `to_obsidian`, `to_svg`, `to_graphml`, `to_canvas`, `to_cypher` | graph → graph.json, graph.html, Obsidian vault, graph.svg, … one function per format |
|
||||||
|
| `wiki.py` | `to_wiki(G, communities, output_dir, ...)` | graph → one markdown article per community + `index.md` |
|
||||||
| `callflow_html.py` | `write_callflow_html(...)` | graphify-out files → Mermaid architecture/call-flow HTML |
|
| `callflow_html.py` | `write_callflow_html(...)` | graphify-out files → Mermaid architecture/call-flow HTML |
|
||||||
| `ingest.py` | `ingest(url, ...)` | URL → file saved to corpus dir |
|
| `ingest.py` | `ingest(url, target_dir, ...)` | URL → file saved to corpus dir |
|
||||||
| `cache.py` | `check_semantic_cache / save_semantic_cache` | files → (cached, uncached) split |
|
| `cache.py` | `check_semantic_cache(files, root)`, `save_semantic_cache(nodes, edges, ...)` | files → cached nodes / edges / hyperedges + the list of files still needing extraction |
|
||||||
| `security.py` | validation helpers | URL / path / label → validated or raises |
|
| `security.py` | `validate_url`, `safe_fetch`, `validate_graph_path`, `sanitize_label` | URL / path / label → validated value, or raises |
|
||||||
| `validate.py` | `validate_extraction(data)` | extraction dict → raises on schema errors |
|
| `validate.py` | `validate_extraction(data)`, `assert_valid(data)` | extraction dict → **list of schema error strings** (`validate_extraction` returns them; `assert_valid` raises) |
|
||||||
| `serve.py` | `start_server(graph_path)` | graph file path → MCP stdio server |
|
| `serve.py` | `serve(graph_path)`, `serve_http(graph_path, *, host, port, ...)` | graph file path → MCP stdio server / HTTP server |
|
||||||
| `watch.py` | `watch(root, flag_path)` | directory → writes flag file on change |
|
| `watch.py` | `watch(watch_path, debounce=3.0)`, `check_update(watch_path)` | directory → rebuild on change; `check_update` reports whether a re-extraction is pending |
|
||||||
| `benchmark.py` | `run_benchmark(graph_path)` | graph file → corpus vs subgraph token comparison |
|
| `benchmark.py` | `run_benchmark(graph_path)` | graph file → corpus vs subgraph token comparison |
|
||||||
|
|
||||||
|
### Calling `extract()` from your own code
|
||||||
|
|
||||||
|
`extract()` takes a **list** of paths, and `root` is keyword-only and optional:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
from graphify.extract import extract
|
||||||
|
|
||||||
|
paths = [Path("src/lib/content.ts"), Path("src/pages/index.astro")]
|
||||||
|
result = extract(paths, root=Path(".").resolve()) # pass root explicitly
|
||||||
|
```
|
||||||
|
|
||||||
|
Always pass `root`. Node ids and `source_file` values are derived relative to it; when it is omitted, `extract()` infers one from the paths you passed, which is the common parent of *that list* rather than your project root. A single-file call therefore anchors to that file's own directory, and ids can end up carrying path segments from the machine they were extracted on.
|
||||||
|
|
||||||
## Extraction output schema
|
## Extraction output schema
|
||||||
|
|
||||||
Every extractor returns:
|
Every extractor returns:
|
||||||
@@ -45,7 +62,7 @@ Every extractor returns:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`validate.py` enforces this schema before `build_graph()` consumes it.
|
`validate.py` enforces this schema before `build()` consumes it.
|
||||||
|
|
||||||
## Confidence labels
|
## Confidence labels
|
||||||
|
|
||||||
@@ -57,8 +74,8 @@ Every extractor returns:
|
|||||||
|
|
||||||
## Adding a new language extractor
|
## Adding a new language extractor
|
||||||
|
|
||||||
1. Add an `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).
|
1. Add an `extract_<lang>(path: Path) -> dict` function following the existing pattern (tree-sitter parse → walk nodes → collect `nodes` and `edges` → call-graph second pass for INFERRED `calls` edges). New languages go in their own module under `graphify/extractors/` - see `graphify/extractors/MIGRATION.md`; `extract.py` re-exports them while the existing ones are ported out of it.
|
||||||
2. Register the file suffix in `extract()` dispatch and `collect_files()`.
|
2. Register the file suffix in `extract()`'s dispatch table and in `collect_files()` (both in `extract.py`).
|
||||||
3. Add the suffix to `CODE_EXTENSIONS` in `detect.py` and `_WATCHED_EXTENSIONS` in `watch.py`.
|
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.
|
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`.
|
5. Add a fixture file to `tests/fixtures/` and tests to `tests/test_languages.py`.
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""ARCHITECTURE.md's module table must name symbols that actually exist (#2640).
|
||||||
|
|
||||||
|
The "Module responsibilities" table is the entry point for library users, and
|
||||||
|
AGENTS.md points agents at the docs before the code. It had drifted to document
|
||||||
|
six functions that do not exist (`detect.collect_files`, `build.build_graph`,
|
||||||
|
`analyze.analyze`, `report.render_report`, `export.export`,
|
||||||
|
`serve.start_server`) and to give `extract()` a single-path signature when it
|
||||||
|
takes a list. Anyone following it wrote code that raised, or -- worse for
|
||||||
|
`extract()` -- code that ran and silently produced non-canonical ids.
|
||||||
|
|
||||||
|
These tests parse the table itself rather than restating it, so adding a row
|
||||||
|
extends the coverage automatically and renaming a function in the code fails
|
||||||
|
here until the doc is updated too.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_ARCHITECTURE = Path(__file__).parent.parent / "ARCHITECTURE.md"
|
||||||
|
|
||||||
|
# A table row: | `<module>.py` | <entry points> | <input → output> |
|
||||||
|
_ROW = re.compile(r"^\|\s*`(\w+)\.py`\s*\|(.*?)\|", re.MULTILINE)
|
||||||
|
# A function reference inside a cell: `name(...)` or a bare `name`.
|
||||||
|
_FUNC = re.compile(r"`([a-z_][a-z0-9_]*)(?:\(|`)")
|
||||||
|
|
||||||
|
|
||||||
|
def _documented_symbols() -> list[tuple[str, str]]:
|
||||||
|
"""(module, function) for every function named in the module table."""
|
||||||
|
text = _ARCHITECTURE.read_text(encoding="utf-8")
|
||||||
|
start = text.index("## Module responsibilities")
|
||||||
|
end = text.index("##", start + 3)
|
||||||
|
pairs: list[tuple[str, str]] = []
|
||||||
|
for module, cell in _ROW.findall(text[start:end]):
|
||||||
|
for func in _FUNC.findall(cell):
|
||||||
|
pairs.append((f"graphify.{module}", func))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_table_was_actually_parsed():
|
||||||
|
"""Guard the parser itself: a regex that silently matches nothing would make
|
||||||
|
every parametrized test below vacuous."""
|
||||||
|
pairs = _documented_symbols()
|
||||||
|
assert len(pairs) >= 10, f"parsed too few symbols, regex likely broken: {pairs}"
|
||||||
|
modules = {m for m, _ in pairs}
|
||||||
|
assert {"graphify.extract", "graphify.build", "graphify.serve"} <= modules, modules
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("module,func", _documented_symbols())
|
||||||
|
def test_architecture_table_symbols_exist(module, func):
|
||||||
|
mod = importlib.import_module(module)
|
||||||
|
assert hasattr(mod, func), (
|
||||||
|
f"ARCHITECTURE.md documents {module}.{func}, which does not exist. "
|
||||||
|
f"Update the table (or re-export the symbol)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_documents_extract_as_taking_a_list():
|
||||||
|
"""`extract(path)` was documented for a function whose first parameter is a
|
||||||
|
list; a caller passing one Path gets TypeError: not iterable."""
|
||||||
|
from graphify.extract import extract
|
||||||
|
|
||||||
|
params = list(inspect.signature(extract).parameters.values())
|
||||||
|
assert params[0].name == "paths", params
|
||||||
|
assert "list" in str(params[0].annotation), params[0].annotation
|
||||||
|
|
||||||
|
text = _ARCHITECTURE.read_text(encoding="utf-8")
|
||||||
|
assert "`extract(path)`" not in text, "the single-path signature is back"
|
||||||
|
assert "extract(paths" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_architecture_tells_library_callers_to_pass_root():
|
||||||
|
"""The omitted `root=` is the parameter whose absence yields non-canonical
|
||||||
|
ids and source_file values, so the doc must not just name it -- it has to
|
||||||
|
say to pass it."""
|
||||||
|
from graphify.extract import extract
|
||||||
|
|
||||||
|
root = inspect.signature(extract).parameters["root"]
|
||||||
|
assert root.kind is inspect.Parameter.KEYWORD_ONLY, root.kind
|
||||||
|
|
||||||
|
text = _ARCHITECTURE.read_text(encoding="utf-8")
|
||||||
|
assert "root=Path" in text, "no worked example passing root"
|
||||||
|
assert "Always pass `root`" in text
|
||||||
Reference in New Issue
Block a user