Skip to content

kg: cut da kg over to the kg-native CRG backend (bridge kept as rollback) - #553

Open
NikashPrakash wants to merge 24 commits into
masterfrom
feat/crg-native-cutover
Open

NikashPrakash wants to merge 24 commits into
masterfrom
feat/crg-native-cutover

Conversation

@NikashPrakash

Copy link
Copy Markdown

CRG decommission Phase A, workstream 1: cut da kg over from the Python
code-review-graph subprocess bridge to the kg-native adapter. The bridge is
kept and stays reachable behind explicit config — this PR deletes nothing.

Task: graph-backend-adapter-contract / t6-bridge-decommission
Spec: .agents/workflow/specs/graph-backend-adapter-contract/design.md §11.4
(progress note added; the four gate conditions are unchanged)
Audit: docs/crg-bridge-consumer-audit.md

What changed

1. The backend is now a published contract.
graphstore.CodeGraphProvider (internal/graphstore/provider.go) is exactly
the eight §11.1 parity rows — build, update, status, impact-radius, flows,
communities, postprocess, detect-changes — plus the two bulk-export reads the
warm-link sync needs. *CRGBridge and the new native engine both satisfy it,
so the cutover is a provider swap rather than a rewrite.

2. The kg-native engine (internal/codegraph).

  • scan.go ingests the repo with go/ast into the normalized symbol corpus
    the crg adapter models: Function/Type symbols named <pkgPath>.<Name>
    and CALLS / IMPORTS / TESTED_BY references. Resolution is conservative:
    same-package and aliased-import hits win, and a bare member name resolves
    only when exactly one declaration carries it, so an ambiguous name produces
    no edge rather than a wrong one.
  • engine.go / query.go persist through the published
    graphstore.CodeGraphWriter contract into <repo>/.dot-agents/code-graph.db
    and compute every derived view by reading that storage back through the
    crg adapter's parity-verified derivations. Nothing is served from the
    in-memory scan, so a divergent write stays visible — the same readback
    discipline the §11.6 oracles use, and the reason the CLI and the parity gate
    cannot drift apart.
  • namespace.go lowers persisted nodes/edges through crg.Corpus.ToGraph, so
    the note/edge field names and symbol-id derivation have one definition,
    shared with the adapter's own ingestion path.
  • NullProvider backs the none adapter, so "no graph backend" is a
    first-class configuration rather than a nil every call site must guard.

3. Backend selection. commands/kg/backend.go is the single chokepoint:

Adapter ref Backend Default
dotagents-builtin:graph/crg@^1.0 codegraph.Engine (in-process) yes
dotagents-builtin:graph/crg-bridge@^0.1 graphstore.CRGBridge (Python) no — §11.4 rollback
dotagents-builtin:graph/none@^1.0 codegraph.NullProvider no

Resolution order: DA_KG_GRAPH_BACKEND.agentsrc.json kg.graph_backend
→ kg-native default. The ref is resolved through the built-in adapter
registry
, so an unregistered or version-incompatible ref is rejected instead
of silently defaulting; bare names (crg, crg-bridge, none) are expanded.
Both registries now register the family via crgbridge.RegisterCRGFamily
not two bare Register calls, because that entry point also runs
registry.EnforceReadsFrom, without which the §11.2 migration_only gate is
registered but inert.

4. Consumers re-pointed. sync_code_warm_link.go (11 ref-lines) and
bridge.go (2) no longer reference CRGBridge at all; da kg serve uses the
new NewMCPServerWithProvider. Graceful degradation is preserved and
generalized: the bridge-era DiscoverCRGBin probe becomes
errBackendUnavailable, so the post_tool_use update hook, the warm code
lane and the readiness checks still no-op rather than failing a session when
the selected backend's tooling is absent.

5. MCP fallbacks. Several tools read the warm store
($KG_HOME/ops/graphstore.db), which is only populated by an explicit
kg warm --include-code mirror pass. With the code graph now living with the
backend, get_impact_radius, semantic_search_nodes, list_graph_stats and
get_review_context's impact section fall back to the backend's own graph
when the mirror is empty — otherwise they would have started reporting an
empty graph.

Tool-shape deltas (documented, not silent)

Full table in docs/crg-bridge-consumer-audit.md. The load-bearing ones:

  • Language coverage — the bridge used Tree-sitter across several
    languages; the native ingester is Go-only today. Non-Go repos build an
    empty graph on the native backend; the rollback backend still covers them.
    Closing this is prerequisite to t6d deleting the bridge.
  • get_review_context_tool — same JSON keys and value types, but
    changed_symbols is derived from the persisted graph (degree-centrality
    risk normalized 0–1, caller counts, TESTED_BY-derived test gaps) rather
    than the CRG composite's own heuristic + LLM-assisted summaries.
  • semantic_search_nodes_tool — unchanged by this PR (it already read the
    warm store, not the bridge). The vector-search richness gap is pre-existing;
    this PR only adds a backend fallback so it stops returning empty.
  • Community description — empty (the bridge's were LLM-authored);
    cohesion is now a structural connected-pair ratio. Members, size,
    dominant language and ids keep their shape.
  • Flow / community ids — positional within one response (the derivation
    keys a flow by its entry-point symbol id, a string). §11.6 already compares
    flows by (flow_id, member_id, position) set equality, not by id.
  • --skip-flows / --skip-postprocess — accepted, no effect: derived
    views are computed on demand, so there is no materialization pass to skip.
  • postprocess — recomputes the derived views and records their sizes as
    store metadata rather than rebuilding tables.
  • kg_crg.* SDK namespace — the engine persists through
    CodeGraphWriter, not a SQL-backed sdk.Store. Building that store so
    ingestion literally lands in kg_crg.* is Phase B; the parity-verified
    derivation code is shared either way.

One deliberate improvement: an unresolvable diff base (HEAD~1 on a
repository whose only commit is the root commit) falls back to every tracked
file instead of hard-erroring, which is what made build_or_update_graph_tool
unusable on a fresh repo.

Soak clock

This restarts the §11.4 condition 1/2 soak clock. The hermetic parity gate
has been green for weeks, but against a native path nothing was calling; from
this merge the matrix soaks against a native path that is actually serving
production. Condition 3 is now MET; 1, 2 and 4 remain open, so the audit's
verdict stays NOT-READY — KEEP THE BRIDGE.

Explicitly out of scope (Phase B / t6d): deleting any bridge code, the CI
.venv code-review-graph install, or the parity gate.

Verification

  • go test ./... (skipping the known-hanging TestConfigLoadSave) — green.
  • Parity gate run locally, all three tests pass:
    go test -race -count=1 -run '^(TestPostprocessParity_TenCommitDualRead|TestPostprocessParity_CatchesDivergence|TestRegression_CrossPathParity_ClampsAreTheSameNumbers)$' ./internal/adapters/builtin/crg ./internal/graphstore
  • go vet ./... and fsguard clean.
  • Coverage: internal/codegraph 97.9%, commands/kg/backend.go 100%,
    commands/kg 95.7%, internal/graphstore 95.8%.
  • bash scripts/crg-bridge-consumer-audit.sh --check docs/crg-bridge-consumer-audit.md
    passes (doc and script back in lockstep).

No-Python e2e, sandboxed HOME/AGENTS_HOME/KG_HOME, throwaway git
fixture, PATH scrubbed of code-review-graph:

code-review-graph: NOT FOUND (good)

$ da kg build          -> Build complete: 8 nodes, 5 edges, 3 files
$ da kg code-status    -> {"nodes":8,"edges":5,"files":3,"languages":"go","state":"ready","ready":true}
$ da kg impact lib/lib.go
                       -> 4 changed symbol(s), 2 impacted symbol(s) across 2 file(s)
                          impacted: app.Run, app.TestRun
$ da kg flows          -> [call_flow] app.Run (steps=3, criticality=3.00)
$ da kg communities    -> 2 communities (size=4 cohesion=0.67; size=1)
$ da kg changes        -> Change Impact report

.dot-agents/code-graph.db present; .code-review-graph/ absent

Then flipping the backend, both via env and via .agentsrc.json
kg.graph_backend: crg-bridge, routes to the Python bridge and degrades as
designed (code-status reports the reason, build errors, the update hook
no-ops at exit 0) — the routing is what is under test, and the CLI is absent
in the sandbox by construction.

All eight MCP tools exercised over stdio on the native backend, same names and
shapes:

tools/list -> build_or_update_graph_tool, embed_graph_tool, list_graph_stats_tool,
              get_impact_radius_tool, semantic_search_nodes_tool, query_graph_tool,
              get_review_context_tool, get_docs_section_tool
build_or_update -> {"nodes":8,"edges":5,"files":3,"duration_ms":54}
get_impact_radius -> 6 nodes
get_review_context -> changed_symbols with risk_score, impact_radius, risk_summary
query_graph / semantic_search -> [{"name":"Greet","type":"Function","file":"lib/lib.go",...}]
list_graph_stats -> {"nodes":8,"edges":5,"languages":{"go":1},"communities":2}
embed_graph -> {"status":"ok"}

Publish the eight §11.1 parity rows (build, update, status, impact-radius,
flows, communities, postprocess, detect-changes) plus the two bulk-export
reads as one interface, so the CRG cutover is a provider swap rather than a
rewrite. CRGBridge is asserted against it; NativeGraphDBPath fixes the
kg-native graph's repo-local location next to the bridge's CRGDBPath so the
two backends can never share a database file.

Add NewMCPServerWithProvider so the MCP server takes an explicitly selected
backend instead of hard-coding NewCRGBridge, and fall back to the backend's
own graph when the warm mirror holds no code rows (impact-radius seed
resolution, semantic search, graph stats, review-context impact). The warm
store is a mirror populated by `kg warm --include-code`, not the source of
truth, so without the fallback the skill-critical tools report an empty
graph after the cutover.
Add internal/codegraph, an Engine satisfying graphstore.CodeGraphProvider
with no subprocess of any kind. Two halves:

- scan.go ingests the repository with go/ast into the normalized symbol
  corpus the kg-native crg adapter models: Function/Type symbols named
  <pkgPath>.<Name>, and CALLS / IMPORTS / TESTED_BY references resolved
  conservatively (same-package and aliased-import hits win; a bare member
  name resolves only when exactly one declaration carries it, so an
  ambiguous name yields no edge rather than a wrong one).
- engine.go / query.go persist through the published graphstore contract
  into <repo>/.dot-agents/code-graph.db and answer every derived view by
  READING THAT STORAGE BACK through the crg adapter's parity-verified
  derivations (flows, communities, risk index, FTS, impact radius). Nothing
  is served from the in-memory scan, so a divergent write stays visible —
  the same readback discipline the §11.6 parity oracles use.

namespace.go is the projection that makes this work: persisted nodes/edges
are lowered through crg.Corpus.ToGraph, so the note/edge field names and the
symbol-id derivation have exactly one definition, shared with the adapter's
own ingestion path.

NullProvider is the `none` adapter's backend: every operation succeeds with
an empty, well-formed result, so "this project consumes no graph backend" is
a first-class configuration rather than a nil every call site must guard.

One deliberate improvement over the bridge: an unresolvable diff base (the
common `HEAD~1` on a repository whose only commit is the root commit) falls
back to every tracked file instead of hard-erroring, which is what made
build_or_update_graph_tool unusable on a fresh repo.
Register the CRG family in both production registries via
crgbridge.RegisterCRGFamily — not two bare Register calls, because that entry
point also runs registry.EnforceReadsFrom, without which the §11.2
migration_only gate is registered but inert.

Add commands/kg/backend.go as the single backend-selection chokepoint:
DA_KG_GRAPH_BACKEND, then .agentsrc.json kg.graph_backend, then the kg-native
default. The ref resolves through the built-in adapter registry, so an
unregistered or version-incompatible ref is rejected rather than silently
defaulting, and a bare adapter name is accepted and expanded.

Re-point every consumer off NewCRGBridge onto codeGraphProvider:
sync_code_warm_link.go (build, update, code-status, impact, flows,
communities, postprocess, changes, readiness, warm code import), bridge.go
(change_analysis, community_context) and `da kg serve`. Graceful degradation
is preserved and generalized: the bridge-era DiscoverCRGBin probe becomes
errBackendUnavailable, so the post_tool_use update hook, the warm code lane
and the readiness checks still no-op instead of failing a session when the
selected backend's tooling is absent.

The bridge is kept and stays reachable behind explicit config — the §11.4
rollback path. Existing subprocess-routing tests are pinned to it rather than
deleted, so they remain the regression coverage for that path.
Update docs/crg-bridge-consumer-audit.md to the post-cutover state: the
consumer table, how backend selection works, where the kg-native graph
lives, and a delta table naming every observable difference from the Python
bridge (Go-only ingestion, degree-centrality risk in the review-context
composite, empty community descriptions with structural cohesion, positional
flow ids, no-op skip flags, postprocess as a recompute-and-stamp pass, and
kg_crg.* SDK persistence still being Phase B work).

Teach scripts/crg-bridge-consumer-audit.sh to derive §11.4 condition 3
instead of asserting a hardcoded "no kg-native replacement wired", and fix
the graceful-degrade probe to match the new hook message, so --check stays a
truthful drift gate.

Add a §11.4 progress note recording that condition 3 is met and that the
condition 1/2 soak clock restarts, because the parity matrix must now soak
against a native path that is actually serving production. The four gate
conditions themselves are unchanged.
Pin the upstream tool surface, prompts and release metadata as a generated,
byte-stable contract with a capability map that records, per tool, whether
the native backend serves it or the bridge does and why.
Route release tool calls through the bridge, expose the derived community,
flow and impact views the native handlers read, and record the upstream
release identity alongside the build and update reports.
Add the native tool handlers, the build/update lifecycle, and the language
capability contract that decides which sources the native scanner indexes.
The embedded language inventory mirrors the generated release fixture and a
drift test pins the two together.
Validate every tools/call against the release schema, then answer natively
where the engine reproduces the release and via the bridge otherwise, and
report the split through a graph capability diagnostic.
goreleaser 2.18.1 errors on homebrew_casks.url.verified: Homebrew removed the
stanza and goreleaser no longer writes it. The block held nothing else, so the
anchor and its alias go with it.
`git commit` spawns a detached `git maintenance run --auto` that keeps creating
and unlinking transient paths under .git after the commit returns, racing both
the template copy's WalkDir and a fixture's own TempDir teardown. Disabling it
in the template config covers every repo cloned from it.
- codegraph: a NAMED int64 reached writePyJSON's reflect fallback and
  rendered as a quoted string while encoding/json spells it as a number,
  so a payload carrying such a field would have shifted every
  context_savings denominator without changing the payload.
- graphstore: NewCRGToolBridge now resolves the interpreter pythonBin
  falls back to. The fallback is a bare name that is never empty, so a
  release whose virtualenv lost its interpreter reported the bridge as
  available and failed on the first call with a raw exec error instead of
  naming what was missing.
- codegraph: flowRowToMap returns the decoded node-id path, so a flow's
  stored path is decoded once per row instead of twice, and flowSteps no
  longer re-checks an error its caller already returned on.
- codegraph/crg: releaseBlobEquals' error result was always nil and
  round4/roundTo4 guard a ParseFloat that cannot reject FormatFloat's own
  output; both guards are removed rather than left unreachable.
Behaviour-driven tests for the release contract, the native tool handlers,
the capability router and the retained bridge: the published 18/12
native-vs-bridge split and its named reasons, the release's own argument
validation vocabulary, every query_type mode and its payload, the flow,
community, impact and review-context views with their result bounds, the
native scanner's node identity and call-target resolution, changed-path
discovery over real git repositories, and the lazy store's derived-view
delegators.

The nine files that remain short are allowlisted with a per-file rationale:
each holds a defensive guard on a condition the surrounding control flow
has already excluded. The ratchet moves 179 -> 188.
- kg sync runs git with maintenance.auto=false and gc.auto=0. git otherwise
  spawns a DETACHED `git maintenance run --auto` that inherits the CLI's
  stdout and outlives the pull, so a caller reading that handle keeps waiting
  after `da kg sync` has returned.
- Release-contract fixtures substitute the SLASH-NORMALIZED repository root.
  Node identity is slash-normalized by construction (scan.go), so splicing a
  native Windows root produced a hybrid `C:\...\001/pkg/...` expectation the
  product never emits; it also fed a backslash into the JSON-encoded
  critical_path, where the encoder correctly escaped it and the fixture did
  not. Both suites now match the one convention the rest of the package uses.
- assertRowsEqual prints the differing field and both values; "row N mismatch"
  cost a CI round trip to diagnose.
- A working-tree fixture named `git~1` at a repository root resolves onto
  .git's NTFS 8.3 alias; it moves under a subdirectory, where go-git's tree
  path validator still refuses it, so the assertion is unchanged.
- -timeout goes back to 1200s. `da kg` now builds the graph in-process
  instead of shelling to the Python release, so commands/kg does real parse
  and sqlite work per test; under -race on windows-latest that passed 600s.
captureStdout read the pipe only AFTER fn returned, so fn blocked forever the
moment it printed more than one pipe buffer. Windows os.Pipe buffers far less
than Linux's 64KiB, and the cutover made `da kg` print materially more, which
is why the package wedged on windows-latest with no goroutine dump: the
runtime's own timeout output went into the same abandoned pipe.

The restore was also inline after fn. fn is test code that can t.Skip (the
POSIX-shim guard does exactly that on Windows), t.Fatal or panic, each of which
unwinds the frame — leaving os.Stdout pointing at a dead pipe for every
following test in the package. 117 call sites, 133 Fatal/Skip sites inside
them.

The reader now runs concurrently and the restore is deferred, and the two
hand-rolled copies of the same pattern route through the helper.
The generator shells out to an operator-named executable and rewrites an
operator-named tree, so it now refuses to do either for a value it was not
designed for: --crg-bin must resolve to a runnable code-review-graph (the
basename is matched against an allowlist and the path re-derived from it),
and --out must resolve inside the working directory. Both reject with an
actionable message before anything is written or spawned.

Also splits the two functions that had grown past the complexity gate and
hoists the fixture path repeated thirteen times into a constant. Verified
reproducible: regenerating into a scratch tree yields all 120 fixture files
byte-identical to the committed ones.
replaceRows assembled `DELETE FROM ` + table, the one dynamically formatted
statement in the store. A table name cannot be a bound parameter and the set
of tables this path may truncate is closed, so each caller now issues its own
literal truncation inside the shared transaction and the helper only owns the
transaction and the row count.

Alongside: ReplaceFlows' generation swap and insert split into named helpers,
the repeated "invalid params" and "rev-parse" literals become constants,
SetNodeCommunity's parameter list groups its two int64s across the interface
and all three implementations, and the blank embed import says why it is there.
Every handler and helper the complexity gate flagged is split into named,
doc-commented pieces along the seams it already had: the transitive-test
collector into input expansion plus a direct, a bare-name and a call-hop pass;
hybrid search into ranking, scoring and bounding; the traversal into frame
pop, visit and neighbour append; the architecture overview into its community
index, cross-edge aggregation and coupling warnings; the scanner's call-target
resolution into a declaration index and receiver candidates.

Extraction only — no logic, ordering or payload changes, and the shapes that
would have added an untested branch were deliberately left as they were. The
byte-for-byte release-fixture comparisons and both per-file coverage gates are
unchanged. Also hoists four repeated literals into constants and explains the
three intentionally empty bodies.
Fat test bodies and recursive payload comparators move into named helpers so
the t.Run closures stay thin, and `max` stops shadowing the builtin. No case,
assertion or message is dropped — the extraction is mechanical, which is why
statement coverage is unchanged.
@sonarqubecloud

Copy link
Copy Markdown

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