Skip to content

✅ fresh

last synced 2026-09-29T21:12:19.681221+00:00 · coverage 100% (graph-diff)

Validation by Beadloom doc_sync — same source as sync-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 ​

python
def compute_diff(project_root: Path, since: str = "HEAD") -> GraphDiff
ParameterTypeDefaultDescription
project_rootPathrequiredAbsolute path to the project root directory.
sincestr"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 ​

python
def compute_diff_from_snapshot(conn: sqlite3.Connection, snapshot_id: int) -> GraphDiff
ParameterTypeDescription
connsqlite3.ConnectionDatabase connection with nodes, edges tables.
snapshot_idintID 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 ​

FieldTypeDescription
ref_idstrNode identifier.
kindstrNode kind (e.g. domain, service).
change_typestrOne of "added", "removed", "changed".
old_summarystr | NonePrevious summary text (only for "changed" type).
new_summarystr | NoneCurrent summary text (only for "changed" type).
old_sourcestr | NonePrevious source path (only for "changed" type).
new_sourcestr | NoneCurrent source path (only for "changed" type).
old_tagstuple[str, ...]Previous sorted tags (defaults to ()).
new_tagstuple[str, ...]Current sorted tags (defaults to ()).
symbols_addedintNumber of code symbols added (defaults to 0).
symbols_removedintNumber of code symbols removed (defaults to 0).

EdgeChange ​

FieldTypeDescription
srcstrSource node ref_id.
dststrDestination node ref_id.
kindstrEdge kind (e.g. depends_on, part_of).
change_typestrOne of "added", "removed".

GraphDiff ​

FieldTypeDescription
since_refstrThe git ref compared against (or "snapshot:<id>" for snapshot diffs).
nodestuple[NodeChange, ...]All detected node changes.
edgestuple[EdgeChange, ...]All detected edge changes.
duplicatestuple[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 ​

  1. Validate git ref. Call _validate_git_ref which runs git rev-parse --verify <ref>. Raise ValueError on failure.
  2. Read current state from disk. Glob *.yml files in <project_root>/.beadloom/_graph/. For each file, parse YAML content via _parse_yaml_content to get the node mappings in file order and an edges_set of (src, dst, kind) tuples. Each node is paired with the file it was read from.
  3. Read previous state from git ref. Call _list_graph_files_at_ref (runs git ls-tree -r --name-only <ref> .beadloom/_graph/) to enumerate files. For each, call _read_yaml_at_ref (runs git show <ref>:<path>) and parse the content. Each node is paired with <ref>:<path>. 3b. Reduce each side to one node per ref_id, and report what the reduction dropped. loader.unique_by_ref_id takes each side's (where, node) pairs and returns the nodes kept — the FIRST under a ref_id, the rule the loader follows — plus one DuplicateRefId per 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.
  4. Compare nodes. Union all ref_id keys from both maps. Classify each:
    • Present in current only: "added".
    • Present in previous only: "removed".
    • Present in both with different kind, summary, source, or tags: "changed" (captures old_summary/new_summary, old_source/new_source, old_tags/new_tags).
  5. Compare edges. Set difference on (src, dst, kind) tuples:
    • current_edges - prev_edges = added edges.
    • prev_edges - current_edges = removed edges.
  6. Assemble result. Node changes sorted by ref_id, edge changes sorted by (src, dst, kind).

Internal Helpers ​

FunctionGit CommandPurpose
_validate_git_refgit rev-parse --verify <ref>Verify the ref exists. Returns bool.
_read_yaml_at_refgit show <ref>:<path>Read file content at ref; returns None if absent.
_list_graph_files_at_refgit 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 ​

python
def render_diff(diff: GraphDiff, console: Console) -> None

Renders a Rich-formatted diff to the console:

  • Duplicate report first, on both the changed and the unchanged path: one yellow line per ref_id carried twice, naming the node kept, the node dropped and what the drop costs. It precedes the header because the unchanged path ends on No 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 shows ref_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>" when symbols_added or symbols_removed are 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 the since ref — is passed through rich.markup.escape first. Unescaped, Rich took [uses] for a style tag and printed ---->, and a source such as app/[slug]/ lost its folder (beadloom-2mj3.19).
  • Edges section: + (green) for added, - (red) for removed, formatted as src --[kind]--> dst.
  • Summary line: "{N} added, {N} changed, {N} removed nodes; {N} added, {N} removed edges".
python
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 ​

python
# 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 ​

python
@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]
FlagDefaultDescription
--sinceHEADGit ref to compare against.
--jsonFalseOutput as JSON (via diff_to_dict).
--projectCurrent directoryProject root directory.

Exit codes:

CodeMeaning
0No changes detected.
1Changes detected, or graph directory not found, or invalid git ref.

Invariants ​

  • GraphDiff.nodes and GraphDiff.edges are immutable tuples.
  • Node changes are sorted lexicographically by ref_id.
  • Edge changes are sorted lexicographically by (src, dst, kind).
  • has_changes returns True if and only if at least one NodeChange or EdgeChange exists. A DuplicateRefId never moves it, so no project's beadloom diff changes exit code because the report was added.
  • Both sides reduce a duplicate ref_id by 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_dict output is deterministic for a given GraphDiff input.
  • NodeChange.old_tags and NodeChange.new_tags are 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 from git show. The at-ref side never consults the image's locale — MEASURED before the fix against HEAD with nothing changed: an ambient latin-1 raised yaml.reader.ReaderError ("unacceptable character #x0086") and an ambient ascii raised UnicodeDecodeError, both uncaught out of the review role's own instrument.
  • Graph YAML that is not valid UTF-8 produces a ValueError naming 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-tree are decoded with errors="surrogateescape", the rule os.fsdecode itself uses, so a name that is not UTF-8 round-trips back through git 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 through onboarding.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 with cwd=project_root).
  • Default comparison is against HEAD.
  • Raises ValueError on an invalid git ref (determined by git rev-parse --verify).
  • Only considers .yml files inside .beadloom/_graph/, and not rules.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; None content is treated as empty.
  • compute_diff_from_snapshot requires a database with nodes, edges, and graph_snapshots tables.

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, empty nodes and edges, exit code 0.
  • Node added. Add a new node YAML on disk not present at HEAD. Assert a single NodeChange with change_type="added".
  • Node removed. Remove a node YAML from disk that exists at HEAD. Assert change_type="removed".
  • Node changed. Modify summary, kind, source, or tags of a node between HEAD and disk. Assert change_type="changed" with correct old_summary/new_summary, old_source/new_source, and old_tags/new_tags.
  • Edge added/removed. Add or remove edges between refs. Assert corresponding EdgeChange entries with correct change_type.
  • Invalid ref. Pass a non-existent ref string. Assert ValueError is raised.
  • Empty graph directory. Both current and previous graphs are empty. Assert has_changes is False.

Serialization Tests ​

  • diff_to_dict round-trip. Verify the dict contains keys since_ref, has_changes, nodes, edges and that nested entries match dataclasses.asdict output.

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.