Skip to content

feat: Apache AGE (PostgreSQL) backend — push, incremental sync, branches, and retrieval - #3875

Open
e4c5 wants to merge 41 commits into
Graphify-Labs:v8from
e4c5:apache-age-backend
Open

e4c5 wants to merge 41 commits into
Graphify-Labs:v8from
e4c5:apache-age-backend

Conversation

@e4c5

@e4c5 e4c5 commented Sep 27, 2026

Copy link
Copy Markdown

What does this PR do?

Implements #3873 — an Apache AGE (PostgreSQL extension) backend for graphify, giving the project a Postgres-native path alongside Neo4j and FalkorDB, plus the machinery none of the existing pushers have: incremental sync, snapshots, and branch-aware graphs.

Phased commits (each milestone reviewable on its own):

  • Phase 0–1 — exporter: push_to_age() in graphify/exporters/graphdb.py — deletion-safe reconcile in the push transaction, batched PREPARE/EXECUTE + UNWIND, auto-created property indexes (GIN + per-prop btree expression), live-spike findings baked in (cypher() graph name must be a literal; SET n += rejects parameterized maps). Wired as graphify export age --push postgresql://... behind a new age extra (psycopg[binary]).
  • Phase 2 — registry: graphify/age_registry.py — plain-SQL registry (repos/snapshots/branches/diff tables, versioned idempotent migrations), stable repository_id = uuid5(normalized remote URL), per-repo default graph names so repos can't collide.
  • Phase 3 — snapshots + incremental sync: graphify/age_snapshots.py + a reversible extension of analyze.graph_diff() (adds properties/changed_nodes/changed_edges; additive keys only). Push applies O(changed) once a snapshot exists.
  • Phase 4 — branches: graphify/age_branches.py — feature branches push as validated diff chains over the base snapshot; graphify age materialize|reap; rebase/force-push detection fails closed into a fresh revision generation.
  • Phase 5–6 — retrieval + agent contract: graphify/age_backend.py (fetch_graph_from_age → NetworkX → identical scoring/formatting as the file backend) behind --age postgresql://... [--graph-name NAME] on query/path/explain; docs/AGE_SCHEMA.md pins the Cypher contract with live-tested fixtures.

Docs: docs/AGE_PLAN.md (full design + spike findings), docs/AGE_SCHEMA.md (agent query contract). Generated skills regenerated via tools.skillgen --bless (export age step + --age usage lines).

Type of change

  • New feature

Verification & Invariants

Invariants this code protects and how:

  • Deletion-safe reconcile: stale nodes/edges deleted in the same transaction as upserts — readers never see a partial graph. Covered by live integration tests (test_age_integration.py) that push, mutate, re-push, and assert row counts.

  • Provenance/direction: undirected nx.Graph edges recover extracted direction via _src/_tgt (same idiom as build.py/serve.py); a relabeled node is deleted under its old label.

  • Fail-closed: ambiguous first-push default-branch resolution is a hard error, not a guess; schema_version/extraction_config_hash mismatch refuses to extend a diff chain; non-contiguous diff chains raise.

  • Untrusted input: graph name → sql.Literal; table/label identifiers → sql.Identifier; Cypher labels/relations whitelisted to [A-Za-z0-9_]/[A-Z0-9_]; full-props attribute keys must match [A-Za-z_][A-Za-z0-9_]* before entering the Cypher SET clause; payloads via sql.Literal(json.dumps(...)); no shell=True anywhere (git calls are arg-list subprocesses with timeouts).

  • Determinism: serve.py traversal is sorted() so answers don't depend on backend row-scan order.

  • Credentials: AGE_PASSWORD/PGPASSWORD preferred over --password/DSN-embedded secrets, matching the neo4j/falkordb convention.

  • Read the CONTRIBUTING.md guide.

  • Reproduced the issue and identified the invariant.

  • Made the smallest fix necessary. (Feature-sized: one concern — AGE backend support — carried as phased commits per the one-branch-one-PR-per-feature convention.)

  • Added a regression test (if bug fix) or isolated boundary test.

  • Kept the PR description synchronized with the final implementation.

  • Documented any limitations / unsupported cases explicitly.

How was this tested?

uv sync --all-extras --frozen
uv run --frozen pytest tests/ -q --tb=short            # 6184 passed, 74 skipped
uv run --frozen ruff check .                            # clean
uv run --frozen pyright                                 # AGE files: only the 2 pre-existing upstream graphdb.py errors remain
uv run --frozen python -m tools.skillgen --check        # 134 artifacts in sync

# Live tier-2/3 suite (cannot run in CI — needs a real AGE instance):
docker run -d --name graphify-age -p 5432:5432 -e POSTGRES_PASSWORD=graphify_test \
  apache/age@sha256:4241e2d8bb86a6b2ea44e9ad06c73856e12b209de295124603a599dd7feb70eb
uv run --frozen pytest tests/ -q -k age                 # 962 passed, live Apache AGE 1.7.0 / PostgreSQL 18.1

Test layout mirrors the FalkorDB suite: tier-1 unit tests are pure/mocked and run everywhere; tier-2/3 integration tests auto-skip without a reachable AGE (service-identifying LOAD 'age' probe, AGE_HOST/AGE_PORT/AGE_DB/AGE_USER/AGE_PASSWORD overrides).

Limitations / unsupported cases

  • fetch_graph_from_age loads the whole named graph into NetworkX — same cost profile as loading graph.json, documented choice (server-side filtering would mean reimplementing scoring in Cypher).
  • serve.py/MCP is not AGE-wired (agents query AGE directly via the pinned contract) — deferred, see plan doc Phase 6.
  • --full-props pushes only scalar attributes whose keys are valid Cypher identifiers; others are dropped (no rewrite, to avoid silent collisions).
  • age extra overlaps postgres extra on psycopg[binary] — kept lean (no tree-sitter-sql) deliberately; can fold into postgres if preferred.
  • git worktree fallback for missing base snapshots requires a non-bare repo checkout.

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments.
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

AI-assisted implementation — commits carry Co-Authored-By trailers per the authorship convention.

e4c5 and others added 30 commits September 5, 2026 19:06
Adds docs/AGE_PLAN.md covering the Apache AGE graph-database sink:
cost-efficient sync, multi-branch support via commit-anchored diff
chains with lazy materialization, a multi-repo registry with
ownership, storage optimization, and a three-tier testing strategy.
Architecture approved across four external review rounds.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records the results of executing the Phase 0 compatibility spike
against a live apache/age container (PostgreSQL 18.1, AGE 1.7.0):

- cypher()'s graph name argument must be a literal, not a bind
  parameter (psycopg.errors.SyntaxError otherwise).
- SET n += <map> / SET n = <map> reject a map arriving via a
  parameter or UNWIND-bound data; only a literal map in the query
  text works. Invalidates the originally-assumed generic
  "SET n += row.props" batch-write shape.
- EXECUTE stmt(%s) with an agtype payload as a bind parameter fails
  with IndeterminateDatatype; the payload must be embedded via
  psycopg.sql.Literal instead.

Updates Phase 1's exporter design to build prepared statements with
an explicit per-field SET list (matching the fixed lean/full property
schema) rather than a generic property-map spread, and to embed the
graph name and EXECUTE payload as SQL literals rather than bind
parameters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds tests/test_age_integration.py, the Phase 0 deliverable from
docs/AGE_PLAN.md: a live-service integration suite for Apache AGE
following tests/test_falkordb_integration.py's exact pattern
(importorskip + service-identifying connection probe + env-var
overrides + clean-slate fixture), so it is a silent no-op in the
default CI and a manual, opt-in suite for a dev running a local
apache/age container.

Validated live against apache/age@sha256:4241e2d8bb...358fc5
(PostgreSQL 18.1, AGE 1.7.0), pinned by digest in the module
docstring:

- graph creation and basic cypher() round trips
- agtype parameter maps via PREPARE/EXECUTE
- UNWIND $rows + MERGE batch writes, including idempotent re-run
- transactional rollback on a mid-batch failure
- GIN index creation on an AGE label table

Also includes an end-to-end test against the future push_to_age()
exporter, which currently skips until Phase 1 lands the function.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records Phase 1 (push_to_age() exporter) as done, including a scope
note on the pure-planner/executor split from the Testing strategy
section: row/field selection and batching are pure and tier-1
tested, but the deletion-diff snapshot query and SQL statement
construction remain inside push_to_age() itself. Full CI-parity
suite green with --all-extras (5384 passed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds push_to_age() to graphify/exporters/graphdb.py, a sibling of
push_to_neo4j/push_to_falkordb, re-exported from graphify/export.py.

Built around the Phase 0 spike findings: the AGE graph name and the
EXECUTE payload are embedded as escaped SQL literals rather than
bind parameters, and every SET clause enumerates the pushed property
fields by name (AGE rejects a parameterized/UNWIND-derived map in
SET). Writes are batched via UNWIND $rows (~500 rows/statement),
grouped by sanitized label/relation, in prepared statements per
push, all inside a single transaction.

Deletion-safe: before upserting, snapshots existing (id, label) and
(src, tgt, relation) triples and deletes what's absent from the
incoming graph in the same transaction, including deleting a node
under its old label when file_type changes.

Also promotes _safe_label/_safe_rel to module level, deduplicating
them out of push_to_neo4j and push_to_falkordb, so label/relation
sanitization is identical and pinned by one shared test across all
three exporters.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tests/test_age_exporter_unit.py (20 tests, no DB, runs in default
CI): schema-pinning for _safe_label/_safe_rel, lean vs. full-props
field selection, row grouping with structural-metric stamping
(degree/is_god_node/in_cycle), batch chunking, and the
missing-psycopg ImportError guard.

tests/test_age_integration.py: extends the Phase 0 spike file with
the full push_to_age() reconcile matrix, validated live against
apache/age (PostgreSQL 18.1, AGE 1.7.0) - fresh push, idempotent
re-push, node/edge deletion reconciled, property change, label
change (old-label cleanup), atomicity on a pre-write failure, and
--full-props.

Found and fixed one real bug while writing these: node deletion used
`row.id` inside `UNWIND $ids AS row`, but $ids is a plain list, so
`row` is already the id scalar, not a map - AGE raised
"scalar object must be a vertex or edge". Fixed to `row` directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- graphify export age --push postgresql://user:pass@host/db
  [--graph-name NAME] [--full-props], password via AGE_PASSWORD or
  PGPASSWORD (appended as a libpq URI query param when not already
  embedded in --push). No offline cypher.txt fallback: AGE's
  cypher() requires a live connection to build statements against.
- New `age = ["psycopg[binary]"]` extra, independent of `postgres`
  (which also carries the unrelated tree-sitter-sql grammar); added
  to `all`. uv.lock regenerated via `uv lock`.
- docs/AGE_SCHEMA.md: the pinned Cypher schema contract (node/edge
  properties, label/relation sanitization, AGE-specific
  implementation notes from the Phase 0 spike, example queries).
- README: `age` extra listed alongside neo4j/falkordb.
- ARCHITECTURE.md: push_to_age() added to the export.py module row
  (test_architecture_doc.py still passes).

Validated end-to-end against a live apache/age container via the
actual CLI command, not just the Python function directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The plan originally called for one branch per phase. Checked the
actual convention: the README's Contributing section only ever says
"open a PR" (singular, never plural or "stacked"), and the closest
prior comparison in scope - the FalkorDB backend - shipped as one
branch (falkordb-backend) and one PR (Graphify-Labs#1176) containing four
separate commits. Consolidates the plan's Delivery section and every
phase's checklist to one branch (apache-age-backend, matching the
falkordb-backend naming) carrying every phase as separate commits;
"commit each milestone separately" means separate commits inside
that one branch/PR, not separate PRs per phase.

The age-phase0-spike and age-phase1-exporter branches used while
this was still being worked out have been folded into
apache-age-backend with their commits intact.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds graphify/age_registry.py, the authoritative SQL schema and
repository identity/ownership machinery from docs/AGE_PLAN.md
Phase 2. Plain PostgreSQL, not AGE - the registry tables live
outside any AGE graph namespace.

- normalize_remote_url() canonicalizes SSH and HTTPS remotes (and
  differing case) to the same dedup key; repository_id_for() derives
  a deterministic UUID from it via uuid5, so the id is computable
  client-side before a repo is ever registered.
- resolve_owner() implements the documented fallback order: explicit
  --owner -> remote URL's owner segment -> git config user.email.
- ensure_schema() applies an ordered list of idempotent migrations
  (tracked in graphify_registry_migrations), creating graphify_repos,
  graphify_snapshots, graphify_branches, graphify_branch_revisions
  (with the one-active-revision partial unique index),
  graphify_branch_diff, and graphify_quality_findings.
- register_repository() upserts a repo's identity row; fields COALESCE
  on conflict so a push that doesn't know the owner never blanks it
  out. resolve_age_graph_name() is the agent-facing discovery lookup,
  returning None (not raising) for an unregistered repo/branch or an
  unmigrated database.
- Git helpers follow graphify.watch._git_head's cwd-anchoring
  convention (Graphify-Labs#2316); _git_head itself is reused for the commit SHA
  rather than reimplemented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tests/test_age_registry_unit.py (16 tests, no DB, default CI):
normalize_remote_url SSH/HTTPS agreement and case folding,
repository_id_for determinism and uniqueness, resolve_owner's
fallback order.

tests/test_age_registry_integration.py (tier 2: live Postgres, no
AGE extension required - reuses the apache/age container purely as
a Postgres server): migration idempotency, repository upsert and
identity dedup across SSH/HTTPS remotes, COALESCE-on-conflict
behavior, agent discovery resolution (including the unregistered/
unmigrated-database case), the partial unique index rejecting a
second active branch revision, and snapshot uniqueness allowing a
delta and a reconstructed checkpoint for the same commit to coexist
while rejecting a true duplicate.

Found and fixed two real bugs while writing these: psycopg returns
UUID columns as uuid.UUID objects, not str, so register_repository()
now normalizes repository_id to str for callers; and
resolve_age_graph_name() raised UndefinedTable instead of returning
None when the registry schema doesn't exist yet, which a read-only
discovery call should never treat as an error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Wires age_registry into the CLI: after a successful push,
graphify export age --push registers/updates the repo's identity in
the registry (owner resolved via the documented fallback order,
branch/commit from git). New flags: --owner, --repo-tag,
--remote-url (override auto-detection), --no-register.

Fails soft in both directions - a missing git remote warns and
skips registration rather than failing the push; a registration
error after a successful push warns rather than losing the pushed
graph. Validated end-to-end against a live container, including the
--no-register and no-git-remote paths.

Also updates ARCHITECTURE.md's module table for age_registry.py
(test_architecture_doc.py still passes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records Phase 2 (repository identity, ownership, registry) as done,
with one deferred item made explicit rather than glossed over:
extraction_config_hash remains an unpopulated reserved column since
nothing threads real extraction config (backend/model/confidence
thresholds) through to the push path yet - added as a new Open
Questions entry to resolve in Phase 3, the first phase that actually
needs to detect incompatible graphs via this field.

Also records that the "global cross-repo AGE graph" deliverable
needed no new code: the existing CLI's --graph flag already lets
push_to_age() push the pre-merged, cross-repo-linked global graph
(global_graph.py + cross_repo_calls.py output) as-is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds changed_nodes/changed_edges (old and new scalar property values)
and a full "properties" payload on new/removed nodes and edges, so a
diff carries everything needed to replay it against a snapshot in
either direction. Purely additive to the existing return shape -
pre-existing callers (skill update.md docs, existing tests) are
unaffected. Groundwork for AGE Phase 3's logical snapshot/delta chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
graphify/age_snapshots.py stores periodic full checkpoints plus
ordered reversible deltas in graphify_snapshots (Phase 2's schema) and
reconstructs the latest known graph from them - the immutable base the
branch machinery (Phase 4) will eventually pin against. Not an AGE
graph copy; reconstruction never touches AGE.

Checkpoint cadence: every Nth push (default 20, overridable) is a full
checkpoint, the rest are deltas against the reconstructed prior state.
Checkpoint-preferred resolution when replaying rows, so a checkpoint
and a delta can coexist for the same commit_sha without the delta ever
being replayed. record_snapshot() is idempotent by commit_sha alone
(checked before the cadence/kind decision) - the uniqueness constraint
alone doesn't guarantee this, since which "kind" a re-push computes
depends on how many rows exist now, not on commit_sha. Found live via
test_record_snapshot_is_idempotent_for_same_commit and fixed before
this commit.

Reconstruction/diff-application logic is pure and DB-free (tier-1
tested in test_age_snapshots_unit.py); only the read/write of
graphify_snapshots rows needs live Postgres
(test_age_snapshots_integration.py, tier 2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
push_to_age() gains a diff= parameter (graph_diff()'s shape). When
given, it skips the full read-existing-AGE-state reconcile entirely
and instead upserts only the new/changed node and edge rows the diff
reports, deleting only what the diff reports removed - including
deleting a relabeled node under its *old* AGE label, since node
identity has no label component. O(changed) instead of O(graph) once
a prior snapshot exists (docs/AGE_PLAN.md Phase 3). diff=None (the
default) keeps the exact Phase 1 full-push behavior unchanged.

The filtering/deletion-computation logic is factored into
_age_diff_filter(), a pure function, so it's tier-1 testable without a
live AGE instance.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Repository identity is now resolved before the push (not only after,
as Phase 2's registration-only wiring did): with a known
repository_id, the CLI reconstructs the last-recorded snapshot,
diffs the new graph against it, and passes that diff into
push_to_age() for incremental sync. Falls back to a full push when
there's no prior snapshot, --no-register is passed, or no git remote
is found - identical to Phase 1/2 behavior in all of those cases.
After a successful push, the snapshot for this commit is recorded
(record_snapshot()) alongside the existing registry upsert.

Validated live end-to-end: full push -> checkpoint -> incremental
push that adds, removes, and relabels nodes -> verified directly
against AGE's live graph state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Documents age_snapshots.py in ARCHITECTURE.md, resolves the
checkpoint-cadence open question (every Nth push, default 20), and
records the commit_sha-idempotency bug found and fixed live during
Phase 3 testing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds an optional conn= parameter: when given an already-open psycopg
connection, push_to_age() writes through it and leaves commit/close to
the caller instead of opening and managing its own. Needed for AGE
Phase 4's atomic branch-push requirement (the AGE mutation, new diff
rows, and head SHA update must commit together in one transaction).
conn=None (the default) keeps every existing caller's behavior
unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two additions age_branches.py's base-snapshot resolution needs:

- snapshot_graph_by_id(): reconstruct the graph "as of" a specific
  recorded snapshot (not just "latest"), via a new pure
  _reconstruct_payload_up_to() that truncates the replay chain at a
  given snapshot_id.
- record_historical_checkpoint(): record a checkpoint reconstructed
  out-of-band (a git-worktree extraction at an old commit) for a
  commit older than anything currently retained. Backdates created_at
  to just before the repository's earliest existing row instead of
  using the insert-time default - _reconstruct_payload_from_rows()
  walks rows in created_at order assuming that tracks git history
  order, so inserting an old commit's state with a plain now()
  timestamp would make it look like the *newest* state and silently
  corrupt every later default-branch reconstruction. Caught during
  design, not live - documented in the function's docstring.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A side-effect-free counterpart to register_repository()'s upsert:
returns the registered row (including default_branch) or None,
without creating anything. The CLI's branch-aware export age dispatch
(Phase 4) needs the registered default_branch to decide whether the
current push targets the default branch or a feature branch, and
shouldn't have to run a full upsert just to check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
graphify/age_branches.py implements docs/AGE_PLAN.md Phase 4: a
non-default branch is never pushed as its own full AGE graph. Instead
push_branch() records an ordered, reversible diff chain in
graphify_branch_diff against a pinned base snapshot (resolved via
merge-base detection, falling back to a temporary git-worktree
extraction when nothing retained covers that commit). An AGE graph is
only created lazily, on demand, via materialize_branch() - serialized
by a PostgreSQL advisory lock keyed on (repository_id, branch) so
concurrent callers do one hydration, not two. reap_branch() drops a
materialized branch graph after a generous idle grace period; diff
rows and snapshots are never deleted, so a reaped branch always
re-materializes cleanly.

Force-push/rebase is detected via git ancestry (head_commit_sha no
longer an ancestor of the new head), which starts a fresh generation
from a newly resolved base rather than corrupting the existing diff
chain; the old generation is marked inactive, never deleted, and its
now-stale materialized graph (if any) is dropped.

Replay/validation logic (replay_branch_diffs, diff_to_rows,
rows_to_diff) is pure and DB-free, tier-1 tested against fabricated
rows for exactly the cases the plan calls out: transition ordering,
non-contiguous chains, and cross-generation rejection. Git-only helpers
(merge_base, is_ancestor, detect_rebase, and the worktree-extraction
fallback) are also tier-1 tested against throwaway git repos in
tmp_path - no database needed for any of this. Only
push_branch/materialize_branch/reap_branch touch Postgres, and only
materialize_branch/reap_branch touch AGE (tier-3 live suite).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
graphify export age --push now checks the registered default_branch
before pushing: on the default branch it keeps Phase 1-3's full/
incremental default-branch flow unchanged; on any other branch it
routes through age_branches.push_branch() instead - a diff-chain-only
push that creates no AGE graph.

New graphify age materialize / graphify age reap verbs expose the
lazy-materialization and reaping machinery directly, both taking
--push URI, --branch (defaults to the current git branch), and
--graph-name; reap additionally takes --min-idle-seconds.

Validated live end-to-end via a full CLI smoke test: default-branch
push -> registration -> feature-branch push (diff rows only, no AGE
graph) -> graphify age materialize -> graphify age reap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Documents age_branches.py in ARCHITECTURE.md, adds a pinned
cross-branch comparison query to AGE_SCHEMA.md (two independent
cypher() calls joined in one SQL statement, live-tested), and resolves
the graph-name-sanitization and reaper-trigger-mechanism open
questions. Records bytes_used alongside extraction_config_hash as an
honest, documented gap - no size-threshold reaping without it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ters

push_to_neo4j, push_to_falkordb, push_to_age, graph_diff(), and
age_snapshots.graph_to_payload() all read edge source/target straight
off nx.Graph.edges()'s (u, v) tuple - but for an undirected Graph that
tuple order reflects node insertion order, not which side was the
extracted "source". build.py has stamped _src/_tgt on every edge for
exactly this reason since Graphify-Labs#1061 ("Preserve original edge direction -
undirected graphs lose it otherwise"), and analyze.py/serve.py/cli.py/
exporters/html.py already read it - graphdb.py and graph_diff() never
did.

Confirmed live and reproducible in three lines:
  G = nx.Graph(); G.add_node("hub"); G.add_node("caller")
  G.add_edge("caller", "hub", relation="calls")
  list(G.edges())  # -> [("hub", "caller")], reversed

Found while validating Phase 5's pinned reverse-dependency-traversal
query against a real AGE instance: a query for callers of a hub node
returned zero rows because every CALLS edge had been pushed backwards.

Fixed by reading data.get("_src", u) / data.get("_tgt", v) everywhere
edge direction is read from a graph, falling back to the tuple order
when _src/_tgt is absent (a directed graph, or a hand-built test
fixture) - unchanged behavior there. age_snapshots.payload_to_graph()
additionally stamps _src/_tgt on reconstructed edges so the direction
survives a payload round trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/AGE_SCHEMA.md gains an "Agent contract" section (review-agent
seed selection / traversal direction / required evidence fields /
depth-cap-limit-timeout discipline; quality-agent structural-property
vs. graphify_quality_findings boundary) plus new pinned queries:
branch-diff seed selection (plain SQL against graphify_branch_diff),
depth-capped/limited/timed-out traversal, and quality findings joined
with structural properties - each executed verbatim in
tests/test_age_agent_queries_integration.py against a seeded live AGE
instance, not just written down.

Rewrote the existing shortest-path pinned query after finding, live,
that AGE 1.7.0 has no shortestPath() function and that list-
comprehension path projection ([n IN nodes(p) | n.id]) fails outright
on this version - neither had ever actually been run before. Replaced
with length(p) ordering plus client-side parsing of the returned path,
and added a pinned parse_agtype_objects() helper since a returned
vertex/edge/path is agtype text (a {...}::vertex/::edge/::path suffix
after each object), not plain JSON, and a naive regex breaks on the
nested "properties" object.

extraction.json (the falkordb suite's fixture) proved too small to
meaningfully exercise depth caps/result limits/statement timeouts, so
a dedicated 200-node fan-out fixture was added per the plan's own
instruction, rather than weakening those assertions. statement_timeout
is proven to actually cancel a runaway query (QueryCanceled), not just
documented as advice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records the two AGE 1.7.0 gaps found live (no shortestPath(), broken
list-comprehension path projection) and the edge-direction bug fixed
as a prerequisite for Phase 5's queries to even validate correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
graphify/age_backend.py fetches a whole AGE graph into an nx.MultiGraph
shaped exactly like build()'s output: full node property set as attrs,
_src/_tgt stamped on every edge (an undirected nx.Graph loses direction
otherwise, same idiom as build.py/analyze.py/exporters/graphdb.py). A
MultiGraph so a genuine parallel edge between the same pair (two
different relation types) is never silently dropped.

Also promotes parse_agtype_objects (docs/AGE_SCHEMA.md's pinned
depth-aware agtype vertex/edge/path parser - a returned object is
"{...}::vertex" text, not plain JSON, and a naive regex breaks on the
nested "properties" object) into this module as the canonical
implementation, and updates test_age_agent_queries_integration.py to
import it instead of keeping a duplicate copy that could drift from
the doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each subcommand's existing graph-loading block gains an
--age postgresql://... --graph-name NAME alternative to --graph: when
given, fetch_graph_from_age() replaces the file-load step and
everything downstream (scoring, path-finding, formatting, querylog)
runs unchanged. `explain` additionally derives a directed view from
the fetched MultiGraph's _src/_tgt (it needs successors()/
predecessors(), which only a directed graph has) the same way `path`'s
file-backend block already does. No local file means no size cap and
no query-stamp file to write.

Proven, not just argued, to be behavior-identical to the file backend:
tests/test_age_backend_integration.py pushes one graph to both a local
graph.json and a live AGE instance and asserts the CLI's printed
output is byte-for-byte identical (path/explain exactly; query modulo
the one "Graph: ..." header line that legitimately differs).

serve.py (the MCP/HTTP server) is deliberately out of scope here - its
_load_graph/multi-graph-context-LRU/hot-reload machinery is keyed on
graph_path as a filesystem path threaded through several already-large
functions, and retrofitting an AGE connection as an alternate "path"
carries real regression risk to existing MCP clients for a nice-to-
have. cli.py's one-shot subcommands have no such state to reconcile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records the scope decision to implement AGE-backed retrieval for
graphify query/path/explain but not serve.py's MCP/HTTP server, and
why.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ental sync, schema enforcement, rebase atomicity)

An external review found several correctness gaps blocking merge. All fixed:

- Critical: every repo pushed without --graph-name shared the literal AGE
  graph "graphify", so a second repo's deletion-safe reconcile deleted the
  first repo's data. Default graph name is now derived from repository_id
  (age_registry.default_age_graph_name); branch materialize/reap resolve
  the registered name instead of re-defaulting.
- Incremental sync silently dropped every changed/new edge for a normal
  lowercase relation (_age_diff_filter compared AGE's sanitized relation
  key against the diff's raw relation string without normalizing both).
- A file_type change deleted a node's otherwise-unchanged edges via
  DETACH DELETE without re-upserting them.
- Schema/extraction-config compatibility is now actually enforced: branch
  rows persist graphify_version/schema_version/extraction_config_hash,
  snapshot reconstruction and base-snapshot resolution filter by them, and
  a push whose config diverges from its branch's recorded config is
  rejected instead of spliced in.
- A non-atomic rebase could leave graphify_branches claiming
  materialized=true for an AGE graph already dropped; the drop and the
  generation-transition registry update are now one transaction.
- Concurrent branch pushes were unserialized; push_branch now holds the
  same per-(repository_id, branch) advisory lock materialize_branch/
  reap_branch already used.
- graph_diff() ignored a pure edge-direction reversal (same pair, same
  props, only _src/_tgt swapped); it's now reported as an explicit
  remove-old-direction + add-new-direction pair.
- reap_branch's idle-time semantics measure push-recency, not query
  activity; docs/CLI wording now say so plainly rather than implying
  unimplemented access tracking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
e4c5 and others added 11 commits September 6, 2026 13:48
…ication, extraction-config enforcement)

Two more high-severity gaps from a second review pass:

- An unregistered repository's first `graphify export age` push
  unconditionally treated whatever branch was checked out as the default
  branch, so pushing first from a feature branch registered it as the
  durable default-branch graph with no diff chain recorded. Added
  --default-branch, plus age_registry.resolve_default_branch_ref() to
  read refs/remotes/origin/HEAD when available; falls back to a loud
  stderr warning (never a silent guess) when neither resolves.
- extraction_config_hash was defined and enforced in age_branches/
  age_snapshots but the CLI never computed or passed one, so the check
  was always a no-op. Added age_registry.extraction_config_hash() (a
  deterministic hash of --full-props plus an optional caller-supplied
  --extraction-config / GRAPHIFY_EXTRACTION_CONFIG_HASH fingerprint),
  threaded through every CLI call to push_branch/record_snapshot/
  latest_snapshot_graph. Also extended the extraction_config_hash filter
  to snapshot_graph_by_id and record_snapshot's own internal
  reconstruction, which previously filtered on schema_version alone.

Two more bugs surfaced live while exercising the --default-branch fix
through the real CLI (previously masked because every branch test
pre-registered the repository and passed an explicit cwd):

- age_branches._extract_graph_at_commit did `cwd=str(cwd)`
  unconditionally for its `git worktree` subprocess calls, turning a
  None cwd (the CLI's default) into the literal string "None".
- The feature-branch push path never upserted graphify_repos before
  writing rows that foreign-key onto it, so an unregistered repo's first
  push failed outright with ForeignKeyViolation if it happened to be a
  feature branch. Registration now happens once, before dispatching to
  either push path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A third review pass correctly flagged that the warn-and-assume fallback
for an unregistered repo's first push (no --default-branch, no resolvable
refs/remotes/origin/HEAD) preserved the exact original bug -- durably
registering whatever branch happened to be checked out as the default-
branch graph -- just with a warning printed first. A warning is not an
adequate guard for a decision this persistent.

Changed to a hard error: refuses to push at all, telling the caller to
pass --default-branch, run `git remote set-head origin -a`, or use
--no-register. This does mean a fresh repo's very first `export age
--push` now requires --default-branch (or a remote with HEAD set) even in
the ordinary single-branch case -- an intentional, documented trade of a
small one-time friction cost against a hard-to-undo footgun.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Mark the Phase 2 "deferred" checkbox done: Phase 3 + the v15 review
  fixes thread graphify_version/schema_version/extraction_config_hash
  into every graphify_snapshots write, and the CLI now computes a real
  extraction_config_hash via age_registry.extraction_config_hash().
- Correct the Status header and the Open Questions entry to say the hash
  is populated and enforced, with only its inputs still coarse.
- README Contributing: document the fork -> branch -> PR-to-upstream flow
  and the one-branch/one-PR-per-feature rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…itself

Running the full graphify pipeline on this repo (~13k nodes / ~27k edges),
pushing to a live AGE instance, and diffing query/path/explain output
against the graph.json backend surfaced three bugs the small
tests/fixtures/extraction.json never exercised:

1. age_backend.parse_agtype_objects (also AGE_SCHEMA.md's pinned parser)
   tracked brace nesting but not string state, so a `label` property
   holding literal `{`/`}` (a docstring fragment, a JS `${x}` template
   literal) corrupted the parse and returned nothing -> the whole AGE
   backend crashed with `not enough values to unpack`.

2. graphify export {age,neo4j,falkordb} loaded graph.json (written
   `directed: false`, direction carried only in source/target arc order)
   as an undirected graph, canonicalising endpoint order and reversing
   ~79% of edges vs what `graphify path`/`explain` see. Separate from the
   earlier _src/_tgt fix: this is the CLI loader never handing the
   exporters a directed graph. Fixed by forcing directed+multigraph for
   the graph-DB sinks.

3. _bfs/_dfs/_subgraph_to_text walked neighbours and rendered edges in
   edge-insertion order, and explain broke degree-sort ties the same way,
   so graph.json link order vs AGE row-scan order produced different
   budget-truncated answers. Neighbour iteration and edge rendering are
   now sorted by node id; explain's connection sort gets an str(id)
   tie-break.

After the fixes, query/path/explain output is identical between the two
backends (64/64 comparisons, modulo the intentional `Graph:` header).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
push_to_age() was ~6.6x slower than necessary: every MATCH (n {id: ...}) /
MERGE (n:L {id: ...}) compiles to a `properties @> '{"id": ...}'` filter,
and AGE indexes only its own internal id/start_id/end_id columns -- nothing
on the user-facing properties agtype -- so each id lookup was a sequential
label-table scan per UNWIND row. A full push of the ~13k-node / ~27k-edge
self-graph took 9m36s.

_ensure_age_property_indexes() now create_vlabel's each incoming vertex
label up front and indexes its properties before any MATCH/MERGE, with the
three index kinds AGE's query compilation needs:
  - GIN (properties) -- serves the `@>` matches push_to_age emits itself
    (9m36s -> 2m47s).
  - btree on agtype_access_operator(properties, '"id"') and on
    '"source_file"' -- serve idiomatic agent Cypher (WHERE n.id = / IN,
    WHERE n.source_file =) and AGE_SCHEMA.md's pinned queries, which
    compile to the access-operator form GIN does not serve. The plan
    always called for these ("indexes on what agents filter by") but they
    were never created.

Edge upsert additionally sub-groups each relation's rows by the AGE labels
of its endpoints and emits MATCH (a:SrcLabel {id: ...}), (b:TgtLabel
{id: ...}) so a labelled match probes one label table's index instead of
all of them (2m47s -> ~1m30s). Larger UNWIND batches were measured and are
worse, not better; batch_size=500 stays.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
AGE_PLAN.md gains a "post-implementation dogfooding pass" section under
Phase 6 documenting the three correctness bugs and the push-performance
work found running graphify on its own repo, plus the file-vs-AGE
comparison result (structural + retrieval parity, storage and latency
trade-offs).

AGE_SCHEMA.md: the pinned agtype parser is now string-aware; the
implementation notes explain which property indexes AGE does and does not
create and which Cypher idiom each index kind serves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	pyproject.toml
#	uv.lock
Type-checker hygiene ahead of the upstream PR: narrow Optional on
fetchone()/RETURNING rows and cursor.description (aggregate queries and
INSERT ... RETURNING always yield a row), return the matched snapshot id
directly instead of the Optional-typed reconstruction result, and mark
_MIGRATIONS as LiteralString so psycopg's execute() overloads match --
which also pins future migrations to literal SQL.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
In --full-props mode, the union of node/edge attribute keys is
interpolated raw into the Cypher SET clause (AGE rejects parameterized
property maps). Values are safe -- they ride PREPARE/EXECUTE JSON
payloads -- but keys were not validated. No current extractor produces
source-derived attribute names, but source text is untrusted per
CONTRIBUTING.md, so only keys matching a bare Cypher identifier
([A-Za-z_][A-Za-z0-9_]*) are pushed now; no rewrite, since a rewritten
name could collide with a real property.

Also casts internally-built SQL fragments to LiteralString via a
_sql_text() helper so pyright accepts the deliberate sql.SQL() dynamic
interpolation (all fragments are whitelisted identifiers/field names),
and narrows an Optional fetchone() on the ag_graph count probe.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The AGE backend added `export age --push` and `--age` on
query/path/explain but no skillgen fragment mentioned either, so agents
never discovered them. Adds Step 7aa to the exports fragment
(7aa rather than renumbering -- 7b/7c/7d headings are pinned by
--audit-coverage), --age-push/--age usage lines to the shared core
fragment, and an --age note to the query reference. Regenerated via
`python -m tools.skillgen --bless`; --check confirms 134 artifacts in
sync.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The repo moved to Graphify-Labs/graphify but the fork/PR instructions in
README and docs/AGE_PLAN.md still pointed contributors at the old org.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

Thanks for the pull request, @e4c5. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.


Graphify review — findings

Adds Apache AGE (PostgreSQL) as a graph-database backend via a new age extra, exposing push_to_age alongside the other exporters so graphify export age --push writes a Cypher graph directly to an AGE instance. Backs it with a multi-repo/multi-branch registry that derives each repo's AGE graph name from its repository_id (no longer a shared literal "graphify"), records logical base snapshots plus incremental deltas for the default branch, and carries non-default branches as commit-anchored diff chains that materialize lazily and reap when idle; graphify query/path/explain --age URI --graph-name NAME fetch from a live AGE graph and reuse the file backend's scoring unchanged. Fixes a directional-edge bug across all graph-DB exporters and graph_diff() where a pure edge reversal produced no diff, and makes an unregistered repo's first push a hard error when the default branch can't be resolved (requiring --default-branch, a remote HEAD, or --no-register) rather than silently misclassifying the checked-out branch.

Worth a look

  • Edge label/type dropped when loading AGE graph into NetworkX — graphify/age_backend.py:145 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Reaper can drop a branch after a concurrent push refreshes it — graphify/age_branches.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Concurrent schema migration can race and fail on migration-version insert — graphify/age_registry.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • push_branch now requires graphify_version and schema_version keywords — graphify/age_branches.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Advisory-lock connection is not closed if unlock fails — graphify/age_branches.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3051 functions depend on the 2409 functions this change touches.

Health — this change adds coupling hotspots:

  • new: _rebuild_code() — 144 callers, 55 callees
  • new: to_obsidian() — 41 callers, 14 callees
  • new: to_json() — 58 callers, 7 callees
  • new: push_to_age() — 22 callers, 14 callees
  • new: main() — 100 callers, 3 callees
  • new: dispatch_command() — 2 callers, 145 callees
  • new: _query_graph_text() — 27 callers, 10 callees
  • new: generate() — 35 callers, 7 callees
  • …and 56 more — each is listed as a finding

Verification — 3051 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2996 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

314 of 314 test file(s) selected (100%) via static blast radius.

Escalated to a full run for safety — the selection is not trustworthy on its own (see below). CI should run the whole suite.

  • tests/test_affected_cli.py — impact, full-run-safety
  • tests/test_affected_member_seed.py — full-run-safety
  • tests/test_age_agent_queries_integration.py — impact, changed-test, full-run-safety
  • tests/test_age_backend_integration.py — impact, changed-test, full-run-safety
  • tests/test_age_backend_unit.py — impact, changed-test, full-run-safety
  • tests/test_age_branches_integration.py — impact, changed-test, full-run-safety
  • tests/test_age_branches_unit.py — impact, changed-test, full-run-safety
  • tests/test_age_cli_multirepo_integration.py — impact, changed-test, full-run-safety
  • tests/test_age_exporter_unit.py — impact, changed-test, full-run-safety
  • tests/test_age_integration.py — impact, changed-test, full-run-safety
  • tests/test_age_registry_integration.py — impact, changed-test, full-run-safety
  • tests/test_age_registry_unit.py — impact, changed-test, full-run-safety
  • tests/test_age_snapshots_integration.py — impact, changed-test, full-run-safety
  • tests/test_age_snapshots_unit.py — impact, changed-test, full-run-safety
  • tests/test_agents_platform.py — impact, full-run-safety
  • tests/test_analyze.py — impact, changed-test, full-run-safety
  • tests/test_anthropic_custom_endpoint.py — full-run-safety
  • tests/test_antigravity_install.py — full-run-safety
  • tests/test_apm_fallback_version.py — full-run-safety
  • tests/test_architecture_doc.py — full-run-safety
  • tests/test_astro_extraction.py — full-run-safety
  • tests/test_astro_import_ids.py — full-run-safety
  • tests/test_atomic_canvas_export.py — impact, full-run-safety
  • tests/test_atomic_version_stamp.py — full-run-safety
  • tests/test_atomic_writes.py — impact, full-run-safety
  • tests/test_backend_env_isolation.py — full-run-safety
  • tests/test_backend_extras.py — full-run-safety
  • tests/test_benchmark.py — impact, full-run-safety
  • tests/test_benchmark_raw_graph.py — impact, full-run-safety
  • tests/test_build.py — impact, full-run-safety
  • tests/test_build_merge_dedup_scope.py — full-run-safety
  • tests/test_build_merge_hyperedges_and_prune.py — full-run-safety
  • tests/test_build_merge_shrink_guard.py — full-run-safety
  • tests/test_builtin_global_type_refs.py — full-run-safety
  • tests/test_cache.py — full-run-safety
  • tests/test_callflow_html.py — full-run-safety
  • tests/test_cargo_introspect.py — full-run-safety
  • tests/test_cargo_missing_manifest.py — full-run-safety
  • tests/test_carried_hyperedge_remap.py — impact, full-run-safety
  • tests/test_case_sensitive_resolution.py — full-run-safety
  • tests/test_charmap_encoding.py — full-run-safety
  • tests/test_chunking.py — full-run-safety
  • tests/test_cjs_module_extension.py — full-run-safety
  • tests/test_claude_cli_backend.py — full-run-safety
  • tests/test_claude_md.py — full-run-safety
  • tests/test_cli_broken_pipe.py — full-run-safety
  • tests/test_cli_export.py — impact, changed-test, full-run-safety
  • tests/test_cli_help.py — full-run-safety
  • tests/test_cluster.py — full-run-safety
  • tests/test_cobol_extractor.py — full-run-safety
  • … and 264 more

non-code file(s) changed (ARCHITECTURE.md, README.md, docs/AGE_PLAN.md, docs/AGE_SCHEMA.md, graphify/skill-agents.md …) → running the full suite for safety (a code graph can't see config/fixture/data deps)

changed code file(s) with no mapped test (ARCHITECTURE.md, README.md, docs/AGE_PLAN.md, docs/AGE_SCHEMA.md, graphify/skill-agents.md …) — a coverage gap or a missing link — running the full suite rather than only the selected tests

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Risky patterns (advisory)

  • medium no-unbounded-fetch in graphify/age_backend.py:120: unbounded read (no limit/bound): for (raw,) in cur.fetchall():
  • medium no-unbounded-fetch in graphify/age_backend.py:136: unbounded read (no limit/bound): for src_raw, r_raw, tgt_raw in cur.fetchall():
  • medium no-unbounded-fetch in graphify/age_branches.py:275: unbounded read (no limit/bound): rows = cur.fetchall()
  • medium no-unbounded-fetch in graphify/age_branches.py:337: unbounded read (no limit/bound): for seq, gen, frm, to, kind, p in cur.fetchall()
  • medium no-unbounded-fetch in graphify/age_registry.py:336: unbounded read (no limit/bound): applied = {row[0] for row in cur.fetchall()}
  • medium no-unbounded-fetch in graphify/age_snapshots.py:225: unbounded read (no limit/bound): rows = cur.fetchall()
  • medium no-unbounded-fetch in graphify/age_snapshots.py:267: unbounded read (no limit/bound): rows = cur.fetchall()
  • medium no-unbounded-fetch in graphify/age_snapshots.py:394: unbounded read (no limit/bound): rows = cur.fetchall()
  • medium no-unbounded-fetch in graphify/exporters/graphdb.py:621: unbounded read (no limit/bound): existing = cur.fetchall()
  • medium no-unbounded-fetch in graphify/exporters/graphdb.py:689: unbounded read (no limit/bound): existing_edges = cur.fetchall()

Docs that may be stale (advisory)

…and 10 more.

· 20 grounded finding(s) anchored inline below; 41 more finding(s) on lines outside this diff (see the check run); 3 additional anchorable finding(s) not shown (cap).

Comment thread graphify/age_backend.py
return json.loads(raw) if isinstance(raw, str) else raw


def fetch_graph_from_age(conninfo: str, graph_name: str) -> nx.MultiGraph:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — fetch_graph_from_age()

high coupling complexity (Ca·Ce = 12).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/age_branches.py
return not is_ancestor(cwd, old_head, new_head)


def _extract_graph_at_commit(cwd: Path | str | None, commit_sha: str) -> nx.Graph:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _extract_graph_at_commit()

high coupling complexity (Ca·Ce = 16).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/age_branches.py
conn.close()


def push_branch(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — push_branch()

14 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/age_branches.py
lock_conn.close()


def _push_branch_locked(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — _push_branch_locked()

fans out to 9 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Comment thread graphify/age_branches.py
}


def materialize_branch(conninfo: str, repository_id: str, branch: str, *, default_graph_name: str) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — materialize_branch()

7 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert cur.fetchone()[0] == 0


def test_materialize_branch_creates_age_graph_with_correct_content(conn, repository_id, tmp_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_materialize_branch_creates_age_graph_with_correct_content()

fans out to 9 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert stored_name == graph_name


def test_materialize_branch_is_idempotent(conn, repository_id, tmp_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_materialize_branch_is_idempotent()

fans out to 9 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert r1["age_graph_name"] == r2["age_graph_name"]


def test_push_branch_applies_incremental_diff_to_materialized_graph(conn, repository_id, tmp_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_push_branch_applies_incremental_diff_to_materialized_graph()

fans out to 9 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert ids == ["a", "c"]


def test_reap_branch_drops_graph_after_idle_threshold(conn, repository_id, tmp_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_reap_branch_drops_graph_after_idle_threshold()

fans out to 10 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

assert remat["already_materialized"] is False


def test_cross_branch_comparison_query(conn, repository_id, tmp_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_cross_branch_comparison_query()

fans out to 10 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant