✅ fresh
last synced 2026-09-29T21:12:19.681221+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. |
duplicates | tuple[DuplicateRefId, ...] | Every ref_id carried by more than one node, on either side. Defaults to (). |
Property: has_changes -> bool -- True when nodes or edges is non-empty. duplicates is deliberately not part of it: a duplicate is a REPORT about the graph, and this command's exit code says whether the graph CHANGED (BDL-069).
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 get the node mappings in file order and anedges_setof(src, dst, kind)tuples. Each node is paired with the file it was read from. - 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. Each node is paired with<ref>:<path>. 3b. Reduce each side to one node perref_id, and report what the reduction dropped.loader.unique_by_ref_idtakes each side's(where, node)pairs and returns the nodes kept — the FIRST under aref_id, the rule the loader follows — plus oneDuplicateRefIdper node dropped. The two sides' findings are concatenated, current side first. Until BDL-069 this side keyed a dict per file and kept the LAST, so a diff could describe a node the loaded graph does not hold, and said nothing about it. - 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, edges_set) where nodes: list[dict[str, Any]] is the node mappings carrying a ref_id, in file order, and edges_set: set[tuple[str, str, str]]. |
_node_view | (none) | The four fields this diff compares, read off one node: kind, summary, source, tags. |
_render_duplicates | (none) | Print each DuplicateRefId, before any verdict about changes. |
Rendering and Serialization
def render_diff(diff: GraphDiff, console: Console) -> NoneRenders a Rich-formatted diff to the console:
- Duplicate report first, on both the changed and the unchanged path: one yellow line per
ref_idcarried twice, naming the node kept, the node dropped and what the drop costs. It precedes the header because the unchanged path ends onNo graph changes since <ref>, which is exactly the sentence a silent reduction would otherwise be read under. - 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.
- Every piece of the graph's own text — ref ids, kinds, summaries, source paths, tags, the edge line
src --[kind]--> dst, the duplicate lines and thesinceref — is passed throughrich.markup.escapefirst. Unescaped, Rich took[uses]for a style tag and printed---->, and a source such asapp/[slug]/lost its folder (beadloom-2mj3.19). - 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)), duplicates (list of asdict(DuplicateRefId), each carrying ref_id, kept and dropped). The JSON form carries what the text form prints, so a machine consumer is not the one consumer that cannot see a ref_id carried twice.
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. ADuplicateRefIdnever moves it, so no project'sbeadloom diffchanges exit code because the report was added.- Both sides reduce a duplicate
ref_idby the loader's own rule (unique_by_ref_id, first node wins) and both report what they dropped, each finding naming where it read the node — a file name for the working tree,<ref>:<path>for the git ref. A reduction that disagreed with the loader's described a node the graph does not hold; a guard on one side of a comparison and not the other invents changes. diff_to_dictoutput is deterministic for a givenGraphDiffinput.NodeChange.old_tagsandNodeChange.new_tagsare always sorted tuples.- Both sides of the diff are decoded by the same call (
_decode_graph_yaml,utf-8+errors="strict"): the working tree from disk, the previous state fromgit show. The at-ref side never consults the image's locale — MEASURED before the fix againstHEADwith nothing changed: an ambientlatin-1raisedyaml.reader.ReaderError("unacceptable character #x0086") and an ambientasciiraisedUnicodeDecodeError, both uncaught out of the review role's own instrument. - Graph YAML that is not valid UTF-8 produces a
ValueErrornaming the file (and the ref, for the at-ref side) — never a diff. Treating an undecodable side as absent would report the whole graph as added or removed. - Paths listed by
git ls-treeare decoded witherrors="surrogateescape", the ruleos.fsdecodeitself uses, so a name that is not UTF-8 round-trips back throughgit show's argv instead of raising. - Both sides pass through one parse (
_parse_yaml_content), and that is where the parse and mapping guards live rather than in a directory walk: a graph file that will not parse, or whose top level is not a mapping, contributes no nodes and no edges on either side. A guard applied to one side of a comparison and not the other invents changes, which is why this reader restates the guards instead of going throughonboarding.graph_files.each_graph_file— a policy over a DIRECTORY, and half of this input is content at a git ref, where there is no directory to walk (BDL-069).
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/, and notrules.yml, which holds rules and no nodes. The name is skipped on both sides. - 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/integration/graph/diff/test_diff.py, tests/integration/graph/diff/test_diff_enhanced.py, tests/integration/infrastructure/console_streams/test_cli_diff.py, tests/integration/onboarding/doc_generator/test_symbol_diff_polish.py, tests/integration/graph/snapshot/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.
- Graph text as written. Assert that the edge kind (
--[uses]-->) and a bracketed source path reach the Rich output unchanged (beadloom-2mj3.19).
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.