diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..15d70c3bc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# AGENTS.md — Temporal dispatch allow-list (для корпоративного агента) + +Этот файл — инструкция агенту, работающему в **корпоративном форке** gortex, по +сопровождению распознавания Temporal-диспатча под ваш кодовой базы. Цель: повышать точность +графа Temporal **без утечки корпоративных имён** в исходники / upstream. + +> Не путать с `docs/agents.md` (это про agent-адаптеры самого gortex). Здесь — только про +> Temporal allow-list и LLM-клининг. + +--- + +## 1. Как устроено распознавание (три слоя) + +Имя активности/воркфлоу в `workflow.ExecuteActivity(ctx, , …)` часто приходит не литералом. +Форк распознаёт его тремя слоями, по возрастанию доверия: + +1. **Generic-эвристика (recall, скрыто).** Любой хелпер с `env` в имени — + `cfg.ActivityFromEnv("KEY", "Default")` — распознаётся структурно: 2-й строковый аргумент + берётся как дефолтное имя. Ребро садится на **speculative 0.4** (`temporal_env_source=heuristic`), + скрыто из обычных запросов. Вести ничего не нужно — работает само. +2. **Allow-list (precision, видимо).** Если имя хелпера в built-in списке (`GetEnvOrDefault`, + `GetEnvOrDefaultValue`, `EnvOr`, `GetenvDefault`, `GetEnvDefault`) **или** в вашем + репо-локальном allow-list — ребро повышается до **inferred 0.6**, видимо по умолчанию + (`temporal_env_source=allowlist`). `os.Getenv(...)` / `cmp.Or(...)` тоже → 0.6. +3. **LLM-клининг (опционально).** Проход `gortex analyze --kind temporal_verify` отдаёт каждое + рёбро уровня ≤0.65 вашей LLM, заземляя в реальном коде: confirmed → повышает и делает видимым, + rejected → гасит (скрывает), uncertain → оставляет как есть. Register-confirmed (0.9) не трогает. + +**Главное:** слой 1 жадный и безвредный (скрыт), слой 2 точечно повышает, слой 3 чистит. Поэтому +**не обязательно** перечислять все хелперы — список нужен лишь чтобы **сделать конкретный хелпер +видимым по умолчанию**. + +--- + +## 2. Как вести allow-list + +Файл **git-ignored** (см. `.gitignore`: `.gortex/`), читается **только** под env-гейтом. + +1. Включить гейт: `export GORTEX_ALLOW_LOCAL_TEMPORAL=1`. +2. Создать `.gortex/temporal-allowlist.yaml` в корне репозитория: + +```yaml +# Имена ваших env-хелперов, по которым диспатч резолвится на дефолт. +# Сопоставление по имени функции (без пакета), регистронезависимо. +env_helpers: + - GetActivityNameFromEnv + - FetchActivityName + - resolveActivity # локальные/lowercase тоже годятся +``` + +3. Проверить эффект: `gortex analyze --kind temporal_orphans --path . --format json` (до/после) — + часть `broken_dispatch` должна закрыться, env-сайты получить `temporal_env_source=allowlist`. + +Что добавлять / чего нет: + +- **Добавлять:** имена функций-обёрток «прочитать env и вернуть дефолт». Это generic-инфраструктура, + не бизнес-данные. +- **НЕ добавлять:** имена активностей/воркфлоу/бизнес-логику. Они тут не нужны (распознаются + структурно), и им не место даже в git-ignored файле. + +--- + +## 3. Протокол анонимизации (не спалить корпоративное) + +- Файл `.gortex/temporal-allowlist.yaml` **git-ignored** — не коммить его и не добавлять в PR. +- Имена env-хелперов сами по себе — generic infra (безопасны), но всё равно держим их **только** в + локальном файле, а не в исходниках форка. +- Если делаешь фикстуры для передачи OSS-стороне — следуй + `docs/temporal-compare/temporal-gap-synthetic-fixtures.md` (протокол: «сохраняем форму, стираем + содержание»): repo-пути → `example.com/app`, имена активностей → `ChargeActivity`, env-ключи → + `FOO_ACTIVITY_ENV`, тела → выкинуть. + +--- + +## 4. LLM-клининг (precision-проход вашей моделью) + +Требует настроенного `llm.provider` (например ваш `custom-provider`) в `.gortex.yaml` / +`~/.config/gortex/config.yaml`. + +```bash +gortex analyze --kind temporal_verify --path . --format json +``` + +- Проверяет **только** низкодоверенные рёбра (speculative 0.4 + inferred 0.6) — набор маленький, + стоимость ограничена. Register-confirmed 0.9 не трогает. +- Вердикты кэшируются в git-ignored `.gortex/temporal-verify-cache.json` по хэшу + (модель + имя + исходник вызова + исходник кандидата) → повторный прогон детерминирован и + бесплатен (годится для CI). Меняется код или модель — кэш промахивается и перепроверяет. +- Вывод: `checked / confirmed / rejected / uncertain / errors` (+ `details` в JSON с причинами). +- На каждом ребре остаются `temporal_llm_verdict` и `temporal_llm_reason` для аудита. + +--- + +## 5. Что делать с тем, что всё ещё не резолвится + +Для dispatch-шейпов, остающихся `broken_dispatch` и не закрытых ничем выше, — +`docs/temporal-compare/temporal-gap-synthetic-fixtures.md`: какие минимальные анонимизированные +фикстуры отдать OSS-стороне, чтобы дорезолвить либу. diff --git a/internal/analyzer/temporal_verify.go b/internal/analyzer/temporal_verify.go new file mode 100644 index 000000000..b80045b27 --- /dev/null +++ b/internal/analyzer/temporal_verify.go @@ -0,0 +1,302 @@ +package analyzer + +// LLM-backed adapter for the Temporal dispatch verification pass. +// +// PURPOSE — wire the deterministic verification core in +// internal/resolver/temporal_verify.go to (a) a real LLM provider, (b) on-disk +// source grounding, (c) a reproducibility cache, and (d) the canonical +// map[string]any output shape. Keeps the resolver core free of any LLM / I/O +// dependency; all the "actions" live here. +// RATIONALE — the verifier and source provider are injected interfaces, so this +// file holds the only LLM + filesystem coupling. The cache makes re-runs cheap +// and deterministic (same code + model → cached verdict). +// KEYWORDS — temporal, verify, llm, source, cache, adapter + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/llm" + "github.com/zzet/gortex/internal/llm/provider" + "github.com/zzet/gortex/internal/resolver" +) + +// VerifyReportToMap converts a TemporalVerifyReport to the canonical +// map[string]any shape the CLI / MCP surfaces marshal. Field names are locked: +// checked, confirmed, rejected, uncertain, errors, details, totals. +func VerifyReportToMap(rep resolver.TemporalVerifyReport) map[string]any { + details := make([]map[string]any, 0, len(rep.Details)) + for _, d := range rep.Details { + details = append(details, map[string]any{ + "from": d.From, + "to": d.To, + "name": d.Name, + "kind": d.Kind, + "source": d.Source, + "verdict": string(d.Verdict), + "reason": d.Reason, + }) + } + return map[string]any{ + "checked": rep.Checked, + "confirmed": rep.Confirmed, + "rejected": rep.Rejected, + "uncertain": rep.Uncertain, + "errors": rep.Errors, + "details": details, + "totals": map[string]int{ + "checked": rep.Checked, + "confirmed": rep.Confirmed, + "rejected": rep.Rejected, + "uncertain": rep.Uncertain, + "errors": rep.Errors, + }, + } +} + +// --- File-backed source provider ------------------------------------------ + +// maxNodeSourceBytes caps the per-node source handed to the LLM so a giant +// function body can't blow the prompt budget. +const maxNodeSourceBytes = 6000 + +// FileSourceProvider reads a graph node's source from disk, slicing the file by +// the node's [StartLine, EndLine]. Files are cached in-memory for the run. +type FileSourceProvider struct { + root string + cache map[string]string +} + +// NewFileSourceProvider returns a source provider rooted at the indexed repo. +func NewFileSourceProvider(root string) *FileSourceProvider { + return &FileSourceProvider{root: root, cache: map[string]string{}} +} + +// NodeSource returns the source text of n's declaration, or ("", false). +func (p *FileSourceProvider) NodeSource(n *graph.Node) (string, bool) { + if n == nil || n.FilePath == "" { + return "", false + } + body, ok := p.fileBody(n.FilePath) + if !ok { + return "", false + } + lines := strings.Split(body, "\n") + start, end := n.StartLine, n.EndLine + if start < 1 { + start = 1 + } + if start > len(lines) { + return "", false + } + if end < start || end > len(lines) { + end = len(lines) + } + src := strings.Join(lines[start-1:end], "\n") + if len(src) > maxNodeSourceBytes { + src = src[:maxNodeSourceBytes] + "\n// …truncated" + } + return src, true +} + +func (p *FileSourceProvider) fileBody(rel string) (string, bool) { + if b, ok := p.cache[rel]; ok { + return b, b != "" + } + raw, err := os.ReadFile(filepath.Join(p.root, rel)) + if err != nil { + // Fall back to treating the recorded path as already-absolute. + if raw, err = os.ReadFile(rel); err != nil { + p.cache[rel] = "" + return "", false + } + } + p.cache[rel] = string(raw) + return string(raw), true +} + +// --- LLM verifier ---------------------------------------------------------- + +const temporalVerifySystemPrompt = `You verify guesses made by a static analyzer for the Temporal workflow engine. +You are given a workflow function that dispatches an activity or child-workflow by a NAME the analyzer GUESSED (often from an env-var default, a naming convention, or a fuzzy match), plus the candidate target function the analyzer resolved that name to. +Decide whether the workflow really dispatches that exact candidate. +Reply with STRICT JSON ONLY, no prose: {"verdict":"confirmed|rejected|uncertain","reason":""}. +- "confirmed": the workflow does dispatch this exact activity/workflow. +- "rejected": it clearly does NOT — wrong/unrelated target, the name is overridden at runtime, or the candidate is a test stub. +- "uncertain": not enough evidence. +Be conservative: prefer "uncertain" over a wrong "confirmed".` + +type llmTemporalVerifier struct { + p llm.Provider +} + +// NewLLMTemporalVerifier builds a verifier from resolved LLM config. Returns a +// close func and (nil, nil, err) when no provider can be constructed — the +// caller treats that as "LLM unavailable" and skips verification. +func NewLLMTemporalVerifier(cfg llm.Config) (resolver.TemporalVerifier, func() error, error) { + cfg = cfg.ApplyDefaults() + if !cfg.IsEnabled() { + return nil, nil, fmt.Errorf("llm provider not enabled (set llm.provider)") + } + p, err := provider.New(cfg) + if err != nil { + return nil, nil, err + } + return &llmTemporalVerifier{p: p}, p.Close, nil +} + +// NewLLMTemporalVerifierFromProvider wraps an already-constructed provider. +// +// PURPOSE — let a host that already owns a live llm.Provider (e.g. the MCP +// server's shared LLM service) reuse it for temporal verification instead of +// spinning up a second provider from raw config via NewLLMTemporalVerifier. +// RATIONALE — the verifier is a thin Verify(req)→verdict adapter over +// Provider.Complete; binding it to an existing provider avoids duplicate model +// loads / API clients and respects the caller's provider lifecycle (no Close +// returned — the caller still owns p). +// KEYWORDS — temporal, verify, llm, provider, reuse +func NewLLMTemporalVerifierFromProvider(p llm.Provider) resolver.TemporalVerifier { + return &llmTemporalVerifier{p: p} +} + +func (v *llmTemporalVerifier) Verify(ctx context.Context, req resolver.TemporalVerifyRequest) (resolver.TemporalVerifyResult, error) { + resp, err := v.p.Complete(ctx, llm.CompletionRequest{ + Messages: []llm.Message{ + {Role: llm.RoleSystem, Content: temporalVerifySystemPrompt}, + {Role: llm.RoleUser, Content: buildTemporalVerifyPrompt(req)}, + }, + MaxTokens: 300, + Shape: llm.ShapeFreeform, + }) + if err != nil { + return resolver.TemporalVerifyResult{}, err + } + return parseTemporalVerdict(resp.Text), nil +} + +func buildTemporalVerifyPrompt(req resolver.TemporalVerifyRequest) string { + var b strings.Builder + fmt.Fprintf(&b, "Dispatch name: %q (kind=%s, recognised via=%s)\n\n", req.DispatchName, req.Kind, req.Source) + fmt.Fprintf(&b, "Calling workflow %q:\n```go\n%s\n```\n\n", req.CallerName, req.CallerSource) + fmt.Fprintf(&b, "Candidate target %q:\n```go\n%s\n```\n\n", req.TargetName, req.TargetSource) + b.WriteString(`Does the workflow dispatch this candidate? Reply with strict JSON {"verdict":...,"reason":...}.`) + return b.String() +} + +// parseTemporalVerdict tolerantly extracts a verdict from a possibly-wrapped +// LLM reply; an unparseable / unknown verdict degrades to "uncertain" so a +// flaky model never silently promotes or suppresses an edge. +func parseTemporalVerdict(raw string) resolver.TemporalVerifyResult { + var v struct { + Verdict string `json:"verdict"` + Reason string `json:"reason"` + } + if err := json.Unmarshal([]byte(extractJSONObject(raw)), &v); err != nil { + return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictUncertain, Reason: "unparseable verdict"} + } + switch strings.ToLower(strings.TrimSpace(v.Verdict)) { + case "confirmed": + return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictConfirmed, Reason: v.Reason} + case "rejected": + return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictRejected, Reason: v.Reason} + default: + return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictUncertain, Reason: v.Reason} + } +} + +// extractJSONObject returns the first {...} span of s (models often wrap JSON in +// prose or code fences), or s unchanged when no braces are present. +func extractJSONObject(s string) string { + i := strings.IndexByte(s, '{') + j := strings.LastIndexByte(s, '}') + if i >= 0 && j > i { + return s[i : j+1] + } + return s +} + +// --- Reproducibility cache ------------------------------------------------- + +// CachingVerifier wraps a TemporalVerifier with a disk-persisted verdict cache +// keyed by a hash of (model, kind, dispatch name, caller source, target +// source). Re-runs over unchanged code + model hit the cache, so the pass is +// reproducible (important for CI) and cheap. Errors are never cached. +type CachingVerifier struct { + inner resolver.TemporalVerifier + model string + path string + cache map[string]cachedVerdict + dirty bool +} + +type cachedVerdict struct { + Verdict string `json:"verdict"` + Reason string `json:"reason"` +} + +// NewCachingVerifier wraps inner, loading any existing cache at path (a missing +// or malformed file is ignored). A "" path disables persistence (in-memory only). +func NewCachingVerifier(inner resolver.TemporalVerifier, model, path string) *CachingVerifier { + c := &CachingVerifier{inner: inner, model: model, path: path, cache: map[string]cachedVerdict{}} + c.load() + return c +} + +func (c *CachingVerifier) key(req resolver.TemporalVerifyRequest) string { + h := sha256.New() + h.Write([]byte(c.model + "\x00" + req.Kind + "\x00" + req.DispatchName + "\x00" + req.CallerSource + "\x00" + req.TargetSource)) + return hex.EncodeToString(h.Sum(nil)) +} + +// Verify returns a cached verdict when present, else delegates and caches the +// result. A delegate error is propagated and NOT cached. +func (c *CachingVerifier) Verify(ctx context.Context, req resolver.TemporalVerifyRequest) (resolver.TemporalVerifyResult, error) { + k := c.key(req) + if cv, ok := c.cache[k]; ok { + return resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdict(cv.Verdict), Reason: cv.Reason}, nil + } + res, err := c.inner.Verify(ctx, req) + if err != nil { + return res, err + } + c.cache[k] = cachedVerdict{Verdict: string(res.Verdict), Reason: res.Reason} + c.dirty = true + return res, nil +} + +func (c *CachingVerifier) load() { + if c.path == "" { + return + } + raw, err := os.ReadFile(c.path) + if err != nil { + return + } + var m map[string]cachedVerdict + if json.Unmarshal(raw, &m) == nil && m != nil { + c.cache = m + } +} + +// Flush persists the cache to disk when dirty and a path is set. Safe to call +// once after the verification run. +func (c *CachingVerifier) Flush() error { + if !c.dirty || c.path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + return err + } + raw, err := json.MarshalIndent(c.cache, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.path, raw, 0o644) +} diff --git a/internal/analyzer/temporal_verify_test.go b/internal/analyzer/temporal_verify_test.go new file mode 100644 index 000000000..a891f0dad --- /dev/null +++ b/internal/analyzer/temporal_verify_test.go @@ -0,0 +1,111 @@ +package analyzer + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/resolver" +) + +func TestVerifyReportToMap(t *testing.T) { + rep := resolver.TemporalVerifyReport{ + Checked: 3, Confirmed: 1, Rejected: 1, Uncertain: 1, Errors: 0, + Details: []resolver.TemporalVerifyDetail{ + {From: "wf::A", To: "act::B", Name: "B", Kind: "activity", Source: "env_default:heuristic", Verdict: resolver.TemporalVerdictConfirmed, Reason: "ok"}, + }, + } + m := VerifyReportToMap(rep) + assert.Equal(t, 3, m["checked"]) + assert.Equal(t, 1, m["confirmed"]) + totals, _ := m["totals"].(map[string]int) + assert.Equal(t, 1, totals["rejected"]) + details, _ := m["details"].([]map[string]any) + require.Len(t, details, 1) + assert.Equal(t, "confirmed", details[0]["verdict"]) + assert.Equal(t, "env_default:heuristic", details[0]["source"]) +} + +func TestParseTemporalVerdict(t *testing.T) { + cases := []struct { + raw string + want resolver.TemporalVerdict + }{ + {`{"verdict":"confirmed","reason":"matches"}`, resolver.TemporalVerdictConfirmed}, + {`{"verdict":"rejected","reason":"wrong target"}`, resolver.TemporalVerdictRejected}, + {`{"verdict":"uncertain"}`, resolver.TemporalVerdictUncertain}, + {`CONFIRMED is my answer: {"verdict":"CONFIRMED"}`, resolver.TemporalVerdictConfirmed}, // prose-wrapped + case + {"```json\n{\"verdict\":\"rejected\"}\n```", resolver.TemporalVerdictRejected}, // fenced + {`not json at all`, resolver.TemporalVerdictUncertain}, // unparseable → uncertain + {`{"verdict":"banana"}`, resolver.TemporalVerdictUncertain}, // unknown → uncertain + } + for _, c := range cases { + got := parseTemporalVerdict(c.raw) + assert.Equal(t, c.want, got.Verdict, "raw=%q", c.raw) + } +} + +func TestFileSourceProvider_SlicesByLine(t *testing.T) { + dir := t.TempDir() + rel := "pkg/a.go" + require.NoError(t, os.MkdirAll(filepath.Join(dir, "pkg"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, rel), + []byte("package pkg\n\nfunc A() {}\nfunc B() {\n\treturn\n}\n"), 0o644)) + + p := NewFileSourceProvider(dir) + // Node B spans lines 4..6. + n := &graph.Node{FilePath: rel, StartLine: 4, EndLine: 6} + src, ok := p.NodeSource(n) + require.True(t, ok) + assert.Contains(t, src, "func B() {") + assert.Contains(t, src, "return") + assert.NotContains(t, src, "func A()") + + // Missing file → not ok. + _, ok = p.NodeSource(&graph.Node{FilePath: "nope.go", StartLine: 1, EndLine: 1}) + assert.False(t, ok) +} + +type countingVerifier struct { + calls int + res resolver.TemporalVerifyResult +} + +func (c *countingVerifier) Verify(context.Context, resolver.TemporalVerifyRequest) (resolver.TemporalVerifyResult, error) { + c.calls++ + return c.res, nil +} + +func TestCachingVerifier_HitsCacheAndPersists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, ".gortex", "temporal-verify-cache.json") + inner := &countingVerifier{res: resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictConfirmed, Reason: "ok"}} + req := resolver.TemporalVerifyRequest{Kind: "activity", DispatchName: "ChargeActivity", CallerSource: "wf", TargetSource: "act"} + + c := NewCachingVerifier(inner, "model-x", path) + r1, _ := c.Verify(context.Background(), req) + r2, _ := c.Verify(context.Background(), req) + assert.Equal(t, resolver.TemporalVerdictConfirmed, r1.Verdict) + assert.Equal(t, resolver.TemporalVerdictConfirmed, r2.Verdict) + assert.Equal(t, 1, inner.calls, "second identical request must hit the cache") + + // A different model is a different key → delegates again. + c.model = "model-y" + _, _ = c.Verify(context.Background(), req) + assert.Equal(t, 2, inner.calls, "different model must miss the cache") + c.model = "model-x" + + require.NoError(t, c.Flush()) + + // A fresh caching verifier loads the persisted verdict — no delegate call. + inner2 := &countingVerifier{res: resolver.TemporalVerifyResult{Verdict: resolver.TemporalVerdictRejected}} + c2 := NewCachingVerifier(inner2, "model-x", path) + r3, _ := c2.Verify(context.Background(), req) + assert.Equal(t, resolver.TemporalVerdictConfirmed, r3.Verdict, "loaded from disk, not the new delegate") + assert.Equal(t, 0, inner2.calls) +} diff --git a/internal/mcp/tools_analyze_temporal_verify.go b/internal/mcp/tools_analyze_temporal_verify.go new file mode 100644 index 000000000..d152932ab --- /dev/null +++ b/internal/mcp/tools_analyze_temporal_verify.go @@ -0,0 +1,160 @@ +package mcp + +// MCP entrypoint for the Temporal dispatch LLM verification pass. +// +// PURPOSE — make resolver.VerifyTemporalEdges reachable from the MCP `analyze` +// surface (the CLI host that originally drove it was removed; analyze is now +// MCP-only). `analyze kind=temporal_verify` runs the precision backstop over +// the active graph's low-confidence Temporal dispatch edges, promoting LLM- +// confirmed edges and suppressing rejected ones, and returns the canonical +// verify report. +// RATIONALE — the verification core (resolver) and its LLM/source adapters +// (analyzer) are injected interfaces; this handler is the only place that wires +// them to the server's live state — the shared LLM provider and the on-disk +// source resolved through the server's own multi-repo/worktree path logic. +// Reusing s.llmService.Provider() avoids constructing a second provider, and +// the source provider delegates to resolveNodePath so paths are correct in +// single-repo, multi-repo, and worktree layouts. With no LLM configured the +// handler returns a clear error instead of running (or panicking). +// KEYWORDS — analyze, temporal, verify, llm, mcp, precision + +import ( + "context" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/analyzer" + "github.com/zzet/gortex/internal/graph" + "github.com/zzet/gortex/internal/resolver" + "go.uber.org/zap" +) + +// maxTemporalNodeSourceBytes caps the per-node source handed to the LLM so a +// giant function body can't blow the prompt budget. Mirrors the cap baked into +// analyzer.NewFileSourceProvider; restated here because this handler resolves +// paths through the server (resolveNodePath) rather than a bare root join. +const maxTemporalNodeSourceBytes = 6000 + +// serverSourceProvider implements resolver.TemporalSourceProvider by reading a +// node's source slice from disk via the server's own path resolution +// (resolveNodePath), so the verifier gets correct on-disk source under +// single-repo, multi-repo prefix, and linked-worktree layouts alike. Read file +// bodies are cached for the run. +type serverSourceProvider struct { + s *Server + cache map[string]string // absolute path -> file body ("" = unreadable) +} + +func newServerSourceProvider(s *Server) *serverSourceProvider { + return &serverSourceProvider{s: s, cache: map[string]string{}} +} + +// NodeSource returns the source text of n's declaration, or ("", false). +func (p *serverSourceProvider) NodeSource(n *graph.Node) (string, bool) { + if n == nil { + return "", false + } + abs, err := p.s.resolveNodePath(n) + if err != nil || abs == "" { + return "", false + } + body, ok := p.fileBody(abs) + if !ok { + return "", false + } + lines := strings.Split(body, "\n") + start, end := n.StartLine, n.EndLine + if start < 1 { + start = 1 + } + if start > len(lines) { + return "", false + } + if end < start || end > len(lines) { + end = len(lines) + } + src := strings.Join(lines[start-1:end], "\n") + if len(src) > maxTemporalNodeSourceBytes { + src = src[:maxTemporalNodeSourceBytes] + "\n// …truncated" + } + return src, true +} + +func (p *serverSourceProvider) fileBody(abs string) (string, bool) { + if b, ok := p.cache[abs]; ok { + return b, b != "" + } + raw, err := os.ReadFile(abs) + if err != nil { + p.cache[abs] = "" + return "", false + } + p.cache[abs] = string(raw) + return string(raw), true +} + +// handleAnalyzeTemporalVerify runs the LLM cleaning pass over the active +// graph's verifiable Temporal dispatch edges and returns the verify report +// (checked/confirmed/rejected/uncertain/errors/details/totals). It requires a +// configured LLM provider — with none, it returns a clear "LLM not configured" +// error result rather than silently no-op'ing or panicking. +func (s *Server) handleAnalyzeTemporalVerify(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if s.llmService == nil || !s.llmService.Enabled() { + return mcp.NewToolResultError( + "temporal_verify requires a configured LLM provider — set llm.provider " + + "in .gortex.yaml (or GORTEX_LLM_PROVIDER) with a valid model / API key", + ), nil + } + provider := s.llmService.Provider() + if provider == nil { + return mcp.NewToolResultError("temporal_verify: LLM provider unavailable"), nil + } + + inner := analyzer.NewLLMTemporalVerifierFromProvider(provider) + // Disk-backed verdict cache so re-runs (e.g. in CI) don't re-pay the LLM. + // The key includes the model and the actual caller / target source, so it + // invalidates automatically when the model or the code changes. + cachePath := "" + roots := s.collectRepoRoots("") + rootKeys := make([]string, 0, len(roots)) + for k := range roots { + rootKeys = append(rootKeys, k) + } + sort.Strings(rootKeys) + for _, k := range rootKeys { + if roots[k] != "" { + cachePath = filepath.Join(roots[k], ".gortex", "temporal-verify-cache.json") + break + } + } + verifier := analyzer.NewCachingVerifier(inner, provider.Name(), cachePath) + src := newServerSourceProvider(s) + report := resolver.VerifyTemporalEdges(ctx, s.graph, src, verifier) + if err := verifier.Flush(); err != nil && s.logger != nil { + s.logger.Warn("temporal_verify: verdict cache flush failed", zap.Error(err)) + } + + out := analyzer.VerifyReportToMap(report) + + if isCompact(req) { + var b strings.Builder + b.WriteString("checked: ") + b.WriteString(strconv.Itoa(report.Checked)) + b.WriteString(", confirmed: ") + b.WriteString(strconv.Itoa(report.Confirmed)) + b.WriteString(", rejected: ") + b.WriteString(strconv.Itoa(report.Rejected)) + b.WriteString(", uncertain: ") + b.WriteString(strconv.Itoa(report.Uncertain)) + b.WriteString(", errors: ") + b.WriteString(strconv.Itoa(report.Errors)) + b.WriteByte('\n') + return mcp.NewToolResultText(b.String()), nil + } + + return s.respondJSONOrTOON(ctx, req, out) +} diff --git a/internal/mcp/tools_analyze_temporal_verify_test.go b/internal/mcp/tools_analyze_temporal_verify_test.go new file mode 100644 index 000000000..4c6935d61 --- /dev/null +++ b/internal/mcp/tools_analyze_temporal_verify_test.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "context" + "strings" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/zzet/gortex/internal/graph" +) + +// TestAnalyzeTemporalVerify_RoutesAndRequiresLLM asserts that +// `analyze kind=temporal_verify` is wired into the dispatcher and, with no LLM +// provider configured (the default test server), returns a clear actionable +// error result instead of panicking or silently no-op'ing. +func TestAnalyzeTemporalVerify_RoutesAndRequiresLLM(t *testing.T) { + srv, _ := setupTestServer(t) + if srv.llmService != nil && srv.llmService.Enabled() { + t.Fatal("test precondition: setupTestServer must leave the LLM disabled") + } + + req := mcplib.CallToolRequest{} + req.Params.Name = "analyze" + req.Params.Arguments = map[string]any{"kind": "temporal_verify"} + + res, err := srv.handleAnalyze(context.Background(), req) + if err != nil { + t.Fatalf("handleAnalyze returned a transport error: %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("expected an error result when no LLM is configured, got %+v", res) + } + text := res.Content[0].(mcplib.TextContent).Text + if !strings.Contains(text, "LLM provider") { + t.Errorf("error result should name the missing LLM provider, got: %q", text) + } +} + +// TestAnalyzeTemporalVerify_UnknownKindStillRejected guards that adding the new +// kind didn't break the dispatcher's unknown-kind fallthrough. +func TestAnalyzeTemporalVerify_UnknownKindStillRejected(t *testing.T) { + srv, _ := setupTestServer(t) + req := mcplib.CallToolRequest{} + req.Params.Name = "analyze" + req.Params.Arguments = map[string]any{"kind": "temporal_verify_does_not_exist"} + + res, err := srv.handleAnalyze(context.Background(), req) + if err != nil { + t.Fatalf("handleAnalyze: %v", err) + } + if res == nil || !res.IsError { + t.Fatalf("unknown kind must yield an error result, got %+v", res) + } +} + +// TestServerSourceProvider_ReadsNodeSource exercises the MCP-side source +// provider that the handler injects into resolver.VerifyTemporalEdges: it must +// resolve a graph node's on-disk path (via the server's resolveNodePath) and +// return the node's source slice. This is the handler-specific seam — the LLM +// verification core itself is covered in the resolver / analyzer packages. +func TestServerSourceProvider_ReadsNodeSource(t *testing.T) { + srv, _ := setupTestServer(t) + + // setupTestServer indexes a fixture containing `func helper() {}`. + var helper *graph.Node + for _, n := range srv.graph.FindNodesByName("helper") { + if n != nil && n.Kind == graph.KindFunction { + helper = n + break + } + } + if helper == nil { + t.Fatal("fixture node `helper` not found in the indexed graph") + } + + src := newServerSourceProvider(srv) + body, ok := src.NodeSource(helper) + if !ok { + t.Fatalf("NodeSource(helper) returned ok=false (path=%q)", helper.FilePath) + } + if !strings.Contains(body, "helper") { + t.Errorf("source slice should contain the function name, got: %q", body) + } + + // A nil node degrades to ("", false) — never a panic. + if got, ok := src.NodeSource(nil); ok || got != "" { + t.Errorf("NodeSource(nil) = (%q, %v), want (\"\", false)", got, ok) + } +} diff --git a/internal/mcp/tools_enhancements.go b/internal/mcp/tools_enhancements.go index 6a54710ce..e96f4623c 100644 --- a/internal/mcp/tools_enhancements.go +++ b/internal/mcp/tools_enhancements.go @@ -131,7 +131,7 @@ func (s *Server) registerEnhancementTools() { s.addTool( mcp.NewTool("analyze", mcp.WithDescription("Unified graph analysis. kind=dead_code: symbols with zero incoming edges. kind=hotspots: high-complexity symbols by fan-in/out. kind=cycles: circular dependency chains. kind=would_create_cycle: check if a new edge would form a cycle (requires from_id, to_id). kind=todos: list KindTodo nodes with optional tag/assignee/ticket/has_assignee filters. kind=blame: run `git blame` against the indexed repo and stamp meta.last_authored on every symbol-level node. kind=coverage: parse a Go cover.out profile (path via `profile` arg) and stamp meta.coverage_pct on every executable symbol. kind=stale_code: list symbols whose meta.last_authored is older than the threshold (requires blame-enriched graph). kind=ownership: group blame metadata by author email — symbol count, files touched, oldest/newest timestamps; supports path_prefix scoping (requires blame-enriched graph). kind=coverage_gaps: list symbols whose meta.coverage_pct falls in [min_pct, max_pct) — sorted ascending so the most undertested code surfaces first (requires coverage-enriched graph). kind=unsafe_patterns: bundled scan for panic-prone / undefined-behaviour primitives across every supported language — Go panic, Rust .unwrap/.expect/panic!/todo!/unimplemented!/unreachable!/assert!/unsafe blocks, Python assert, JS/TS throw — aggregated into one row-per-site response with a per-detector summary. Filters: language, detector, severity, path_prefix, limit, exclude_tests. kind=sast / kind=hygiene: Bandit-parity SAST rule library — 190+ structural rules across Python / Go / JS+TS / Java / Ruby / PHP / Rust, each carrying CWE + OWASP + tags metadata. Per-detector summary + per-CWE rollup. Filters: language, detector, severity, cwe, tag, path_prefix, limit, exclude_tests, kinds_only. kind=review: idiomatic / correctness rule library for Go + Python (nil-deref-prone type assertions, inverted error checks, check-then-act races, query-in-loop N+1) carrying error/warning severity. Undecidable rows (N+1, check-then-act) are refined by a graph-grounding post-pass that drops sites the resolved call / loop metadata refutes. Same row shape + filters as sast. kind=health_score: composite per-symbol health value (0..100) + A..F grade aggregated from coverage_pct, complexity (fan-in/out + community-crossings), recency (last_authored), and session churn. Per-axis breakdown surfaced on every row; missing axes are skipped (not zero-imputed). Always returns a population distribution (mean / median / std_dev / Gini coefficient over inequality of risk + per-grade counts). Pass roll_up='file' or 'repo' for per-file / per-repo averages with min/max bands and per-grade counts. Filters: path_prefix, kinds, grade, min_score, max_score, min_axes, limit, roll_up. Sorted ascending so worst symbols surface first. kind=impact: composite per-symbol change-impact score (0..100, higher = more impactful) plus a risk label, ranking symbols by blast radius from five axes — PageRank centrality, transitive reach, cyclomatic complexity, git co-change coupling, and community span. Per-axis breakdown on every row. Filters: ids, path_prefix, kinds, min_score, max_score, limit. kind=bottlenecks: rank functions by computation-bottleneck risk from index-time per-function metrics (cyclomatic + cognitive complexity, max loop depth) plus interprocedural signals computed over the call graph — transitive_loop_depth (deepest nested-loop chain across calls, a hidden-O(n^k) detector) and recursion (unguarded when recursive with no branching base case). Each row carries a score and human-readable reasons. Filters: path_prefix, kinds, min_score, limit. kind=named: run a named query bundle — a reusable, named selection of structural detectors. Pass name= to fan every selected detector across the codebase and aggregate matches; omit name to list every bundle. Ten bundles ship built-in (sql-injection, command-injection, hardcoded-secrets, weak-crypto, xss, unsafe-deserialization, path-traversal, ssrf, xxe, debug-leftovers); a repo defines its own in .gortex.yaml::queries. Filters: name, language, severity, path_prefix, limit, exclude_tests. kind=tests_as_edges: a first-class view over the EdgeTests test→code edge layer. group_by=symbol (default) lists each tested symbol with the tests covering it; group_by=test inverts it to each test with the symbols it exercises. Always carries a summary of the edge layer's size. Filters: group_by, path_prefix, limit. kind=connectivity_health: a graph-EXTRACTION quality diagnostic — reports isolated nodes (zero edges of ANY kind, structural edges included), leaf / source-only / sink-only counts, effective-vs-nominal graph size and ratio, plus a per-node-kind breakdown and a dead-weight-by-file ranking that localises extraction gaps. Distinct from kind=dead_code: dead_code finds unreachable CODE (zero incoming usage edges, safe to delete); connectivity_health finds mis-EXTRACTED nodes (a normally indexed symbol always carries a structural edge, so an isolated node means the indexer failed, not that the code is unused). Filter: limit (caps dead_weight_by_file). kind=retrieval_log: mine the append-only retrieval query log (every search_symbols / smart_context / find_usages / search_text … call: question, corpus, nodes_returned, duration_ms, zero-result signal) for offline recall tuning — surfaces the top zero-result queries (the highest-signal candidates for synonym expansion or index gaps) plus per-tool latency (p50/p95) and result-size rollups. Filters: limit, tool, zero_only, since, top, include_recent. Gated by GORTEX_QUERY_LOG_DISABLE."), - mcp.WithString("kind", mcp.Required(), mcp.Description("Analysis kind: dead_code | hotspots | cycles | would_create_cycle | todos | blame | coverage | stale_code | ownership | coverage_gaps | stale_flags | releases | cgo_users | wasm_users | orphan_tables | unreferenced_tables | coverage_summary | channel_ops | goroutine_spawns | field_writers | race_writes | unclosed_channels | unsafe_patterns | sast | hygiene | health_score | annotation_users | config_readers | event_emitters | pubsub | string_emitters | error_surface | log_events | sql_rebuild | external_calls | routes | models | components | k8s_resources | images | kustomize | cross_repo | dbt_models | impact | bottlenecks | named | tests_as_edges | connectivity_health | retrieval_log")), + mcp.WithString("kind", mcp.Required(), mcp.Description("Analysis kind: dead_code | hotspots | cycles | would_create_cycle | todos | blame | coverage | stale_code | ownership | coverage_gaps | stale_flags | releases | cgo_users | wasm_users | orphan_tables | unreferenced_tables | coverage_summary | channel_ops | goroutine_spawns | field_writers | race_writes | unclosed_channels | unsafe_patterns | sast | hygiene | health_score | annotation_users | config_readers | event_emitters | pubsub | string_emitters | error_surface | log_events | sql_rebuild | external_calls | routes | models | components | k8s_resources | images | kustomize | cross_repo | dbt_models | impact | bottlenecks | named | tests_as_edges | connectivity_health | resolution_outcomes | temporal_verify | retrieval_log")), mcp.WithString("framework", mcp.Description("(dbt_models) Filter to one transformation framework — dbt or sqlmesh")), mcp.WithString("materialized", mcp.Description("(dbt_models) Substring match on the model materialization — table, view, incremental, …")), mcp.WithBoolean("compact", mcp.Description("One-line-per-result text output")), @@ -735,7 +735,7 @@ func (s *Server) handlePrefetchContext(ctx context.Context, req mcp.CallToolRequ func (s *Server) handleAnalyze(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { kind, err := req.RequireString("kind") if err != nil { - return mcp.NewToolResultError("kind is required (one of: dead_code, hotspots, cycles, would_create_cycle, todos, blame, coverage, stale_code, ownership, coverage_gaps, stale_flags, releases, cgo_users, wasm_users, orphan_tables, unreferenced_tables, coverage_summary, channel_ops, def_use, goroutine_spawns, field_writers, race_writes, unclosed_channels, unsafe_patterns, health_score, annotation_users, config_readers, event_emitters, pubsub, string_emitters, error_surface, log_events, sql_rebuild, external_calls, synthesizers, resolution_outcomes, retrieval_log, routes, models, components, k8s_resources, images, kustomize, cross_repo, impact, named, tests_as_edges, connectivity_health, pagerank, louvain, wcc, scc, kcore, suggest_boundaries)"), nil + return mcp.NewToolResultError("kind is required (one of: dead_code, hotspots, cycles, would_create_cycle, todos, blame, coverage, stale_code, ownership, coverage_gaps, stale_flags, releases, cgo_users, wasm_users, orphan_tables, unreferenced_tables, coverage_summary, channel_ops, def_use, goroutine_spawns, field_writers, race_writes, unclosed_channels, unsafe_patterns, health_score, annotation_users, config_readers, event_emitters, pubsub, string_emitters, error_surface, log_events, sql_rebuild, external_calls, synthesizers, resolution_outcomes, temporal_verify, retrieval_log, routes, models, components, k8s_resources, images, kustomize, cross_repo, impact, named, tests_as_edges, connectivity_health, pagerank, louvain, wcc, scc, kcore, suggest_boundaries)"), nil } switch kind { case "dead_code": @@ -832,6 +832,8 @@ func (s *Server) handleAnalyze(ctx context.Context, req mcp.CallToolRequest) (*m return s.handleAnalyzeTemporalOrphans(ctx, req) case "resolution_outcomes": return s.handleAnalyzeResolutionOutcomes(ctx, req) + case "temporal_verify": + return s.handleAnalyzeTemporalVerify(ctx, req) case "retrieval_log": return s.handleAnalyzeRetrievalLog(ctx, req) case "routes": @@ -881,7 +883,7 @@ func (s *Server) handleAnalyze(ctx context.Context, req mcp.CallToolRequest) (*m case "kcore": return s.handleAnalyzeKCore(ctx, req) default: - return mcp.NewToolResultError("unknown analyze kind: " + kind + " (expected: dead_code, hotspots, cycles, would_create_cycle, todos, blame, coverage, stale_code, ownership, coverage_gaps, stale_flags, releases, cgo_users, wasm_users, orphan_tables, unreferenced_tables, coverage_summary, channel_ops, def_use, goroutine_spawns, field_writers, race_writes, unclosed_channels, unsafe_patterns, sast, hygiene, review, health_score, annotation_users, config_readers, env_var_users, sql_call_sites, fixes_history, edge_audit, domain, event_emitters, pubsub, string_emitters, error_surface, log_events, sql_rebuild, external_calls, resolution_outcomes, retrieval_log, routes, models, components, k8s_resources, images, kustomize, cross_repo, dbt_models, impact, bottlenecks, named, tests_as_edges, connectivity_health, pagerank, louvain, wcc, scc, kcore)"), nil + return mcp.NewToolResultError("unknown analyze kind: " + kind + " (expected: dead_code, hotspots, cycles, would_create_cycle, todos, blame, coverage, stale_code, ownership, coverage_gaps, stale_flags, releases, cgo_users, wasm_users, orphan_tables, unreferenced_tables, coverage_summary, channel_ops, def_use, goroutine_spawns, field_writers, race_writes, unclosed_channels, unsafe_patterns, sast, hygiene, review, health_score, annotation_users, config_readers, env_var_users, sql_call_sites, fixes_history, edge_audit, domain, event_emitters, pubsub, string_emitters, error_surface, log_events, sql_rebuild, external_calls, resolution_outcomes, temporal_verify, retrieval_log, routes, models, components, k8s_resources, images, kustomize, cross_repo, dbt_models, impact, bottlenecks, named, tests_as_edges, connectivity_health, pagerank, louvain, wcc, scc, kcore)"), nil } } diff --git a/internal/resolver/temporal_verify.go b/internal/resolver/temporal_verify.go new file mode 100644 index 000000000..fb4d8efca --- /dev/null +++ b/internal/resolver/temporal_verify.go @@ -0,0 +1,248 @@ +package resolver + +// LLM cleaning pass for low-confidence Temporal dispatch edges. +// +// PURPOSE: the AST layers deliberately over-produce — the generic "env"-name +// heuristic, the convention fallback, and the fuzzy matcher all mint edges at +// the speculative / inferred tier to maximise recall. This pass is the +// precision backstop: it asks an LLM, grounded in the real caller + candidate +// source, whether each such edge is a true dispatch, and then PROMOTES the +// confirmed ones (visible, high confidence), SUPPRESSES the rejected ones +// (hidden), and leaves the uncertain ones where they are. +// +// RATIONALE: per-edge LLM verification is expensive, but the verifiable set is +// tiny (only resolved temporal stubs at confidence ≤ 0.65 — dozens, not the +// whole graph), so the cost is bounded. The verifier and the source provider +// are injected interfaces, so the core is deterministic and unit-testable with +// a fake LLM; the real provider + caching adapter wraps this. Register-confirmed +// edges (0.9) are never touched — the blast radius is strictly the already +// uncertain band. +// +// KEYWORDS: temporal, llm, verify, false-positive, precision, clean + +import ( + "context" + "strings" + + "github.com/zzet/gortex/internal/graph" +) + +// Confidence band the verifier operates in. Resolved-but-uncertain temporal +// stubs (heuristic env-default 0.4, fuzzy 0.5, convention / inferred env-default +// 0.6) fall in (0, 0.65]; register-confirmed 0.9 edges are above it and never +// verified. +const temporalVerifyMaxConfidence = 0.65 + +// Confidence stamped on an edge the LLM confirmed — above the inferred band so +// it surfaces by default, but below register-confirmed 0.9 (it is an LLM +// judgement over a heuristic, not a parsed registration). +const temporalVerifyConfirmedConfidence = 0.85 + +// Confidence stamped on an edge the LLM rejected — floored near zero and +// flagged speculative so it drops out of default queries without deleting the +// edge (the verdict + reason ride on its meta for audit). +const temporalVerifyRejectedConfidence = 0.1 + +// TemporalVerdict is the LLM's judgement on a single candidate dispatch edge. +type TemporalVerdict string + +const ( + TemporalVerdictConfirmed TemporalVerdict = "confirmed" + TemporalVerdictRejected TemporalVerdict = "rejected" + TemporalVerdictUncertain TemporalVerdict = "uncertain" +) + +// TemporalVerifyRequest is the grounded context handed to the verifier for one +// candidate edge: the dispatch name + how it was recognised, plus the source of +// the calling workflow and the candidate activity / workflow it resolved to. +type TemporalVerifyRequest struct { + DispatchName string // the activity / workflow name being dispatched + Kind string // "activity" / "workflow" + Source string // how the AST resolved it: heuristic / convention / fuzzy / env_default / … + CallerName string + CallerSource string + TargetName string + TargetSource string +} + +// TemporalVerifyResult is a single verdict plus a short human reason. +type TemporalVerifyResult struct { + Verdict TemporalVerdict + Reason string +} + +// TemporalVerifier verifies one candidate dispatch edge. The real implementation +// calls an LLM with a grounded prompt and structured output; tests inject a fake. +type TemporalVerifier interface { + Verify(ctx context.Context, req TemporalVerifyRequest) (TemporalVerifyResult, error) +} + +// TemporalSourceProvider returns the source text of a graph node (a workflow or +// activity function). The real implementation reads the node's file slice; tests +// inject an in-memory map. +type TemporalSourceProvider interface { + NodeSource(n *graph.Node) (string, bool) +} + +// TemporalVerifyDetail records the outcome for one edge (for the report / audit). +type TemporalVerifyDetail struct { + From string + To string + Name string + Kind string + Source string + Verdict TemporalVerdict + Reason string +} + +// TemporalVerifyReport summarises a verification run. +type TemporalVerifyReport struct { + Checked int + Confirmed int + Rejected int + Uncertain int + Errors int + Details []TemporalVerifyDetail +} + +// temporalVerifiable reports whether an edge is an LLM-verification candidate: a +// resolved Temporal stub/link edge sitting in the uncertain confidence band. +func temporalVerifiable(e *graph.Edge) bool { + if e == nil || e.Meta == nil { + return false + } + via, _ := e.Meta["via"].(string) + if !strings.HasPrefix(via, "temporal.") { + return false + } + if e.To == "" || strings.HasPrefix(e.To, "unresolved::") { + return false // unresolved placeholder — nothing to verify + } + return e.Confidence > 0 && e.Confidence <= temporalVerifyMaxConfidence +} + +// temporalEdgeSource recovers how an edge was resolved, for the prompt + report. +func temporalEdgeSource(e *graph.Edge) string { + if v, _ := e.Meta["temporal_env_source"].(string); v != "" { + return "env_default:" + v + } + if v, _ := e.Meta["temporal_resolution_via"].(string); v != "" { + return v + } + return "exact" +} + +// VerifyTemporalEdges runs the LLM cleaning pass over every verifiable Temporal +// edge in g, mutating each edge's tier by the verdict and returning a report. It +// holds the graph's resolve mutex while mutating edge meta (mirroring +// ResolveTemporalCalls) so it is safe against concurrent meta readers. A +// verifier error leaves the edge untouched and is counted. +func VerifyTemporalEdges(ctx context.Context, g graph.Store, src TemporalSourceProvider, v TemporalVerifier) TemporalVerifyReport { + var report TemporalVerifyReport + if g == nil || src == nil || v == nil { + return report + } + + // Phase 1 (locked): snapshot the candidate edges and their endpoint nodes, + // then release the resolve mutex. The LLM verification in phase 2 is slow + // and network-bound, so it must NOT run while holding the lock (that would + // stall every concurrent resolution / edit for the whole pass). + type candidate struct { + edge *graph.Edge + caller, target *graph.Node + } + mu := g.ResolveMutex() + mu.Lock() + var candidates []candidate + for e := range g.EdgesByKind(graph.EdgeCalls) { + if !temporalVerifiable(e) { + continue + } + caller := g.GetNode(e.From) + target := g.GetNode(e.To) + if caller == nil || target == nil { + continue + } + candidates = append(candidates, candidate{edge: e, caller: caller, target: target}) + } + mu.Unlock() + + // Phase 2 (unlocked): read source and run the LLM verifier per candidate. + type verdictResult struct { + edge *graph.Edge + verdict TemporalVerdict + reason string + } + var results []verdictResult + for _, c := range candidates { + callerSrc, _ := src.NodeSource(c.caller) + targetSrc, _ := src.NodeSource(c.target) + name, _ := c.edge.Meta["temporal_name"].(string) + kind, _ := c.edge.Meta["temporal_kind"].(string) + source := temporalEdgeSource(c.edge) + + report.Checked++ + res, err := v.Verify(ctx, TemporalVerifyRequest{ + DispatchName: name, + Kind: kind, + Source: source, + CallerName: c.caller.Name, + CallerSource: callerSrc, + TargetName: c.target.Name, + TargetSource: targetSrc, + }) + if err != nil { + report.Errors++ + continue + } + switch res.Verdict { + case TemporalVerdictConfirmed: + report.Confirmed++ + case TemporalVerdictRejected: + report.Rejected++ + default: + report.Uncertain++ + } + report.Details = append(report.Details, TemporalVerifyDetail{ + From: c.edge.From, To: c.edge.To, Name: name, Kind: kind, + Source: source, Verdict: res.Verdict, Reason: res.Reason, + }) + results = append(results, verdictResult{edge: c.edge, verdict: res.Verdict, reason: res.Reason}) + } + if len(results) == 0 { + return report + } + + // Phase 3 (locked): apply the verdicts to the edges and PERSIST them via + // ReindexEdges, so the promotion / suppression survives on a disk-backed + // store (where EdgesByKind hands back decoded copies) — not just on the + // in-memory store. + mu.Lock() + defer mu.Unlock() + batch := make([]graph.EdgeReindex, 0, len(results)) + for _, r := range results { + e := r.edge + if e.Meta == nil { + e.Meta = map[string]any{} + } + e.Meta["temporal_llm_verdict"] = string(r.verdict) + if r.reason != "" { + e.Meta["temporal_llm_reason"] = r.reason + } + switch r.verdict { + case TemporalVerdictConfirmed: + e.Confidence = temporalVerifyConfirmedConfidence + e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, e.Confidence) + delete(e.Meta, graph.MetaSpeculative) + case TemporalVerdictRejected: + e.Confidence = temporalVerifyRejectedConfidence + e.ConfidenceLabel = graph.ConfidenceLabelFor(graph.EdgeCalls, e.Confidence) + e.Meta[graph.MetaSpeculative] = true + } + // To is unchanged — OldTo == e.To — but ReindexEdges still persists the + // edge's mutated Confidence / Meta to the backend. + batch = append(batch, graph.EdgeReindex{Edge: e, OldTo: e.To}) + } + g.ReindexEdges(batch) + return report +} diff --git a/internal/resolver/temporal_verify_test.go b/internal/resolver/temporal_verify_test.go new file mode 100644 index 000000000..4360bdf1f --- /dev/null +++ b/internal/resolver/temporal_verify_test.go @@ -0,0 +1,151 @@ +package resolver + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/zzet/gortex/internal/graph" +) + +type fakeVerifier struct{ verdicts map[string]TemporalVerdict } + +func (f fakeVerifier) Verify(_ context.Context, req TemporalVerifyRequest) (TemporalVerifyResult, error) { + return TemporalVerifyResult{Verdict: f.verdicts[req.DispatchName], Reason: "fake"}, nil +} + +type fakeSource struct{} + +func (fakeSource) NodeSource(n *graph.Node) (string, bool) { return "// src of " + n.Name, true } + +type errVerifier struct{} + +func (errVerifier) Verify(context.Context, TemporalVerifyRequest) (TemporalVerifyResult, error) { + return TemporalVerifyResult{}, assert.AnError +} + +func TestVerifyTemporalEdges_PromotesSuppressesKeeps(t *testing.T) { + b := newTemporalTestGraph() + b.addGoFunc("wf/w.go::CoreWF", "CoreWF", "wf/w.go", "svc") + b.addGoFunc("wf/a.go::ChargeActivity", "ChargeActivity", "wf/a.go", "svc") + b.addGoFunc("wf/a.go::FakeActivity", "FakeActivity", "wf/a.go", "svc") + b.addGoFunc("wf/a.go::MaybeActivity", "MaybeActivity", "wf/a.go", "svc") + b.addGoFunc("wf/a.go::SureActivity", "SureActivity", "wf/a.go", "svc") + + mkEdge := func(name, target string, conf float64) *graph.Edge { + e := &graph.Edge{ + From: "wf/w.go::CoreWF", To: target, Kind: graph.EdgeCalls, + Confidence: conf, + Meta: map[string]any{"via": "temporal.stub", "temporal_kind": "activity", "temporal_name": name}, + } + b.g.AddEdge(e) + return e + } + confirmed := mkEdge("ChargeActivity", "wf/a.go::ChargeActivity", 0.4) + confirmed.Meta["temporal_env_source"] = "heuristic" + confirmed.Meta[graph.MetaSpeculative] = true + rejected := mkEdge("FakeActivity", "wf/a.go::FakeActivity", 0.6) + rejected.Meta["temporal_resolution_via"] = "convention" + uncertain := mkEdge("MaybeActivity", "wf/a.go::MaybeActivity", 0.5) + registered := mkEdge("SureActivity", "wf/a.go::SureActivity", 0.9) // above band — never verified + + v := fakeVerifier{verdicts: map[string]TemporalVerdict{ + "ChargeActivity": TemporalVerdictConfirmed, + "FakeActivity": TemporalVerdictRejected, + "MaybeActivity": TemporalVerdictUncertain, + }} + + rep := VerifyTemporalEdges(context.Background(), b.g, fakeSource{}, v) + assert.Equal(t, 3, rep.Checked, "only the three in-band edges are verified") + assert.Equal(t, 1, rep.Confirmed) + assert.Equal(t, 1, rep.Rejected) + assert.Equal(t, 1, rep.Uncertain) + + // Confirmed → promoted, visible. + assert.Equal(t, 0.85, confirmed.Confidence) + assert.Equal(t, "confirmed", confirmed.Meta["temporal_llm_verdict"]) + _, spec := confirmed.Meta[graph.MetaSpeculative] + assert.False(t, spec, "confirmed edge must no longer be hidden") + + // Rejected → suppressed, hidden. + assert.Equal(t, 0.1, rejected.Confidence) + assert.Equal(t, true, rejected.Meta[graph.MetaSpeculative]) + assert.Equal(t, "rejected", rejected.Meta["temporal_llm_verdict"]) + + // Uncertain → tier unchanged. + assert.Equal(t, 0.5, uncertain.Confidence) + assert.Equal(t, "uncertain", uncertain.Meta["temporal_llm_verdict"]) + + // Register-confirmed → untouched. + assert.Equal(t, 0.9, registered.Confidence) + _, verdicted := registered.Meta["temporal_llm_verdict"] + assert.False(t, verdicted, "register-confirmed edge must not be verified") +} + +func TestVerifyTemporalEdges_ErrorLeavesEdgeUntouched(t *testing.T) { + b := newTemporalTestGraph() + b.addGoFunc("w.go::WF", "WF", "w.go", "svc") + b.addGoFunc("a.go::A", "A", "a.go", "svc") + e := &graph.Edge{ + From: "w.go::WF", To: "a.go::A", Kind: graph.EdgeCalls, Confidence: 0.4, + Meta: map[string]any{"via": "temporal.stub", "temporal_kind": "activity", "temporal_name": "A"}, + } + b.g.AddEdge(e) + + rep := VerifyTemporalEdges(context.Background(), b.g, fakeSource{}, errVerifier{}) + assert.Equal(t, 1, rep.Checked) + assert.Equal(t, 1, rep.Errors) + assert.Equal(t, 0.4, e.Confidence, "a verifier error must leave the edge untouched") + _, verdicted := e.Meta["temporal_llm_verdict"] + assert.False(t, verdicted) +} + +func TestVerifyTemporalEdges_SkipsUnresolvedPlaceholders(t *testing.T) { + b := newTemporalTestGraph() + b.addGoFunc("w.go::WF", "WF", "w.go", "svc") + // Unresolved placeholder target — nothing to verify. + e := &graph.Edge{ + From: "w.go::WF", To: temporalStubPlaceholder("activity", "Ghost"), + Kind: graph.EdgeCalls, Confidence: 0, + Meta: map[string]any{"via": "temporal.stub", "temporal_kind": "activity", "temporal_name": "Ghost"}, + } + b.g.AddEdge(e) + + rep := VerifyTemporalEdges(context.Background(), b.g, fakeSource{}, fakeVerifier{}) + assert.Equal(t, 0, rep.Checked, "unresolved placeholders are not verification candidates") +} + +func TestVerifyTemporalEdges_PersistsViaReindex(t *testing.T) { + b := newTemporalTestGraph() + b.addGoFunc("wf/w.go::CoreWF", "CoreWF", "wf/w.go", "svc") + b.addGoFunc("wf/a.go::ChargeActivity", "ChargeActivity", "wf/a.go", "svc") + e := &graph.Edge{ + From: "wf/w.go::CoreWF", To: "wf/a.go::ChargeActivity", Kind: graph.EdgeCalls, + Confidence: 0.4, + Meta: map[string]any{ + "via": "temporal.stub", "temporal_kind": "activity", + "temporal_name": "ChargeActivity", graph.MetaSpeculative: true, + }, + } + b.g.AddEdge(e) + + v := fakeVerifier{verdicts: map[string]TemporalVerdict{"ChargeActivity": TemporalVerdictConfirmed}} + VerifyTemporalEdges(context.Background(), b.g, fakeSource{}, v) + + // Re-fetch from the store (not the original pointer) to prove the verdict + // was persisted through ReindexEdges, not just mutated on a transient copy + // that a disk-backed store would discard. + var got *graph.Edge + for _, oe := range b.g.GetOutEdges("wf/w.go::CoreWF") { + if oe != nil && oe.To == "wf/a.go::ChargeActivity" { + got = oe + } + } + if assert.NotNil(t, got, "edge must still exist after verify") { + assert.Equal(t, 0.85, got.Confidence, "promotion must be persisted in the store") + assert.Equal(t, "confirmed", got.Meta["temporal_llm_verdict"]) + _, spec := got.Meta[graph.MetaSpeculative] + assert.False(t, spec, "suppression flag must be cleared in the store") + } +}