✅ fresh
last synced 2026-06-17T22:39:35.514075+00:00 · coverage 100% (
graph-diff)Validation by Beadloom
doc_sync— same source assync-check.
Graph Diff
Compare the current on-disk graph YAML against the state at a given git ref.
Source: src/beadloom/graph/diff.py, src/beadloom/services/commands/index_ops.py
Specification
Purpose
Detect structural changes (added, removed, or modified nodes and edges) between the current .beadloom/_graph/*.yml files on disk and their counterparts at a specified git ref. This enables change tracking across commits and supports CI workflows that gate on graph drift.
A second entry point, compute_diff_from_snapshot, compares a saved database snapshot against the current live database state instead of disk-vs-git.
Entry Point
def compute_diff(project_root: Path, since: str = "HEAD") -> GraphDiff| Parameter | Type | Default | Description |
|---|---|---|---|
project_root | Path | required | Absolute path to the project root directory. |
since | str | "HEAD" | Git ref to compare against. |
Returns: A GraphDiff instance containing all detected changes.
Raises: ValueError if since is not a valid git ref.
Snapshot Entry Point
def compute_diff_from_snapshot(conn: sqlite3.Connection, snapshot_id: int) -> GraphDiff| Parameter | Type | Description |
|---|---|---|
conn | sqlite3.Connection | Database connection with nodes, edges tables. |
snapshot_id | int | ID of a saved snapshot in the graph_snapshots table. |
Returns: A GraphDiff instance. The since_ref field is set to "snapshot:<id>".
Raises: ValueError if the snapshot ID is not found.
Unlike compute_diff, this function compares a saved snapshot (loaded via _load_snapshot_data from beadloom.graph.snapshot) with the current live state in the nodes and edges database tables. The same comparison logic applies: nodes are compared by kind, summary, source, and tags; edges by (src_ref_id, dst_ref_id, kind) set difference.
Data Structures
All dataclasses are frozen (immutable).
NodeChange
| Field | Type | Description |
|---|---|---|
ref_id | str | Node identifier. |
kind | str | Node kind (e.g. domain, service). |
change_type | str | One of "added", "removed", "changed". |
old_summary | str | None | Previous summary text (only for "changed" type). |
new_summary | str | None | Current summary text (only for "changed" type). |
old_source | str | None | Previous source path (only for "changed" type). |
new_source | str | None | Current source path (only for "changed" type). |
old_tags | tuple[str, ...] | Previous sorted tags (defaults to ()). |
new_tags | tuple[str, ...] | Current sorted tags (defaults to ()). |
symbols_added | int | Number of code symbols added (defaults to 0). |
symbols_removed | int | Number of code symbols removed (defaults to 0). |
EdgeChange
| Field | Type | Description |
|---|---|---|
src | str | Source node ref_id. |
dst | str | Destination node ref_id. |
kind | str | Edge kind (e.g. depends_on, part_of). |
change_type | str | One of "added", "removed". |
GraphDiff
| Field | Type | Description |
|---|---|---|
since_ref | str | The git ref compared against (or "snapshot:<id>" for snapshot diffs). |
nodes | tuple[NodeChange, ...] | All detected node changes. |
edges | tuple[EdgeChange, ...] | All detected edge changes. |
Property: has_changes -> bool -- True when nodes or edges is non-empty.
Algorithm
- Validate git ref. Call
_validate_git_refwhich runsgit rev-parse --verify <ref>. RaiseValueErroron failure. - Read current state from disk. Glob
*.ymlfiles in<project_root>/.beadloom/_graph/. For each file, parse YAML content via_parse_yaml_contentto extract anodes_dict(keyed byref_id) and anedges_setof(src, dst, kind)tuples. Merge all files into combinedcurrent_nodesandcurrent_edges. - Read previous state from git ref. Call
_list_graph_files_at_ref(runsgit ls-tree -r --name-only <ref> .beadloom/_graph/) to enumerate files. For each, call_read_yaml_at_ref(runsgit show <ref>:<path>) and parse the content. Merge intoprev_nodesandprev_edges. - Compare nodes. Union all
ref_idkeys from both maps. Classify each:- Present in current only:
"added". - Present in previous only:
"removed". - Present in both with different
kind,summary,source, ortags:"changed"(capturesold_summary/new_summary,old_source/new_source,old_tags/new_tags).
- Present in current only:
- Compare edges. Set difference on
(src, dst, kind)tuples:current_edges - prev_edges= added edges.prev_edges - current_edges= removed edges.
- Assemble result. Node changes sorted by
ref_id, edge changes sorted by(src, dst, kind).
Internal Helpers
| Function | Git Command | Purpose |
|---|---|---|
_validate_git_ref | git rev-parse --verify <ref> | Verify the ref exists. Returns bool. |
_read_yaml_at_ref | git show <ref>:<path> | Read file content at ref; returns None if absent. |
_list_graph_files_at_ref | git ls-tree -r --name-only <ref> .beadloom/_graph/ | List .yml files at the ref. Returns list[str] of relative paths. |
_parse_yaml_content | (none) | Parse YAML string into (nodes_dict, edges_set) where nodes_dict: dict[str, dict[str, object]] (keys: kind, summary, source, tags) and edges_set: set[tuple[str, str, str]]. |
Rendering and Serialization
def render_diff(diff: GraphDiff, console: Console) -> NoneRenders a Rich-formatted diff to the console:
- Header:
"Graph diff (since {ref}):"(bold). - No-change case: prints
"No graph changes since {ref}.". - Nodes section:
+(green) for added,~(yellow) for changed,-(red) for removed. Each entry showsref_id (kind). Changed nodes additionally display:- Old summary (dim) and new summary (bold) when summaries differ.
- Source path change:
"source: <old> → <new>"when source paths differ. - Tags change:
"tags: <old_list> → <new_list>"when tags differ. - Symbols change:
"symbols: +<N> -<N>"whensymbols_addedorsymbols_removedare non-zero.
- Edges section:
+(green) for added,-(red) for removed, formatted assrc --[kind]--> dst. - Summary line:
"{N} added, {N} changed, {N} removed nodes; {N} added, {N} removed edges".
def diff_to_dict(diff: GraphDiff) -> dict[str, object]Serializes a GraphDiff to a JSON-compatible dictionary. Produces a dict with keys: since_ref, has_changes, nodes (list of asdict(NodeChange)), edges (list of asdict(EdgeChange)).
API
Public Functions
# src/beadloom/graph/diff.py
def compute_diff(project_root: Path, since: str = "HEAD") -> GraphDiff: ...
def compute_diff_from_snapshot(conn: sqlite3.Connection, snapshot_id: int) -> GraphDiff: ...
def render_diff(diff: GraphDiff, console: Console) -> None: ...
def diff_to_dict(diff: GraphDiff) -> dict[str, object]: ...
# src/beadloom/services/commands/index_ops.py
def diff_cmd(*, since: str, as_json: bool, project: Path | None) -> None: ...compute_diff, compute_diff_from_snapshot, render_diff, and diff_to_dict are defined in src/beadloom/graph/diff.py. Console is from rich.console.
diff_cmd is the Click command handler registered as beadloom diff in src/beadloom/services/commands/index_ops.py. It imports from beadloom.graph.diff at call time, resolves the project root, validates the graph directory exists, and delegates to compute_diff. On success it either renders via render_diff (Rich console) or emits JSON via diff_to_dict (when --json is passed). It exits with code 1 when changes are detected, when the graph directory is missing, or when the git ref is invalid; code 0 when no changes are found.
Public Classes
@dataclass(frozen=True)
class NodeChange:
ref_id: str
kind: str
change_type: str # "added" | "removed" | "changed"
old_summary: str | None = None # only for "changed"
new_summary: str | None = None # only for "changed"
old_source: str | None = None
new_source: str | None = None
old_tags: tuple[str, ...] = ()
new_tags: tuple[str, ...] = ()
symbols_added: int = 0
symbols_removed: int = 0
@dataclass(frozen=True)
class EdgeChange:
src: str
dst: str
kind: str
change_type: str # "added" | "removed"
@dataclass(frozen=True)
class GraphDiff:
since_ref: str
nodes: tuple[NodeChange, ...]
edges: tuple[EdgeChange, ...]
@property
def has_changes(self) -> bool: ...CLI
beadloom diff [--since REF] [--json] [--project DIR]| Flag | Default | Description |
|---|---|---|
--since | HEAD | Git ref to compare against. |
--json | False | Output as JSON (via diff_to_dict). |
--project | Current directory | Project root directory. |
Exit codes:
| Code | Meaning |
|---|---|
0 | No changes detected. |
1 | Changes detected, or graph directory not found, or invalid git ref. |
Invariants
GraphDiff.nodesandGraphDiff.edgesare immutable tuples.- Node changes are sorted lexicographically by
ref_id. - Edge changes are sorted lexicographically by
(src, dst, kind). has_changesreturnsTrueif and only if at least oneNodeChangeorEdgeChangeexists.diff_to_dictoutput is deterministic for a givenGraphDiffinput.NodeChange.old_tagsandNodeChange.new_tagsare always sorted tuples.
Constraints
- Requires a git repository at
project_root(all git commands run withcwd=project_root). - Default comparison is against
HEAD. - Raises
ValueErroron an invalid git ref (determined bygit rev-parse --verify). - Only considers
.ymlfiles inside.beadloom/_graph/. - Files that do not exist at the given ref are treated as absent (contributing zero nodes and edges for that ref).
- YAML files are parsed with
yaml.safe_load;Nonecontent is treated as empty. compute_diff_from_snapshotrequires a database withnodes,edges, andgraph_snapshotstables.
Testing
Test files: tests/test_diff.py, tests/test_diff_enhanced.py, tests/test_cli_diff.py, tests/test_symbol_diff_polish.py, tests/test_snapshot.py
Unit Tests
- No changes. Create identical YAML at HEAD and on disk. Assert
has_changes is False, emptynodesandedges, exit code0. - Node added. Add a new node YAML on disk not present at HEAD. Assert a single
NodeChangewithchange_type="added". - Node removed. Remove a node YAML from disk that exists at HEAD. Assert
change_type="removed". - Node changed. Modify
summary,kind,source, ortagsof a node between HEAD and disk. Assertchange_type="changed"with correctold_summary/new_summary,old_source/new_source, andold_tags/new_tags. - Edge added/removed. Add or remove edges between refs. Assert corresponding
EdgeChangeentries with correctchange_type. - Invalid ref. Pass a non-existent ref string. Assert
ValueErroris raised. - Empty graph directory. Both current and previous graphs are empty. Assert
has_changes is False.
Serialization Tests
diff_to_dictround-trip. Verify the dict contains keyssince_ref,has_changes,nodes,edgesand that nested entries matchdataclasses.asdictoutput.
Rendering Tests
- Rich output. Capture console output with
Console(file=StringIO()). Assert presence of+,~,-markers and summary line with correct counts. - No-change output. Assert the "No graph changes" message is printed.
- Changed-node rendering. Assert that source path changes, tag changes, and symbol counts are rendered when present.
Integration Tests
- Git-backed comparison. Create a temporary git repo, commit graph YAML, modify on disk, run
compute_diff. Assert all change types are correctly detected against actual git state.