Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ Selected via `llm.provider` in `.gortex.yaml` or `~/.config/gortex/config.yaml`.
- **`compress_bodies: true`** on `read_file` / `get_symbol_source` / `get_editing_context` elides function bodies to stubs while keeping signatures + doc-comments + structure. ~30–40% of original tokens. 14 languages.
- **Overlay sessions** (`overlay_push`, `overlay_list`, `overlay_drop`, `compare_with_overlay`) let editor extensions push unsaved buffers as a per-session shadow graph — every subsequent tool call reads through it without mutating base. Bound to the MCP session lifecycle; idle TTL via `GORTEX_OVERLAY_IDLE_TTL` (default 30m).
- **Speculative execution** (`preview_edit`, `simulate_chain`) takes an LSP `WorkspaceEdit` and returns the graph diff + broken callers/implementors + impact rollup + suggested tests + (optional) LSP diagnostics — disk untouched. `simulate_chain` with `keep: true` promotes the final state into a real overlay.
- **Change-contract pipeline** (`change_contract`, `symbols_for_ranges`) — one envelope every change source lowers into. `change_contract` takes a WorkspaceEdit, a git diff range (`source:diff base:…`), an explicit symbol set, or file line-ranges, runs LOWER → PREDICT → EVALUATE (guards + architecture + event-boundary rule families) → SCORE → CLASSIFY → EMIT, and returns one verdict `{allow|warn|refuse}` with reasons, risk, a `verification_command`, a checkable `stop_condition`, and an `edit_strategy`. `lens:api` focuses it on public-surface / API drift; `risk_gate:true` requires a TTL'd impact-review ack (`ack:true`, stored as a development memory) for load-bearing symbols. `symbols_for_ranges` is the standalone lowering primitive. The pre-write **parse gate** on `edit_file` / `write_file` refuses an edit that would introduce new tree-sitter parse errors (override with `allow_parse_errors`); `safe_delete_symbol propagate:true` patches surviving call sites; `analyze kind=suggest_boundaries` seeds an `architecture:` block from detected communities.
- **MCP 2026 Streamable HTTP** at `POST /mcp` — `gortex server` always mounts it; `gortex daemon --http-addr <addr>` opts the daemon in (non-localhost binds require `--http-auth-token`).
- **Session memory** (`save_note`, `query_notes`, `distill_session`) persists agent-authored notes per repo, auto-linked to symbols mentioned in the body. Notes survive daemon restarts and context compactions, scoped to the session's workspace.
- **Development memories** (`store_memory`, `query_memories`, `surface_memories`) — cross-session, symbol-linked durable knowledge that compounds the longer a team uses Gortex. Memories carry `kind` (invariant / constraint / convention / gotcha / decision / incident / reference), `importance` (1..5), `confidence` (0..1), and are surfaced *proactively* by `surface_memories` when their anchor symbols / files enter the agent's working set.
Expand All @@ -71,7 +72,7 @@ Selected via `llm.provider` in `.gortex.yaml` or `~/.config/gortex/config.yaml`.
- **Capability edges** — `reads_env` / `executes_process` / `accesses_field` are first-class traversable edges (in the `walk_graph`/`nav`/`graph_query` surface) synthesised post-resolution, so a supply-chain / least-privilege audit can ask "what reads $AWS_SECRET", "what shells out", "what writes this field" in one hop.
- **PR review, end-to-end** — `gortex prs` triages open pull requests from the graph (`gortex prs <N>` for one PR; `--triage` / `--conflicts` / `--worktrees` / `--base` / `--format`; `gortex prs bundle`), and `gortex review [<base>|--diff] [--audience agent|human] [--post]` reviews a diff. The MCP surface mirrors it: `pr_risk` / `list_prs` / `get_pr_impact` / `triage_prs` / `conflicts_prs` / `suggest_reviewers` score and rank PRs against the graph, while `review` / `review_pack` / `post_review` / `pr_review_context` / `suggested_review_questions` / `critique_review` / `suppress_finding` drive the review itself.
- **Multimodal + broad ingest** — image files (`KindImage` assets with format/dimensions/sha256) and PDF documents (per-page searchable `KindDoc` nodes) are graph nodes; new first-class extractors cover Terraform/HCL cross-block references, Helm charts/templates, Ansible playbooks, .NET `.sln`/`.csproj`, MCP server configs, Quarto `.qmd`, Luau, COBOL paragraphs + JCL, and C/C++ `#define` macros. Grammar-less languages can register a regex fallback chunker (`index.fallback_chunkers`) or an external extractor plugin (`index.extractor_plugins`) from config — no fork. `gortex db schema --postgres <dsn>` ingests a live database's schema.
- **`analyze` is a 60-kind dispatcher** — beyond the structural kinds, it now covers `impact` (composite change-risk score), `bottlenecks` (interprocedural computation-bottleneck risk — cognitive complexity, loop depth, transitive/hidden-O(n^k) loop nesting across calls, unguarded recursion), `health_score` (per-symbol A–F grade), `sast` / `named` / `unsafe_patterns` (security), `clusters`, `connectivity_health`, `tests_as_edges`, `synthesizers` (framework-dispatch-synthesized edges, grouped by pass + provenance), `resolution_outcomes` (structured why-unresolved taxonomy), `review` (an idiomatic/correctness rulepack — NPE, thread-safety check-then-act, N+1, logic errors across Go + Python — with a graph-grounded false-positive-reduction pass), and more.
- **`analyze` is a 61-kind dispatcher** — beyond the structural kinds, it now covers `impact` (composite change-risk score), `bottlenecks` (interprocedural computation-bottleneck risk — cognitive complexity, loop depth, transitive/hidden-O(n^k) loop nesting across calls, unguarded recursion), `health_score` (per-symbol A–F grade), `sast` / `named` / `unsafe_patterns` (security), `clusters`, `suggest_boundaries` (Leiden-community-seeded architecture-layer suggestions), `connectivity_health`, `tests_as_edges`, `synthesizers` (framework-dispatch-synthesized edges, grouped by pass + provenance), `resolution_outcomes` (structured why-unresolved taxonomy), `review` (an idiomatic/correctness rulepack — NPE, thread-safety check-then-act, N+1, logic errors across Go + Python — with a graph-grounded false-positive-reduction pass), and more.

## MANDATORY: Session memory — save, recall, distill

Expand Down
3 changes: 2 additions & 1 deletion cmd/gortex/eval_recall.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import (
"gopkg.in/yaml.v3"

"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/gitcmd"
"github.com/zzet/gortex/internal/embedding"
"github.com/zzet/gortex/internal/eval/recall"
"github.com/zzet/gortex/internal/gitcmd"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
gortexmcp "github.com/zzet/gortex/internal/mcp"
Expand Down Expand Up @@ -233,6 +233,7 @@ func runEvalRecall(_ *cobra.Command, _ []string) error {
eng.SetSearch(textBackend)
srv := gortexmcp.NewServer(eng, g, idx, nil, zap.NewNop(), cfg.Guards.Rules)
srv.SetArchitecture(cfg.Architecture)
srv.SetEventRules(cfg.Events.Rules)
srv.SetArtifacts(cfg.Artifacts)
srv.SetNamedQueries(cfg.Queries)
srv.RunAnalysis()
Expand Down
1 change: 1 addition & 0 deletions cmd/gortex/eval_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func runEvalServer(cmd *cobra.Command, args []string) error {
gortexmcp.Version = version
srv := gortexmcp.NewServer(eng, g, idx, nil, logger, cfg.Guards.Rules)
srv.SetArchitecture(cfg.Architecture)
srv.SetEventRules(cfg.Events.Rules)
srv.SetArtifacts(cfg.Artifacts)
srv.SetNamedQueries(cfg.Queries)

Expand Down
6 changes: 6 additions & 0 deletions internal/analysis/architecture.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func EvaluateArchitecture(g graph.Store, arch config.ArchitectureConfig, changed
LayerFrom: fromLayer,
LayerTo: toLayer,
EdgeType: string(e.Kind),
Severity: ruleSeverity(arch.Severity),
})
}
}
Expand All @@ -92,6 +93,9 @@ func evaluateArchRules(g graph.Store, arch config.ArchitectureConfig, changedSym
if !ruleApplies(rule, ep, nodeLayer) {
continue
}
if matchesAnyGlob(ep, rule.Except) {
continue
}
label := archRuleLabel(rule)
if rule.MaxFanOut > 0 {
if fan := distinctCallTargets(g, id); fan > rule.MaxFanOut {
Expand All @@ -102,6 +106,7 @@ func evaluateArchRules(g graph.Store, arch config.ArchitectureConfig, changedSym
"%s has dependency fan-out %d, exceeding the limit of %d",
n.ID, fan, rule.MaxFanOut)),
Violator: n.ID,
Severity: ruleSeverity(rule.Severity),
})
}
}
Expand All @@ -127,6 +132,7 @@ func evaluateArchRules(g graph.Store, arch config.ArchitectureConfig, changedSym
"%s calls into %s from outside the permitted set", caller.ID, n.ID)),
Violator: caller.ID,
EdgeType: string(e.Kind),
Severity: ruleSeverity(rule.Severity),
})
}
}
Expand Down
160 changes: 160 additions & 0 deletions internal/analysis/event_boundary.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package analysis

import (
"fmt"

"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
)

// EventBoundaryFamily (PYG-1) evaluates declarative event-boundary rules over
// the pub/sub graph. A changed symbol's produce edges (EdgeEmits /
// EdgeProducesTopic) and consume edges (EdgeListensOn / EdgeConsumesTopic) are
// matched against config rules: which paths may produce or consume a topic,
// whether a produced topic must have a consumer, and which paths are forbidden
// from it. It is a RuleFamily, so change_contract runs it like any other.
type EventBoundaryFamily struct {
Rules []config.EventRule
}

func (f EventBoundaryFamily) Name() string { return "events" }

var (
produceEdges = []graph.EdgeKind{graph.EdgeEmits, graph.EdgeProducesTopic}
consumeEdges = []graph.EdgeKind{graph.EdgeListensOn, graph.EdgeConsumesTopic}
)

func edgeKindIn(k graph.EdgeKind, set []graph.EdgeKind) bool {
for _, e := range set {
if e == k {
return true
}
}
return false
}

// topicTargets returns the topic/event nodes a symbol links to via the given
// edge kinds.
func topicTargets(g graph.Store, id string, kinds []graph.EdgeKind) []*graph.Node {
var out []*graph.Node
for _, e := range g.GetOutEdges(id) {
if !edgeKindIn(e.Kind, kinds) {
continue
}
if t := g.GetNode(e.To); t != nil {
out = append(out, t)
}
}
return out
}

// topicHasConsumer reports whether any symbol consumes the topic node.
func topicHasConsumer(g graph.Store, topicID string) bool {
for _, e := range g.GetInEdges(topicID) {
if edgeKindIn(e.Kind, consumeEdges) {
return true
}
}
return false
}

func (f EventBoundaryFamily) Evaluate(g graph.Store, changedSet []string) []GuardViolation {
if g == nil || len(f.Rules) == 0 {
return nil
}
var violations []GuardViolation
seen := make(map[string]bool)
add := func(v GuardViolation) {
key := v.RuleName + "\x00" + v.Violator + "\x00" + v.Description
if seen[key] {
return
}
seen[key] = true
violations = append(violations, v)
}

for _, id := range changedSet {
n := g.GetNode(id)
if n == nil {
continue
}
produced := topicTargets(g, id, produceEdges)
consumed := topicTargets(g, id, consumeEdges)
if len(produced) == 0 && len(consumed) == 0 {
continue
}
for _, rule := range f.Rules {
sev := ruleSeverity(rule.Severity)
for _, topic := range produced {
if rule.Topic != "" && !globMatch(rule.Topic, topic.Name) {
continue
}
if rule.Producer != "" && !globMatch(rule.Producer, n.FilePath) {
add(GuardViolation{
RuleName: eventRuleLabel(rule, topic.Name),
Kind: "event_boundary",
Description: eventMessage(rule, fmt.Sprintf("%s produces topic %q from %s, outside the permitted producer path %q", n.Name, topic.Name, n.FilePath, rule.Producer)),
Violator: n.ID,
Severity: sev,
})
}
if matchesAnyGlob(n.FilePath, rule.Forbid) {
add(GuardViolation{
RuleName: eventRuleLabel(rule, topic.Name),
Kind: "event_boundary",
Description: eventMessage(rule, fmt.Sprintf("%s in %s is forbidden from producing topic %q", n.Name, n.FilePath, topic.Name)),
Violator: n.ID,
Severity: sev,
})
}
if rule.RequireConsumer && !topicHasConsumer(g, topic.ID) {
add(GuardViolation{
RuleName: eventRuleLabel(rule, topic.Name),
Kind: "event_boundary",
Description: eventMessage(rule, fmt.Sprintf("topic %q is produced by %s but has no consumer", topic.Name, n.Name)),
Violator: n.ID,
Severity: sev,
})
}
}
for _, topic := range consumed {
if rule.Topic != "" && !globMatch(rule.Topic, topic.Name) {
continue
}
if rule.Consumer != "" && !globMatch(rule.Consumer, n.FilePath) {
add(GuardViolation{
RuleName: eventRuleLabel(rule, topic.Name),
Kind: "event_boundary",
Description: eventMessage(rule, fmt.Sprintf("%s consumes topic %q from %s, outside the permitted consumer path %q", n.Name, topic.Name, n.FilePath, rule.Consumer)),
Violator: n.ID,
Severity: sev,
})
}
if matchesAnyGlob(n.FilePath, rule.Forbid) {
add(GuardViolation{
RuleName: eventRuleLabel(rule, topic.Name),
Kind: "event_boundary",
Description: eventMessage(rule, fmt.Sprintf("%s in %s is forbidden from consuming topic %q", n.Name, n.FilePath, topic.Name)),
Violator: n.ID,
Severity: sev,
})
}
}
}
}
return violations
}

func eventRuleLabel(rule config.EventRule, topic string) string {
if rule.Name != "" {
return rule.Name
}
return "event:" + topic
}

func eventMessage(rule config.EventRule, fallback string) string {
if rule.Message != "" {
return rule.Message
}
return fallback
}
69 changes: 69 additions & 0 deletions internal/analysis/event_boundary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package analysis

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
)

// buildEventGraph wires one producer that emits topic "orders" and, optionally,
// a consumer that listens on it.
func buildEventGraph(withConsumer bool) (*graph.Graph, string) {
g := graph.New()
g.AddNode(&graph.Node{ID: "svc/pub.go::Publish", Name: "Publish", Kind: graph.KindFunction, FilePath: "svc/pub.go"})
g.AddNode(&graph.Node{ID: "topic::orders", Name: "orders", Kind: graph.KindEvent, FilePath: "svc/pub.go"})
g.AddEdge(&graph.Edge{From: "svc/pub.go::Publish", To: "topic::orders", Kind: graph.EdgeEmits})
if withConsumer {
g.AddNode(&graph.Node{ID: "worker/sub.go::Consume", Name: "Consume", Kind: graph.KindFunction, FilePath: "worker/sub.go"})
g.AddEdge(&graph.Edge{From: "worker/sub.go::Consume", To: "topic::orders", Kind: graph.EdgeListensOn})
}
return g, "svc/pub.go::Publish"
}

func TestEventBoundaryProducerPath(t *testing.T) {
g, pubID := buildEventGraph(true)
fam := EventBoundaryFamily{Rules: []config.EventRule{{
Name: "orders-producer", Topic: "orders",
Producer: "ingest/**", Severity: "error",
}}}
v := fam.Evaluate(g, []string{pubID})
require.Len(t, v, 1)
require.Equal(t, "event_boundary", v[0].Kind)
require.Equal(t, "error", v[0].Severity)
}

func TestEventBoundaryRequireConsumer(t *testing.T) {
// No consumer present -> require_consumer fires.
g, pubID := buildEventGraph(false)
fam := EventBoundaryFamily{Rules: []config.EventRule{{
Name: "orders-needs-consumer", Topic: "orders", RequireConsumer: true,
}}}
require.Len(t, fam.Evaluate(g, []string{pubID}), 1)

// With a consumer present -> no violation.
g2, pubID2 := buildEventGraph(true)
require.Empty(t, fam.Evaluate(g2, []string{pubID2}))
}

func TestEventBoundaryForbid(t *testing.T) {
g, pubID := buildEventGraph(true)
fam := EventBoundaryFamily{Rules: []config.EventRule{{
Name: "no-pub-from-svc", Topic: "*",
Forbid: []string{"svc/**"},
}}}
v := fam.Evaluate(g, []string{pubID})
require.Len(t, v, 1)
require.Equal(t, "warn", v[0].Severity) // default severity
}

func TestEventBoundaryAllowedProducerClean(t *testing.T) {
g, pubID := buildEventGraph(true)
fam := EventBoundaryFamily{Rules: []config.EventRule{{
Name: "orders-producer", Topic: "orders",
Producer: "svc/**", // pub.go is in svc/, so it's allowed
}}}
require.Empty(t, fam.Evaluate(g, []string{pubID}))
}
33 changes: 33 additions & 0 deletions internal/analysis/guards.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,30 @@ type GuardViolation struct {
LayerFrom string `json:"layer_from,omitempty"`
LayerTo string `json:"layer_to,omitempty"`
EdgeType string `json:"edge_type,omitempty"`
// Severity tiers the violation for the change_contract verdict mapping:
// "error" → refuse, "warn" → warn, "info" → annotate. Stamped from the
// rule; empty means the consumer applies its own default.
Severity string `json:"severity,omitempty"`
}

// ruleSeverity normalises a configured severity, defaulting to "warn" so a
// rule advises until it explicitly opts into blocking ("error").
func ruleSeverity(s string) string {
if s == "" {
return "warn"
}
return strings.ToLower(s)
}

// matchesAnyGlob reports whether path p matches any of the (possibly **-using)
// globs — the except-list check shared by the guard and architecture families.
func matchesAnyGlob(p string, globs []string) bool {
for _, g := range globs {
if g != "" && globMatch(g, p) {
return true
}
}
return false
}

// EvaluateGuards checks the given guard rules against a set of changed symbol IDs
Expand Down Expand Up @@ -60,6 +84,9 @@ func evaluateCoChange(rule config.GuardRule, changedNodes []*graph.Node) []Guard
hasTarget := false

for _, n := range changedNodes {
if matchesAnyGlob(n.FilePath, rule.Except) {
continue
}
if strings.HasPrefix(n.FilePath, rule.Source) {
hasSource = true
}
Expand All @@ -80,6 +107,7 @@ func evaluateCoChange(rule config.GuardRule, changedNodes []*graph.Node) []Guard
RuleName: rule.Name,
Kind: "co-change",
Description: msg,
Severity: ruleSeverity(rule.Severity),
}}
}

Expand All @@ -96,6 +124,9 @@ func evaluateBoundary(g graph.Store, rule config.GuardRule, changedNodes []*grap
if !strings.HasPrefix(n.FilePath, rule.Source) {
continue
}
if matchesAnyGlob(n.FilePath, rule.Except) {
continue
}

outEdges := g.GetOutEdges(n.ID)
for _, edge := range outEdges {
Expand Down Expand Up @@ -128,6 +159,8 @@ func evaluateBoundary(g graph.Store, rule config.GuardRule, changedNodes []*grap
RuleName: rule.Name,
Kind: "boundary",
Description: fmt.Sprintf("%s: %s %s %s", msg, n.ID, edge.Kind, target.ID),
Violator: n.ID,
Severity: ruleSeverity(rule.Severity),
})
}
}
Expand Down
Loading
Loading