✅ fresh
last synced 2026-06-17T22:39:35.514075+00:00 · coverage 85% (
graph)Validation by Beadloom
doc_sync— same source assync-check.
Graph Domain
YAML format for describing the project architecture graph, with loader, diff engine, rule engine, import resolver, linter, snapshot storage, C4 architecture model mapping, and cross-repo federation.
Features and components
Features (each with a SPEC.md):
- Graph Diff — graph delta engine vs a git ref / snapshot.
- Rule Engine — architecture-boundary lint (incl.
module-coverage). - Import Resolver — code-import → edge resolution.
- C4 Diagrams — graph → C4/Mermaid mapping.
- Federation — cross-repo export + hub aggregation.
- Snapshot — point-in-time graph snapshots + compare.
Components (internal building blocks, each with a DOC.md):
- Graph Loader — YAML →
nodes/edgesingestion seam. - Contracts — first-class cross-service contract model.
- SDL — dependency-free GraphQL SDL surface extractor (name-level).
- GraphQL Surface — typed GraphQL Tier-A surface (field types + nullability/list + args) via the optional
graphql-coreextra, honest name-level fallback. - GraphQL Breaking — native typed GraphQL breaking analysis (names broken consumer references).
- AMQP Body — strict AMQP message-body JSON-Schema model + native body-diff (names broken consumer-read fields; nested + array depth).
- AsyncAPI — source-only AsyncAPI ingestion adapter (extracts a message payload JSON-Schema into the internal AMQP body model; honest degradation).
Specification
File Location
The graph is stored in .beadloom/_graph/*.yml. All files with the .yml extension in this directory are loaded during reindex, sorted by name.
YAML Structure
nodes:
- ref_id: my-service # Unique identifier (required)
kind: service # Node type (required)
summary: "Description" # Brief description (required)
source: src/my_service/ # Path to source code (optional)
lifecycle: active # active|planned|deprecated|dead (optional, default active)
docs: # Linked documents (optional)
- docs/my-service.md
# Any additional fields go into extra (JSON)
edges:
- src: my-service # Source ref_id (required)
dst: core # Destination ref_id (required)
kind: part_of # Edge type (required)
lifecycle: active # active|planned|deprecated|dead (optional, default active)
# A cross-repo edge endpoint uses @<repo>:<ref_id>, e.g. dst: @integration-service:plansNode Types (node kind)
| Kind | Description |
|---|---|
domain | Domain area |
feature | Feature |
service | Service / module |
entity | Data entity |
adr | Architecture Decision Record |
Edge Types (edge kind)
| Kind | Description | BFS Priority |
|---|---|---|
part_of | A is part of B | 1 |
touches_entity | A touches entity B | 2 |
uses | A uses B | 3 |
implements | A implements B | 3 |
depends_on | A depends on B | 4 |
touches_code | A touches code of B | 5 |
The docs Field
An array of paths to documents linked to the node. Paths are specified relative to the project root (e.g., docs/spec.md). During reindex, a doc_path -> ref_id mapping is built to link chunks to graph nodes.
The lifecycle Field (federation)
An optional lifecycle status on each node and edge — one of active (default), planned, deprecated, dead, or external (BDL-038 G7). It is a first-class SQLite column (not stored in extra), so it is type-checked, SQL-queryable, and visible to the rule engine: only active edges count as "live" for the no-dependency-cycles and architecture-layers rules. The federation hub reconciles each edge's lifecycle against reality to produce a three-valued intent-vs-reality verdict. Absent → active; an invalid value is recorded in GraphLoadResult.errors and falls back to active. See the federation SPEC.
external marks a node the author declares as present-but-not-ours — e.g. a native Swift/Kotlin/ObjC++/C++ bridge in modules/. A dependent whose target is external (or an edge that itself declares lifecycle: external) resolves to EdgeVerdict.EXTERNAL / ContractVerdict.EXTERNAL at the hub — never DRIFT. external is the most-significant lifecycle (external > dead > deprecated > planned > active), so a single external endpoint marks the whole contract external. VALID_LIFECYCLES in loader.py accepts it; the DB lifecycle CHECK admits it (SCHEMA v4).
Cross-repo references (federation)
A graph ref may name a node in another repository as @<repo>:<ref_id> (e.g. @integration-service:plans). A plain ref (no leading @) stays local exactly as before. Cross-repo edges are persisted in a dedicated foreign_edges table and resolve at a federation hub via beadloom export / beadloom federate. See the federation SPEC.
GraphQL contracts (federation)
A cross-service contract may declare protocol: graphql (alongside amqp). The producer edge (kind: produces) carries contract: {protocol: graphql, schema: <Name>, source_file: <path-to-schema.graphql>}; at load time the loader parses the referenced SDL (relative to the project root) with the dependency-free sdl.extract_surface extractor and folds the exposed surface — top-level Query/Mutation/Subscription field names plus type/input/enum/interface type names — into the stored contract payload as exposed: [...sorted...]. A missing/unreadable file records exposed: [] plus a GraphLoadResult.warnings entry (honest, never faked). The consumer edge (kind: consumes, often @backend:<Schema>) declares contract: {protocol: graphql, schema: <Name>, references: [op/type names]}, carried through verbatim. Both sides reconcile by contract_key = graphql:<schema> (a name, not a code symbol), so a TS/FSD client and a backend resolve across the language boundary (G3). See the federation SPEC.
Typed Tier-A surface + native breaking verdict (BDL-060 / S2)
When the optional graphql-core extra (beadloom[graphql]) is installed, the loader additionally folds a producer's typed Tier-A surface into the contract payload as a sorted/deduped fields block — each operation field (across queries, mutations, AND subscriptions) carries its return type with nullability (!) and list ([]) wrapping preserved plus its args ({name, type}). The graphql_surface component owns this extraction; absent the extra (or on a malformed SDL) it degrades honestly to the name-level surface (no fields block, no fabricated types). A consumer edge may likewise declare a typed fields block (carried verbatim). The federation export emits contract.fields (sorted via _normalize_contract_surface); older readers tolerate a missing block (additive schema). When both sides are typed, contracts.Contract computes a native breaking verdict (the graphql_breaking component): a consumer-referenced field/arg that is absent, type-narrowed, or nullability-broken (e.g. a non-null arg the consumer doesn't supply, a Plan!→Plan narrowing, a dropped/retyped subscription field) → BREAKING, naming the offending field/arg; a purely additive producer change (new optional field, new nullable arg, nullability widening) is benign. Absent typed depth on either side the verdict honestly degrades to the BDL-038 name-presence check. Beadloom computes this verdict itself (native rigor); it does not delegate to an external GraphQL registry/tool.
AMQP message body + native body-diff (BDL-060 / S3)
An AMQP contract edge may declare an optional body — a minimal JSON-Schema (type, properties, required, enum, nested objects, array items) describing the message payload — on the producer (direction: produces) and the consumer (direction: consumes). The loader carries it verbatim; the federation hub normalizes it (properties + required sorted recursively via amqp_body.serialize_body) in _normalize_contract_surface, so an equivalent body declared in a different order is byte-stable on the wire (the determinism invariant). The export schema bump is additive — older readers tolerate a missing body. When both sides carry a body, contracts.Contract computes a native body-diff verdict (the amqp_body component): a consumer-read field that is absent, type-incompatible, required-by-consumer-but-now-optional/removed, enum-narrowed, or nested/array-broken in the producer body → BREAKING, naming the field path (field, parent.child, field[], field[].child); additive producer fields and widened requiredness are benign. Absent a body on either side the AMQP verdict degrades honestly to the name-level both-sides presence check (BDL-038 parity). Teams using AsyncAPI can ingest the message payload JSON-Schema via the source-only asyncapi.extract_payload_body adapter (AsyncAPI 2.x/3.x; honest degradation to None — never a fabricated body); the internal model stays the minimal JSON-Schema body, AsyncAPI is just a source.
Nested landscapes — product vs company (federation, BDL-038 / U5)
An optional landscape provenance names the product a satellite belongs to. It is resolved like repo (resolve_landscape: .beadloom/config.yml landscape: key > the resolved repo name) and beadloom export emits it only when explicitly configured (an undeclared-landscape export keeps the F1 wire shape — no landscape key). beadloom federate then composes either one product-landscape (all satellites share, or none declare, a landscape) or a company-landscape (several). Implicit same-contract_key reconciliation is scoped within a landscape — reconcile_contracts groups by (landscape, contract_key) — so two unrelated products that happen to share a coincidental message_type / schema name reconcile in separate groups and produce zero mutual DRIFT / UNDECLARED / false-CONFIRMED. A genuine cross-product contract is declared with an explicit @otherrepo:<ref> consumer edge: such a key is promoted cross-landscape (one shared group) and still resolves with a both-sides verdict regardless of landscape. An export with no landscape (or one equal to its repo) shares a single default group, so a single-product run is byte-identical to F1. See the federation SPEC.
Landscape gate — federate --fail-on (federation, BDL-039 / F3)
gate_failures(fed, fail_on) -> list[GateFailure] is a pure function over a FederatedGraph: it scans every edge EdgeVerdict and every contract ContractVerdict (matched case-insensitively against the enum value) and returns deterministically-sorted GateFailures (each with a kind edge/contract, an identity, the matched verdict, and for BREAKING the missing GraphQL names). beadloom federate --fail-on <csv> wires it as a CI gate: it writes federated.json/.txt and prints the report first, then exits 1 on any failure (so CI always has the artifact). A bare --fail-on / default uses SAFE_DEFAULT_FAIL_ON (breaking,drift,orphaned_consumer,undeclared_producer + the edge-level undeclared). No false gates (principle 3): NEVER_FAIL_VERDICTS (external/expected/dead/unmapped/confirmed/ok/cleanup_candidate) is disjoint from the default set and is rejected if passed to --fail-on. gate_failure_remediation(failure) -> str | None derives an agent-actionable "how to fix" hint per verdict (BREAKING names the missing surface; ORPHANED_CONSUMER / UNDECLARED_PRODUCER / DRIFT name the contract and the corrective action), printed as a fix: line under each failing verdict. See the federation SPEC.
Agent-actionable violations — Violation.remediation (rule engine, BDL-039 / F3)
Each Violation carries an additive remediation: str | None (default None so existing constructions/tests are unaffected). evaluate_all populates it as a deterministic post-pass via _remediation_for(rule_type, violation), which templates a "how to fix" hint per rule kind: deny/forbid-import → remove/reroute the named import; forbid → remove/reroute the named edge; cycle → break the cycle at a named edge; layer → invert the dependency or extract a shared abstraction; cardinality → split the over-large node; require → add the required edge. The linter surfaces this through --format json (additive remediation key on each violations entry, plus a stable findings array {kind, rule, severity, locations, why, remediation}) and --format github (GitHub Actions ::error/::warning annotations). rich/porcelain are unchanged.
Modules
- loader.py -- YAML graph parser and SQLite loader. Parses
.beadloom/_graph/*.ymlfiles and populatesnodesandedgestables. Validates ref_id uniqueness and edge integrity. Supports in-place YAML node updates, written through the atomic-io primitive (write_yaml_atomic: temp file +fsync+ atomicos.replace) so an interrupted edit never truncates the source-of-truth*.yml. Cross-repo edge endpoints (@<repo>:<ref_id>) are recorded asForeignEdges into a dedicatedforeign_edgestable (surfaced onGraphLoadResult.foreign_edges) for hub resolution rather than treated as dangling-edge errors (F1). For a GraphQLproducescontract with asource_file, the loader folds the parsed SDLexposedsurface into the stored contract payload (F2 / BDL-038); a missing file recordsexposed: []+ a warning. - diff.py -- Graph delta engine. Compares current on-disk graph YAML against state at a given git ref, or compares a saved snapshot against the current DB state. Detects added, removed, and changed nodes and edges, including source path changes, tag changes, and symbol count deltas. Provides Rich rendering and JSON serialization. See graph-diff SPEC.
- rules/ -- Architecture rule engine (decomposed by responsibility, BDL-059 S3):
types.py(model),loader.py(YAML → typed rules + DB validation),evaluators.py(per-rule-type evaluation),cycles.py(colored-DFS cycle detection + edge-liveness helpers),__init__.py(evaluate_allorchestration + remediation + stable re-exports). Parsesrules.yml(schema v1/v2/v3), validates rules against the graph DB, and evaluates deny, require, cycle, import-boundary, forbid-edge, layer, and cardinality rules against code imports, edges, file paths, and node metrics. Supports severity levels (error,warn), tag-based node matching viaNodeMatcher, and bulk tag assignments (v3).rule_engine.pyremains a thin re-export shim for the prior import path. - import_resolver.py -- Multi-language import analysis. Extracts imports via tree-sitter for Python, TypeScript/JavaScript, Go, Rust, Kotlin, Java, Swift, Objective-C, and C/C++. Resolves imports to graph node ref_ids. Generates
depends_onedges from resolved imports. - linter.py -- Linter orchestrator. Loads rules, optionally runs incremental reindex, evaluates all rules, and returns structured
LintResultwith violations, counts, and timing. Provides Rich, JSON, and porcelain output formatters. - snapshot.py -- Architecture snapshot storage. Saves the current graph state (nodes, edges, symbol counts) to the
graph_snapshotstable, lists saved snapshots, and compares two snapshots to produce aSnapshotDiffwith added, removed, and changed nodes and edges. - c4.py -- C4 architecture model mapping. Maps graph nodes and edges to the C4 model (System / Container / Component levels) using
part_ofdepth heuristics or explicitc4_levelin node extras. Renders diagrams in Mermaid C4 syntax and C4-PlantUML syntax. Supports level-based filtering (context, container, component) and scoped component views. - federation/ -- Cross-repo federation (BDL-037 / F1, BDL-038 / F2), decomposed by responsibility (BDL-059 S3):
refs.py(the@<repo>:<ref_id>FederatedRefidentity model +parse_ref),export.py(the deterministic satellite export —build_export/serialize_exportwith repo/commit_sha/exported_at provenance),reconcile.py(the hub aggregationaggregate_exports→FederatedGraphwith three-valued intent-vs-realityEdgeVerdicts, first-class AMQP + GraphQL contract reconciliation carrying a contract-levelContractVerdict, and per-satellite staleness),gate.py(the landscape gate —gate_failures/GateFailure/SAFE_DEFAULT_FAIL_ON/NEVER_FAIL_VERDICTS, BDL-039 / F3, that gives those verdicts teeth in CI), and__init__.py(re-exports the full public surface, sofrom beadloom.graph.federation import Xis unchanged). Contract reconciliation + classification is delegated to contracts.py. See federation SPEC. - contracts.py -- First-class cross-service contract model (BDL-038 / F2). Owns the
Contract/ContractEndpointmodel, the protocol-prefixed language-neutralcontract_keyderivation (AMQPamqp:<exchange>/<routing>:<message_type>, GraphQLgraphql:<schema>), theContractVerdictenum (contract-level intent-vs-reality), andreconcile_contracts(groups AMQP and GraphQL contract-bearing edges into first-classContracts by key; attaches the producerexposedsurface and consumerreferencesonto theContract— plus, when present, the typedexposed_fields/referenced_fieldsfrom the GraphQLfieldsblock and theexposed_body/referenced_bodyfrom the AMQPbodyJSON-Schema; thefederation/reconcile.pyhub delegates here and projects back to the F1 flat shape viaContract.to_report_dict). TheBREAKINGverdict is typed when both sides carry depth — for GraphQL thegraphql_breakingcomponent (absence / type-narrowing / nullability / arg breaks, subscriptions first-class), for AMQP theamqp_bodycomponent (absence / type-incompatible / required-narrowed / nested+array body breaks) — else the BDL-038 name-presence check. See federation SPEC. - sdl.py -- Minimal, dependency-free GraphQL SDL surface extractor (BDL-038 / F2).
extract_surface(sdl_text)returns the producer's exposed names — top-levelQuery/Mutation/Subscriptionfield names plustype/input/enum/interfacetype names — as aset[str];operation_field_names(sdl_text)returns only the operation field names (the name-level fallback substrate forgraphql_surface). Name-presence only (no schema validation); malformed/empty SDL yieldsset()(recorded honestly asexposed: []). - graphql_surface.py -- Typed GraphQL Tier-A surface extraction (BDL-060 / S2, G1a).
extract_typed_surface(sdl_text)returns aTypedSurfacemapping each operation field (queries/mutations/subscriptions) to its returntype(with!/[]wrapping) +args, parsed via the OPTIONALgraphql-coreextra; absent the extra (or on a malformed SDL) it degrades honestly to the name-level surface (typed=False, empty types — never fabricated).serialize_typed_surface/parse_typed_surfacegive a deterministic sorted/deduped wire shape for the federationcontract.fieldsblock. - graphql_breaking.py -- Native typed GraphQL breaking analysis (BDL-060 / S2, G1a).
breaking_field_descriptors(exposed_fields, referenced_fields)names the consumer references the producer breaks (absent / type-narrowed / nullability-broken / arg-broken),"<field>"or"<field>(<arg>)"; additive producer changes are benign. Beadloom computes the verdict itself — no external GraphQL tool. Invoked bycontracts.Contract.breaking_fieldsonly when both sides carry a real typed surface. - amqp_body.py -- Strict AMQP message-body JSON-Schema model + native body-diff (BDL-060 / S3, G1b).
BodySchema/parse_body/serialize_bodymodel the minimal JSON-Schema body (type/properties/required/enum/nested objects/arrayitems) deterministically (sorted, recursively);breaking_body_descriptors(producer_body, consumer_body)names the consumer-read fields the producer body breaks (absent / type-incompatible / required-by-consumer-now-optional / enum-narrowed / nested + array), pathingfield/parent.child/field[]/field[].child; additive producer fields + widened requiredness are benign. Invoked bycontracts.Contract.body_breaking_fieldsonly when both sides declared a body. - asyncapi.py -- Source-only AsyncAPI ingestion adapter (BDL-060 / S3, G1b).
extract_payload_body(document, channel=None)extracts a message payload JSON-Schema (AsyncAPI 2.xchannels.<ch>.{publish,subscribe}.message.payload/ 3.xchannels.<ch>.messages.<m>.payloadwith one-hop$refintocomponents.messages) into the internalamqp_bodymodel; AsyncAPI is a source only. Reads YAML or JSON via the coreyaml.safe_load(no optional extra); an unparseable / payload-less / remote-$refdoc degrades honestly toNone(never a fabricated body).
Invariants
ref_idmust be unique across all YAML fileskind(nodes and edges) is a free-form string — Beadloom is paradigm-agnostic, not DDD-only (BDL-038 / U1). The loader and the SQLite schema accept anykindverbatim (no enum, no CHECK), so an FSD project can usepage/widget/entity/repository(and FSD-style edge kinds) and they surviveexport → federatewith zero loss or coercion, exactly like DDDdomain/service. The federation + contract path (thefederation/package,contracts.py) is fully kind-agnostic and never branches on a DDD kind.- The conventional DDD preset kinds — nodes
domain/feature/service/entity/adr; edgespart_of/depends_on/uses/implements/touches_entity/touches_code(plus the contract kindsproduces/consumes) — are the vocabulary the rule engine recognizes inrules.ymlmatchers (rule_engine.VALID_NODE_KINDS/VALID_EDGE_KINDS): a rule that matches onkindmust name one of these (else a rules-load error). This is a constraint on the rule preset's vocabulary, not on the graph: a graph node/edge with a non-presetkindis stored, exported, and federated faithfully — it is never rejected, andbeadloom lintdoes not flag it (only a rule referencing an unknown kind is an error).
- The conventional DDD preset kinds — nodes
- Edges referencing non-existent local nodes are skipped with a warning
- Duplicate ref_id values are skipped with an error
- Rules file supports schema versions 1, 2, and 3
- Schema v3 adds optional top-level
tags:block for bulk tag assignments and tag-based matching inNodeMatcher - Rule names must be unique within
rules.yml - Each rule must have exactly one of:
deny,require,forbid_cycles,forbid_import,forbid,layers, orcheck - Rule severity must be one of:
error,warn
API
Module src/beadloom/graph/loader.py
parse_graph_file(path: Path) -> ParsedFile-- Parse a single YAML graph file into nodes and edges.load_graph(graph_dir: Path, conn: sqlite3.Connection, *, project_root: Path | None = None) -> GraphLoadResult-- Load all*.ymlfiles from a directory into SQLite (two-pass: nodes then edges). ReturnsGraphLoadResultwithnodes_loaded,edges_loaded,errors,warnings. A contract-bearing edge's persistedcontract_keyis the full protocol-prefixed identity fromcontracts.contract_key(e.g.amqp:<exchange>/<routing>:<message_type>), so same-name / different-exchange contracts on one node pair stay distinct (BDL-038 / G4); plain edges keep''(identity(src,dst,kind)).project_root(default:graph_dir's grandparent) anchors a GraphQLproducescontract's relativesource_file, whose parsed SDLexposedsurface is folded into the edge's contract payload.update_node_in_yaml(graph_dir: Path, conn: sqlite3.Connection, ref_id: str, *, summary: str | None = None, source: str | None = None) -> bool-- Update a node's fields in YAML source and SQLite. ReturnsTrueif node was found and updated. The YAML writeback is atomic (write_yaml_atomic).get_node_tags(conn: sqlite3.Connection, ref_id: str) -> set[str]-- Extract tags from a node'sextraJSON column. Returns an empty set when the node does not exist or has notagskey in its extra data.
Module src/beadloom/graph/diff.py
compute_diff(project_root: Path, since: str = "HEAD") -> GraphDiff-- Compare current graph YAML with state at a git ref. RaisesValueErroron invalid ref.compute_diff_from_snapshot(conn: sqlite3.Connection, snapshot_id: int) -> GraphDiff-- Compare a saved snapshot (fromgraph_snapshotstable) with the current live state in thenodesandedgestables. Returns aGraphDiffwithsince_refset to"snapshot:<id>". RaisesValueErrorif the snapshot ID is not found.render_diff(diff: GraphDiff, console: Console) -> None-- Render a GraphDiff using Rich console output. Displays source path changes, tag changes, and symbol count deltas for changed nodes.diff_to_dict(diff: GraphDiff) -> dict[str, object]-- Serialize a GraphDiff to a JSON-compatible dict.
Package src/beadloom/graph/rules/
Decomposed by responsibility (BDL-059 S3); see the rule-engine SPEC for the per-module map. src/beadloom/graph/rule_engine.py is a thin backwards-compatible re-export shim for the prior import path. The full public surface is re-exported from beadloom.graph.rules.
load_rules(rules_path: Path) -> list[Rule]-- Parserules.ymland return validatedRuleobjects (union ofDenyRule | RequireRule | CycleRule | ImportBoundaryRule | ForbidEdgeRule | LayerRule | CardinalityRule). RaisesValueErroron schema errors.load_rules_with_tags(rules_path: Path) -> tuple[list[Rule], dict[str, list[str]]]-- Parserules.ymlreturning both rules and tag assignments from the optional top-leveltags:block (schema v3). Returns(rules, tag_assignments)tuple.validate_rules(rules: list[Rule], conn: sqlite3.Connection) -> list[str]-- Validate rules against the database. Returns warning messages for ref_ids not found in nodes.evaluate_deny_rules(conn: sqlite3.Connection, rules: list[DenyRule]) -> list[Violation]-- Evaluate deny rules against code_imports. Supports tag-based matching viaget_node_tags().evaluate_require_rules(conn: sqlite3.Connection, rules: list[RequireRule]) -> list[Violation]-- Evaluate require rules against nodes and edges. Supports tag-based matching.evaluate_cycle_rules(conn: sqlite3.Connection, rules: list[CycleRule]) -> list[Violation]-- Evaluate cycle rules using iterative DFS over edges of specified kind(s). Reports each unique cycle once with the full path.evaluate_import_boundary_rules(conn: sqlite3.Connection, rules: list[ImportBoundaryRule]) -> list[Violation]-- Evaluate import boundary rules against code_imports usingfnmatchglob patterns on file paths.evaluate_forbid_edge_rules(conn: sqlite3.Connection, rules: list[ForbidEdgeRule]) -> list[Violation]-- Evaluate forbid edge rules against theedgestable. Checks source and destination nodes againstfrom_matcherandto_matcher, optionally restricted byedge_kind. Supports tag-based matching.evaluate_layer_rules(conn: sqlite3.Connection, rules: list[LayerRule]) -> list[Violation]-- Evaluate layer rules against the edges table. Forenforce: top-down, detects lower-to-upper layer dependencies and optional layer-skip violations whenallow_skip=False.evaluate_cardinality_rules(conn: sqlite3.Connection, rules: list[CardinalityRule]) -> list[Violation]-- Evaluate cardinality rules against nodes,code_symbols,file_index, andsync_state. Checksmax_symbols,max_files, andmin_doc_coveragethresholds for matched nodes.evaluate_all(conn: sqlite3.Connection, rules: list[Rule], *, project_root: Path | None = None) -> list[Violation]-- Evaluate all rules (deny + require + cycle + import boundary + forbid edge + layer + cardinality + unregistered-feature-candidate + module-coverage), enrich eachViolationwith a deterministicremediationhint, and return violations sorted by(rule_name, file_path or "").project_root(default: cwd) roots the on-disk module enumeration themodule-coveragerule uses.
Module src/beadloom/graph/import_resolver.py
extract_imports(file_path: Path) -> list[ImportInfo]-- Extract import statements from a source file using tree-sitter. Supports Python, TS/JS, Go, Rust, Kotlin, Java, Swift, Objective-C, C/C++.resolve_import_to_node(import_path: str, file_path: Path, conn: sqlite3.Connection, scan_paths: list[str] | None = None, *, is_ts: bool = False) -> str | None-- Map an import path to a graph node ref_id via code_symbols annotations or hierarchical source-prefix matching.index_imports(project_root: Path, conn: sqlite3.Connection) -> int-- Scan all source files, index imports into code_imports table, and createdepends_onedges. Returns count of imports indexed.create_import_edges(conn: sqlite3.Connection) -> int-- Createdepends_onedges from resolved code imports. Returns number of edges created.
Module src/beadloom/graph/linter.py
lint(project_root: Path, *, rules_path: Path | None = None, reindex: Callable[[Path], object] | None = None) -> LintResult-- Run the full lint process: load rules, evaluate, return results.reindexis an optional callback (e.g.application.reindex.incremental_reindex) invoked before evaluation so the graph-layer linter stays pure; the CLI injects the application reindex as an orchestration concern. RaisesLintErroron invalid configuration.format_rich(result: LintResult) -> str-- Format aLintResultas human-readable text with violation markers.format_json(result: LintResult) -> str-- Format aLintResultas structured JSON with violations array and summary.format_porcelain(result: LintResult) -> str-- Format aLintResultas machine-readable one-line-per-violation output.
Module src/beadloom/graph/snapshot.py
save_snapshot(conn: sqlite3.Connection, label: str | None = None) -> int-- Save current graph state (nodes, edges, symbol counts) as a snapshot in thegraph_snapshotstable. Returns the new snapshot ID.list_snapshots(conn: sqlite3.Connection) -> list[SnapshotInfo]-- List all saved snapshots, newest first. Returns a list ofSnapshotInfoobjects.compare_snapshots(conn: sqlite3.Connection, old_id: int, new_id: int) -> SnapshotDiff-- Compare two snapshots and return aSnapshotDiffwith added, removed, and changed nodes and edges. RaisesValueErrorif either snapshot ID is not found.
Module src/beadloom/graph/c4.py
map_to_c4(conn: sqlite3.Connection) -> tuple[list[C4Node], list[C4Relationship]]-- Map architecture graph to C4 model elements. Reads all nodes and edges from the database. Assigns C4 levels using explicitc4_levelin node extras (priority) orpart_ofdepth heuristic (depth 0=System, 1=Container, 2+=Component). Returns a tuple of C4 nodes and relationships.render_c4_mermaid(nodes: list[C4Node], relationships: list[C4Relationship]) -> str-- Render C4 model as Mermaid C4 diagram syntax (C4Container). ProducesSystem(),Container(),Component()elements with_Ext/Dbvariants for external/database nodes. Groups children inSystem_Boundary()blocks.render_c4_plantuml(nodes: list[C4Node], relationships: list[C4Relationship]) -> str-- Render C4 model as C4-PlantUML syntax. Produces a complete@startuml/@endumlblock with!includefor the C4-PlantUML stdlib. Uses standard macros:System(),Container(),Component(),Rel()with_Ext/Dbvariants.filter_c4_nodes(nodes: list[C4Node], relationships: list[C4Relationship], *, level: str = "container", scope: str | None = None) -> tuple[list[C4Node], list[C4Relationship]]-- Filter C4 nodes by diagram level."context"keeps only System-level and external nodes."container"keeps System and Container nodes."component"requiresscopeand keeps children of the scoped container. RaisesValueErroriflevel="component"withoutscope, or ifscoperef_id is not found.
Package src/beadloom/graph/federation/
Cross-repo identity, satellite export, and hub aggregation — decomposed by responsibility (BDL-059 S3): refs.py (identity model), export.py (satellite export), reconcile.py (hub aggregation → FederatedGraph + verdicts), gate.py (landscape gate). The package __init__.py re-exports the full public surface, so from beadloom.graph.federation import X is unchanged. See the federation SPEC for full detail.
parse_ref(raw: str) -> FederatedRef-- Parse a graph ref: plain → localFederatedRef(None, raw);@repo:id→ foreignFederatedRef("repo", "id"); malformed@...→FederationRefError. Only the first:after@splits repo from ref_id.is_foreign_ref(raw: str) -> bool-- Cheap leading-@check (does not validate shape).build_export(conn, *, repo, commit_sha, exported_at, generator, landscape=None) -> dict-- Build the deterministic satellite export artifact (schema v2) from the indexed graph; unions theedgesandforeign_edgestables; nodes sorted byref_id, edges by(src, dst, kind).landscape(BDL-038 / U5) is emitted only when provided (an undeclared-landscape export omits the key — F1 back-compat).resolve_landscape(project_root: Path) -> str-- Resolve the landscape (product) name:.beadloom/config.ymllandscape:key > the resolved repo name. The CLI omits it from the export when it equals the repo default.serialize_export(export: dict) -> str-- Serialize an export dict to deterministic JSON (sorted keys, 2-space indent).resolve_repo_name(project_root: Path) -> str-- Resolve the repo name:.beadloom/config.ymlrepo:> gitoriginremote basename > directory name.current_commit_sha(project_root: Path) -> str | None-- git HEAD sha, orNonewhenproject_rootis not the git toplevel (honest "unknown HEAD").aggregate_exports(exports: list[dict], *, now: str | None = None) -> FederatedGraph-- Compose ≥2 satellite exports into one namespaced federated graph: resolve@repo:endpoints, tag each edge with its satellite'slandscape, assign anEdgeVerdictper edge, reconcile AMQP + GraphQL contracts into first-classContracts scoped by(landscape, contract_key)with a contract-levelContractVerdict(sorted bycontract_key), record per-satellite staleness + landscape provenance.nowinjectable for deterministic age. Edge-verdict reconciliation also honoursexternalandunmapped(BDL-038 G7/U4): an edge whose target node islifecycle: external(or which itself declaresexternal) →EdgeVerdict.EXTERNAL; an edge whose target resolves in the union but is present-without-a-usable-surface (empty summary) →EdgeVerdict.UNMAPPED. Both suppress DRIFT and are kept distinct fromunresolved_refs(genuinely-absent foreign targets).serialize_federation(fed: FederatedGraph) -> str-- Serialize aFederatedGraphto deterministic JSON:{ schema_version, repos, nodes, edges, contracts, unresolved_refs }.render_federation_report(fed: FederatedGraph) -> str-- Human-readable text report (satellites grouped by landscape with a product/company-landscape label + sha/age, edge-verdict counts, DRIFT list, contract-verdict counts + explicit BREAKING / DRIFT / ORPHANED_CONSUMER / UNDECLARED_PRODUCER call-outs with the missing GraphQL names, unresolved refs).
Constants: EXPORT_SCHEMA_VERSION = 2, FEDERATION_SCHEMA_VERSION = 2 (independent). Export schema v2 (BDL-038 / F2) adds the protocol: graphql contract wire — a producer edge carries contract.exposed (parsed SDL surface), a consumer edge carries contract.references; aggregate_exports / federate still read v1 exports (missing GraphQL fields default to empty). Federation schema v2 (BDL-038 / BEAD-04) enriches each contracts entry with a contract-level verdict (ContractVerdict) plus protocol / contract_key / lifecycle and, for GraphQL, exposed / references / the missing names that triggered BREAKING; F1's flat keys (message_type / directions / repos / confirmed) are KEPT as a subset, and contracts is sorted by contract_key. The bump is on the hub OUTPUT only — the two version bumps are independent.
Module src/beadloom/graph/contracts.py
First-class cross-service contract model (F2). The federation/reconcile.py hub delegates contract reconciliation here. See the federation SPEC.
contract_key(payload: dict) -> str-- Derive a protocol-prefixed, language-neutral contract identity: AMQP →amqp:<exchange>/<routing_key>:<message_type>(missing exchange/routing fall back to*, so a v1 message-type-only payload yieldsamqp:*/*:<message_type>and still reconciles); GraphQL →graphql:<schema>; other →<protocol>:<message_type-or-name>.reconcile_contracts(edges: list[dict]) -> list[Contract]-- Group AMQP and GraphQL contract-bearing edges by(landscape, contract_key)into first-classContracts (BDL-038 / U5: implicit same-key matching is landscape-scoped so unrelated products never cross-pollute; an explicit@otherrepo:key is promoted cross-landscape into one shared group); accumulates the producerexposedsurface and consumerreferences(sorted + deduped) — plus, for AMQP, the producer'sexposed_bodyand consumer'sreferenced_bodyJSON-Schema (normalized) — and folds the most-significant edgelifecycle(external>dead>deprecated>planned>active) onto eachContract, then assigns itsverdictviaclassify. Insertion order preserved (the hub sorts the projected dicts bycontract_key). Helperscross_landscape_keys(edges)/edge_group_key(edge, keys)expose the grouping so the hub's UNDECLARED sweep stays landscape-consistent.classify(contract: Contract) -> ContractVerdict-- Assign the contract-level intent-vs-reality verdict (RFC §5, G5), lifecycle intent first:external→EXTERNAL(BDL-038 G7: a contract whose folded edgelifecycleisexternalresolves here — never DRIFT),dead→DEAD,planned/deprecated→EXPECTED; then shape: GraphQL or AMQP with both sides and a non-emptymissing_references(a GraphQL surface break —references ⊄ exposedor the typed verdict — or an AMQP body break) →BREAKING, both sides compatible →CONFIRMED, consumers-only →ORPHANED_CONSUMER, producers-only →UNDECLARED_PRODUCER. Complementary to the edge-levelEdgeVerdict.UNDECLARED(an additional projection, not a replacement).
Module src/beadloom/graph/sdl.py
Minimal, dependency-free GraphQL SDL surface extractor (F2). See the federation SPEC.
extract_surface(sdl_text: str) -> set[str]-- Return the producer's exposed names: top-levelQuery/Mutation/Subscriptionfield names plustype/input/enum/interfacetype names. Name-presence only (no schema validation); empty/whitespace-only/malformed SDL yields an empty set (callers sort it for determinism and record it honestly asexposed: []).
Public Data Classes
| Class | Module | Description |
|---|---|---|
ParsedFile | loader | Result of parsing a single YAML file: nodes, edges |
GraphLoadResult | loader | Summary: nodes_loaded, edges_loaded, errors, warnings, foreign_edges (list of ForeignEdge) |
ForeignEdge | loader | Frozen dataclass: src, dst, kind. A cross-repo edge endpoint (@repo:ref_id) recorded at single-repo load time (not inserted, not a dangling error) for hub resolution (F1) |
GraphParseError | loader | Exception raised when a graph YAML file cannot be parsed; carries the offending path and (when available) source line |
NodeChange | diff | Frozen dataclass: ref_id, kind, change_type, old_summary, new_summary, old_source, new_source, old_tags, new_tags, symbols_added, symbols_removed |
EdgeChange | diff | Frozen dataclass: src, dst, kind, change_type |
GraphDiff | diff | Frozen dataclass: since_ref, nodes, edges, property has_changes |
NodeMatcher | rules | Frozen dataclass: ref_id, kind, tag, exclude, method matches(node_ref_id, node_kind, *, tags=None) |
DenyRule | rules | Frozen dataclass: name, description, from_matcher, to_matcher, unless_edge, severity |
RequireRule | rules | Frozen dataclass: name, description, for_matcher, has_edge_to, edge_kind, severity |
CycleRule | rules | Frozen dataclass: name, description, edge_kind (str or tuple), max_depth (default 10), severity |
ImportBoundaryRule | rules | Frozen dataclass: name, description, from_glob, to_glob, severity. Matches file paths via fnmatch globs against code_imports |
ForbidEdgeRule | rules | Frozen dataclass: name, description, from_matcher, to_matcher, edge_kind (optional), severity. Forbids graph edges between matched nodes (operates on edges table, unlike DenyRule which checks code_imports) |
LayerDef | rules | Frozen dataclass: name, tag. Defines a single architecture layer for use in LayerRule |
LayerRule | rules | Frozen dataclass: name, description, layers (tuple of LayerDef), enforce ("top-down"), allow_skip (default True), edge_kind (default "uses"), severity. Enforces dependency direction between ordered layers |
CardinalityRule | rules | Frozen dataclass: name, description, for_matcher, max_symbols, max_files, min_doc_coverage, severity (default "warn"). Detects architectural smells via node-level cardinality checks |
Violation | rules | Frozen dataclass: rule_name, rule_description, rule_type (deny/require/cycle/forbid_import/forbid/layer/cardinality), severity, file_path, line_number, from_ref_id, to_ref_id, message |
SnapshotInfo | snapshot | Frozen dataclass: id, label, created_at, node_count, edge_count, symbols_count |
SnapshotDiff | snapshot | Frozen dataclass: old_id, new_id, added_nodes, removed_nodes, changed_nodes, added_edges, removed_edges, property has_changes |
ImportInfo | import_resolver | Frozen dataclass: file_path, line_number, import_path, resolved_ref_id |
LintResult | linter | Dataclass: violations, rules_evaluated, files_scanned, imports_resolved, elapsed_ms, properties error_count, warning_count, has_errors |
LintError | linter | Exception raised on invalid lint configuration |
C4Node | c4 | Frozen dataclass: ref_id, label, c4_level ("System" / "Container" / "Component"), description, boundary (parent ref_id or None), is_external, is_database |
C4Relationship | c4 | Frozen dataclass: src, dst, label (edge kind: "uses" / "depends_on") |
FederatedRef | federation | Frozen dataclass: repo (str | None), ref_id; properties is_foreign, qualified (@repo:ref_id or ref_id) |
FederationRefError | federation | ValueError raised on a malformed @... foreign ref |
EdgeVerdict | federation | Enum: OK / DRIFT / EXPECTED / CLEANUP_CANDIDATE / UNDECLARED / DEAD / EXTERNAL / UNMAPPED (intent-vs-reality verdict; EXTERNAL/UNMAPPED suppress DRIFT — BDL-038 G7/U4) |
FederatedGraph | federation | Dataclass: nodes, edges, repos, unresolved_refs, contracts — the composed result of aggregating ≥2 satellite exports |
ContractEndpoint | contracts | Frozen dataclass: repo, ref_id, direction, source_file — one side of a contract (F2) |
Contract | contracts | Dataclass: contract_key, protocol, name, endpoints, lifecycle, verdict, exposed (producer SDL surface), references (consumer-referenced names), and the AMQP exposed_body / referenced_body (the producer's / consumer's declared body JSON-Schema, BDL-060 S3); properties producers / consumers / body_breaking_fields (AMQP body break paths when both sides declared a body) / missing_references (the BREAKING signal — GraphQL consumer references absent from exposed, or the AMQP body break paths); to_report_dict() keeps F1's flat {message_type, directions, repos, confirmed} subset and adds verdict / protocol / contract_key / lifecycle (+ exposed / references / missing for GraphQL, the body break paths under missing for AMQP) |
ContractVerdict | contracts | Enum: CONFIRMED / DRIFT / ORPHANED_CONSUMER / UNDECLARED_PRODUCER / BREAKING / EXPECTED / EXTERNAL / DEAD (contract-level intent-vs-reality; assigned by classify) |
Constraints
- Files must be valid YAML
- UTF-8 encoding
- Only files with the
.ymlextension (not.yaml)
Testing
Tests: tests/test_graph_loader.py, tests/test_cli_graph.py, tests/test_diff.py, tests/test_diff_enhanced.py, tests/test_cli_diff.py, tests/test_rule_engine.py, tests/test_rule_severity.py, tests/test_cycle_rule.py, tests/test_import_boundary_rule.py, tests/test_linter.py, tests/test_cli_lint.py, tests/test_import_resolver.py, tests/test_import_scan.py, tests/test_symbol_diff_polish.py, tests/test_snapshot.py, tests/test_cli_snapshot.py, tests/test_c4.py, tests/test_graph_federation.py, tests/test_graph_contracts.py, tests/test_graph_sdl.py, tests/test_lifecycle_rules.py, tests/test_export.py, tests/test_federate.py, tests/test_federate_roundtrip_db.py