From d68c43d7664abb7891ba1a3471f300c99167e5f8 Mon Sep 17 00:00:00 2001 From: Tutitoos Date: Fri, 4 Sep 2026 02:05:59 +0200 Subject: [PATCH 01/18] fix(profiles): preserve runtime state and attest the served graph --- cmd/kivgraph/BUILD.bazel | 4 + .../0115-profile-upgrade-preserves-runtime.md | 51 ++++ internal/config/BUILD.bazel | 16 +- internal/config/profile_upgrade_test.go | 227 ++++++++++++++++++ internal/config/profiles.go | 169 +++++++++++-- 5 files changed, 443 insertions(+), 24 deletions(-) create mode 100644 docs/adr/0115-profile-upgrade-preserves-runtime.md create mode 100644 internal/config/profile_upgrade_test.go diff --git a/cmd/kivgraph/BUILD.bazel b/cmd/kivgraph/BUILD.bazel index 2c9d5781..78b04958 100644 --- a/cmd/kivgraph/BUILD.bazel +++ b/cmd/kivgraph/BUILD.bazel @@ -106,6 +106,7 @@ go_test( "logs_test.go", "main_test.go", "output_test.go", + "profile_freshness_test.go", "profile_indexer_test.go", "profile_registry_test.go", "profile_test.go", @@ -131,6 +132,7 @@ go_test( "//internal/eventlog", "//internal/facts", "//internal/filelock", + "//internal/freshness", "//internal/goworkspace", "//internal/hotsnapshot", "//internal/indexer", @@ -138,6 +140,7 @@ go_test( "//internal/integrations", "//internal/invalidation", "//internal/logging", + "//internal/mcp", "//internal/mcp/tools", "//internal/metrics", "//internal/procstat", @@ -155,5 +158,6 @@ go_test( "//internal/webassets", "//internal/workspace", "@com_github_charmbracelet_bubbletea//:bubbletea", + "@com_github_modelcontextprotocol_go_sdk//mcp", ], ) diff --git a/docs/adr/0115-profile-upgrade-preserves-runtime.md b/docs/adr/0115-profile-upgrade-preserves-runtime.md new file mode 100644 index 00000000..b6aad4eb --- /dev/null +++ b/docs/adr/0115-profile-upgrade-preserves-runtime.md @@ -0,0 +1,51 @@ +# ADR 0115: preserve runtime identity during profile upgrade + +## Context + +The first profile migration copied the entire installation state directory. +A real `daemon.sock` made that copy fail before MCP initialization. Moving the +root would also replace live lock inodes. The profile artifact list omitted the +published freshness attestations. + +These are upgrade defects, not reasons to rebuild an unchanged graph or weaken +freshness guarantees. This decision refines the migration +section of ADR 0087; it does not change the MCP envelope or analyzer guarantees. + +## Decision + +Keep the installation root in place. Copy only graph artifacts into a private +staging directory: generations, `CURRENT`, `BACKUP`, backups, freshness, +synthetic `go.work` and `go.work.sum`, and the legacy `graph.lbdb`. Copy the +repository registry separately. The content-addressed fact cache, analyzer +targets, logs, endpoints, PID files, sockets, and lock files remain at +installation scope and are never relocated. + +A nonblocking migration lock outside the root serializes upgrades. Acquire the +existing analyzer-targets, resync, and publication locks before copying. Refuse +a reachable daemon and ask the operator to stop it. A stale Unix socket is +allowed but neither copied nor deleted. Other special top-level files, and +special files anywhere inside graph artifacts, fail closed. Permissions and +I/O errors are not interpreted as a stopped daemon or silently skipped. + +Validate the registry and CURRENT target before publication. Publish a separate +`.pre-profiles` backup and then atomically rename the candidate into its profile +directory. The original graph remains untouched at installation scope as an +additional recovery source; removing it is an explicit maintenance decision, +not part of loading configuration. This temporarily costs additional disk. + +If interrupted between the two renames, retry only when the backup and current +candidate have identical paths, modes and bytes. A differing backup is retained +and reported, never overwritten. Continue supporting recovery of a missing root +from the previous migration's backup. An existing profile must pass validation; +directory existence alone is not successful migration. + +## Validation and limits + +Regression tests use real Unix sockets and writer locks, interrupted migration +fixtures, mismatched backups, and partial destinations. + +The migration checks filesystem layout, not native graph integrity; opening the +published snapshot remains a separate startup gate. Installation must stop old +writers, retain the old bundle, verify native startup, and roll back on failure. +An old binary must not resume writing the retained legacy graph alongside a new +profile-aware server. diff --git a/internal/config/BUILD.bazel b/internal/config/BUILD.bazel index b1adc836..19f6ecef 100644 --- a/internal/config/BUILD.bazel +++ b/internal/config/BUILD.bazel @@ -25,6 +25,7 @@ go_test( "build_files_test.go", "config_test.go", "languages_test.go", + "profile_upgrade_test.go", "profile_topology_test.go", "profiles_test.go", ], @@ -32,5 +33,18 @@ go_test( deps = [ "//internal/testsupport", "//internal/topology", - ], + ] + select({ + "@rules_go//go/platform:aix": ["//internal/filelock"], + "@rules_go//go/platform:android": ["//internal/filelock"], + "@rules_go//go/platform:darwin": ["//internal/filelock"], + "@rules_go//go/platform:dragonfly": ["//internal/filelock"], + "@rules_go//go/platform:freebsd": ["//internal/filelock"], + "@rules_go//go/platform:illumos": ["//internal/filelock"], + "@rules_go//go/platform:ios": ["//internal/filelock"], + "@rules_go//go/platform:linux": ["//internal/filelock"], + "@rules_go//go/platform:netbsd": ["//internal/filelock"], + "@rules_go//go/platform:openbsd": ["//internal/filelock"], + "@rules_go//go/platform:solaris": ["//internal/filelock"], + "//conditions:default": [], + }), ) diff --git a/internal/config/profile_upgrade_test.go b/internal/config/profile_upgrade_test.go new file mode 100644 index 00000000..71495244 --- /dev/null +++ b/internal/config/profile_upgrade_test.go @@ -0,0 +1,227 @@ +//go:build unix + +package config + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + "testing" + + "github.com/Luqueee/kivgraph/internal/filelock" +) + +func legacyProfileFixture(t *testing.T) (string, string) { + t.Helper() + // Unix socket addresses must fit Darwin's 104-byte limit. + root, err := os.MkdirTemp("", "kg-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + state := filepath.Join(root, "state") + if err := os.MkdirAll(filepath.Join(state, "generations", "000007"), 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "config.yaml") + registry := filepath.Join(root, "repositories.yaml") + writeConfigFixture(t, path, "version: 1\nworkspace:\n repositories_file: "+registry+"\nstorage:\n database_path: "+filepath.Join(state, "graph.lbdb")+"\n") + writeConfigFixture(t, registry, "version: 1\nrepositories: []\n") + writeConfigFixture(t, filepath.Join(state, "CURRENT"), "000007\n") + return path, state +} + +func TestProfileUpgradePreservesRuntimeAndFreshness(t *testing.T) { + path, state := legacyProfileFixture(t) + // A real socket cannot be copied as an ordinary file. It must retain its + // identity, as must the lock inode protecting this installation. + listener, err := net.Listen("unix", filepath.Join(state, "daemon.sock")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + listener.(*net.UnixListener).SetUnlinkOnClose(false) + if _, err := LoadProfile(path, ""); err == nil { + t.Fatal("migrated with live daemon") + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } + socketBefore, err := os.Lstat(filepath.Join(state, "daemon.sock")) + if err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(state, "freshness"), 0700); err != nil { + t.Fatal(err) + } + writeConfigFixture(t, filepath.Join(state, "freshness", "00000000000000000007.json"), `{"version":1,"generation":7,"digest":"attestation"}`) + writeConfigFixture(t, filepath.Join(state, "publish.lock"), "") + before, err := os.Stat(filepath.Join(state, "publish.lock")) + if err != nil { + t.Fatal(err) + } + if _, err := LoadProfile(path, ""); err != nil { + t.Fatal(err) + } + after, err := os.Stat(filepath.Join(state, "publish.lock")) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(before, after) { + t.Fatal("migration replaced live lock inode") + } + for _, root := range []string{filepath.Join(state, "profiles", "default"), state + ".pre-profiles"} { + if _, err := os.Stat(filepath.Join(root, "freshness", "00000000000000000007.json")); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(filepath.Join(root, "daemon.sock")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("copied runtime socket: %v", err) + } + } + socketAfter, err := os.Lstat(filepath.Join(state, "daemon.sock")) + if err != nil { + t.Fatal(err) + } + if !os.SameFile(socketBefore, socketAfter) { + t.Fatal("replaced runtime socket") + } + if _, err := LoadProfile(path, ""); err != nil { + t.Fatalf("idempotent load: %v", err) + } +} + +func TestProfileUpgradeRefusesActiveWritersAndCanRetry(t *testing.T) { + for _, name := range []string{"publish.lock", "resync.lock", "analyzer-targets.lock", "profile-migration.lock"} { + t.Run(name, func(t *testing.T) { + path, state := legacyProfileFixture(t) + lockPath := filepath.Join(state, name) + if name == "profile-migration.lock" { + lockPath = state + ".profile-migration.lock" + } + lock, acquired, err := filelock.Acquire(lockPath) + if err != nil || !acquired { + t.Fatalf("lock: %v %v", acquired, err) + } + t.Cleanup(func() { _ = lock.Release() }) + if _, err := LoadProfile(path, ""); err == nil { + t.Fatal("migrated during active write") + } + if _, err := os.Stat(filepath.Join(state, "profiles", "default")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("published on refusal: %v", err) + } + if err := lock.Release(); err != nil { + t.Fatal(err) + } + if _, err := LoadProfile(path, ""); err != nil { + t.Fatalf("retry: %v", err) + } + }) + } +} + +func TestProfileUpgradeRejectsPartialDestination(t *testing.T) { + path, state := legacyProfileFixture(t) + if err := os.MkdirAll(filepath.Join(state, "profiles", "default"), 0700); err != nil { + t.Fatal(err) + } + configuration, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + if err := ensureDefaultProfile(configuration, configuration.Workspace.RepositoriesFile); err == nil { + t.Fatal("accepted incomplete destination") + } +} + +func TestProfileUpgradeRefusesSpecialGraphArtifact(t *testing.T) { + path, state := legacyProfileFixture(t) + listener, err := net.Listen("unix", filepath.Join(state, "generations", "unexpected.sock")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + if _, err := LoadProfile(path, ""); err == nil { + t.Fatal("accepted special graph file") + } + if _, err := os.Stat(state + ".pre-profiles"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("published backup on failure: %v", err) + } + if err := listener.Close(); err != nil { + t.Fatal(err) + } + if _, err := LoadProfile(path, ""); err != nil { + t.Fatalf("retry: %v", err) + } +} + +func TestProfileUpgradeResumesOnlyAnIdenticalBackup(t *testing.T) { + for _, changed := range []bool{false, true} { + t.Run(fmt.Sprint(changed), func(t *testing.T) { + path, state := legacyProfileFixture(t) + if _, err := LoadProfile(path, ""); err != nil { + t.Fatal(err) + } + // Simulate interruption after publishing backup but before profile. + if err := os.RemoveAll(filepath.Join(state, "profiles")); err != nil { + t.Fatal(err) + } + if changed { + writeConfigFixture(t, filepath.Join(state+".pre-profiles", "CURRENT"), "different\n") + } + _, err := LoadProfile(path, "") + if changed && err == nil { + t.Fatal("overwrote mismatched recovery point") + } + if !changed && err != nil { + t.Fatalf("resume: %v", err) + } + }) + } +} + +func TestProfileUpgradeRejectsUnsafeArtifactsWithoutPublication(t *testing.T) { + for _, kind := range []string{"current-parent", "current-file", "graph-symlink", "unexpected-socket", "unreadable-graph"} { + t.Run(kind, func(t *testing.T) { + path, state := legacyProfileFixture(t) + switch kind { + case "current-parent": + writeConfigFixture(t, filepath.Join(state, "CURRENT"), "..\n") + case "current-file": + writeConfigFixture(t, filepath.Join(state, "generations", "000008"), "not a directory") + writeConfigFixture(t, filepath.Join(state, "CURRENT"), "000008\n") + case "graph-symlink": + if err := os.Symlink(path, filepath.Join(state, "generations", "link")); err != nil { + t.Fatal(err) + } + case "unexpected-socket": + listener, err := net.Listen("unix", filepath.Join(state, "unknown.sock")) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + case "unreadable-graph": + if os.Geteuid() == 0 { + t.Skip("root bypasses read permissions") + } + file := filepath.Join(state, "generations", "unreadable") + writeConfigFixture(t, file, "private") + if err := os.Chmod(file, 0000); err != nil { + t.Fatal(err) + } + } + if _, err := LoadProfile(path, ""); err == nil { + t.Fatal("accepted unsafe graph") + } + if _, err := os.Stat(filepath.Join(state, "profiles", "default")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("published invalid state: %v", err) + } + }) + } +} + +// OS failures after successful staging (close/unlock or rename failure during +// a concurrent filesystem change) have no deterministic injection seam here. +// Tests exercise real read failures and locks rather than adding test-only +// filesystem hooks to the migration. diff --git a/internal/config/profiles.go b/internal/config/profiles.go index 824f1875..7fdd75b2 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -2,12 +2,20 @@ package config import ( "bytes" + "crypto/sha256" "errors" "fmt" + "io" + "io/fs" + "net" "os" "path/filepath" "sort" + "strings" + "syscall" + "time" + "github.com/Luqueee/kivgraph/internal/filelock" "gopkg.in/yaml.v3" ) @@ -44,14 +52,27 @@ func profileAt(configuration Config, name string) Profile { // ensureDefaultProfile builds the initial profile beside the legacy layout and // publishes it with one rename. Existing state is copied, never moved, so an // interrupted upgrade still has the graph it started with. -func ensureDefaultProfile(configuration Config, repositoriesPath string) error { +func ensureDefaultProfile(configuration Config, repositoriesPath string) (resultErr error) { profile := profileAt(configuration, configuration.Profiles.Default) if _, err := os.Stat(profile.StateDirectory); err == nil { - return nil + return validateMigratedProfile(profile.StateDirectory) } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("inspect default profile %q: %w", profile.StateDirectory, err) } stateRoot := filepath.Dir(configuration.Storage.DatabasePath) + // Keep the installation directory and every lock inode in place. Moving + // the root would detach running processes from both their sockets and locks. + lock, acquired, err := filelock.Acquire(stateRoot + ".profile-migration.lock") + if err != nil { + return fmt.Errorf("lock profile migration: %w", err) + } + if !acquired { + return errors.New("profile migration is in progress; retry after it completes") + } + defer func() { resultErr = errors.Join(resultErr, lock.Release()) }() + if _, err := os.Stat(profile.StateDirectory); err == nil { + return validateMigratedProfile(profile.StateDirectory) + } backupRoot := stateRoot + ".pre-profiles" if _, err := os.Stat(stateRoot); errors.Is(err, os.ErrNotExist) { if _, backupErr := os.Stat(backupRoot); backupErr == nil { @@ -64,6 +85,19 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) error { } else if err != nil { return fmt.Errorf("inspect legacy profile state %q: %w", stateRoot, err) } + if err := inspectLegacyRuntime(stateRoot); err != nil { + return err + } + for _, name := range []string{"analyzer-targets.lock", "resync.lock", "publish.lock"} { + lock, acquired, err := filelock.Acquire(filepath.Join(stateRoot, name)) + if err != nil { + return fmt.Errorf("lock legacy state %s: %w", name, err) + } + if !acquired { + return fmt.Errorf("profile migration blocked by active writer (%s); stop writers and retry", name) + } + defer func() { resultErr = errors.Join(resultErr, lock.Release()) }() + } root := profilesRoot(configuration) if entries, err := os.ReadDir(root); err == nil { for _, entry := range entries { @@ -82,11 +116,7 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) error { return fmt.Errorf("prepare default profile migration: %w", err) } defer os.RemoveAll(temporaryParent) - temporaryState := filepath.Join(temporaryParent, filepath.Base(stateRoot)) - if err := copyProfileArtifact(stateRoot, temporaryState); err != nil { - return fmt.Errorf("copy legacy state for profile migration: %w", err) - } - temporaryProfile := filepath.Join(temporaryState, "profiles", configuration.Profiles.Default) + temporaryProfile := filepath.Join(temporaryParent, "profile") if err := os.MkdirAll(temporaryProfile, 0o700); err != nil { return fmt.Errorf("create temporary default profile: %w", err) } @@ -94,34 +124,49 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) error { return err } for _, name := range []string{ - "generations", "CURRENT", "BACKUP", "backups", "publish.lock", - "resync.lock", "go.work", "graph.lbdb", + "generations", "CURRENT", "BACKUP", "backups", "freshness", + "go.work", "go.work.sum", "graph.lbdb", } { - source := filepath.Join(temporaryState, name) + source := filepath.Join(stateRoot, name) if _, err := os.Lstat(source); errors.Is(err, os.ErrNotExist) { continue } else if err != nil { return fmt.Errorf("inspect copied profile artifact %q: %w", source, err) } - if err := os.Rename(source, filepath.Join(temporaryProfile, name)); err != nil { + if err := copyProfileArtifact(source, filepath.Join(temporaryProfile, name)); err != nil { return fmt.Errorf("place copied profile artifact %q: %w", name, err) } } if err := validateMigratedProfile(temporaryProfile); err != nil { return err } - if _, err := os.Stat(backupRoot); err == nil { - return fmt.Errorf("profile migration backup already exists: %s", backupRoot) + if _, err := os.Lstat(backupRoot); err == nil { + candidate, err := profileArtifactDigest(temporaryProfile) + if err != nil { + return err + } + backup, err := profileArtifactDigest(backupRoot) + if err != nil { + return err + } + if candidate != backup { + return fmt.Errorf("profile migration backup differs from legacy state: %s", backupRoot) + } } else if !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("inspect profile migration backup: %w", err) + } else { + temporaryBackup := filepath.Join(temporaryParent, "backup") + if err := copyProfileArtifact(temporaryProfile, temporaryBackup); err != nil { + return fmt.Errorf("prepare legacy profile backup: %w", err) + } + if err := os.Rename(temporaryBackup, backupRoot); err != nil { + return fmt.Errorf("retain legacy profile state: %w", err) + } } - if err := os.Rename(stateRoot, backupRoot); err != nil { - return fmt.Errorf("retain legacy profile state: %w", err) + if err := os.MkdirAll(root, 0o700); err != nil { + return fmt.Errorf("create profiles directory: %w", err) } - if err := os.Rename(temporaryState, stateRoot); err != nil { - if rollbackErr := os.Rename(backupRoot, stateRoot); rollbackErr != nil { - return fmt.Errorf("publish migrated profile: %w; rollback failed: %v", err, rollbackErr) - } + if err := os.Rename(temporaryProfile, profile.StateDirectory); err != nil { return fmt.Errorf("publish migrated profile: %w", err) } // Load remains the compatibility seam for installation-level diagnostics. @@ -135,6 +180,13 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) error { } func validateMigratedProfile(profileRoot string) error { + info, err := os.Lstat(profileRoot) + if err != nil { + return fmt.Errorf("inspect migrated profile: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("migrated profile %q is not a directory", profileRoot) + } registry := filepath.Join(profileRoot, "repositories.yaml") if _, err := LoadRepositories(registry); err != nil { return fmt.Errorf("validate migrated repository registry: %w", err) @@ -148,11 +200,11 @@ func validateMigratedProfile(profileRoot string) error { return fmt.Errorf("validate migrated CURRENT: %w", err) } generation := string(bytes.TrimSpace(current)) - if generation == "" || filepath.Base(generation) != generation { + if generation == "" || strings.Trim(generation, "0123456789") != "" { return fmt.Errorf("validate migrated CURRENT: invalid generation %q", generation) } generationRoot := filepath.Join(profileRoot, "generations", generation) - info, err := os.Stat(generationRoot) + info, err = os.Lstat(generationRoot) if err != nil { return fmt.Errorf("validate migrated CURRENT generation %q: %w", generation, err) } @@ -162,6 +214,34 @@ func validateMigratedProfile(profileRoot string) error { return nil } +func inspectLegacyRuntime(root string) error { + entries, err := os.ReadDir(root) + if err != nil { + return fmt.Errorf("inspect legacy runtime: %w", err) + } + for _, entry := range entries { + info, err := entry.Info() + if err != nil { + return fmt.Errorf("inspect legacy runtime %q: %w", entry.Name(), err) + } + if info.IsDir() || info.Mode().IsRegular() { + continue + } + if entry.Name() != "daemon.sock" || info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("unexpected special legacy artifact %q", filepath.Join(root, entry.Name())) + } + connection, err := net.DialTimeout("unix", filepath.Join(root, entry.Name()), time.Second) + if err == nil { + _ = connection.Close() + return errors.New("profile migration blocked by a running daemon; stop it and retry") + } + if !errors.Is(err, syscall.ECONNREFUSED) && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("check legacy daemon: %w", err) + } + } + return nil +} + func copyProfileRegistry(source, destination string) error { data, err := os.ReadFile(source) if err != nil { @@ -196,16 +276,59 @@ func copyProfileArtifact(source, destination string) error { } return nil } - data, err := os.ReadFile(source) + if !info.Mode().IsRegular() { + return fmt.Errorf("legacy profile artifact %q is not a regular file", source) + } + input, err := os.Open(source) if err != nil { return fmt.Errorf("read legacy profile artifact %q: %w", source, err) } - if err := os.WriteFile(destination, data, info.Mode().Perm()); err != nil { + defer input.Close() + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + return fmt.Errorf("create copied artifact %q: %w", destination, err) + } + _, copyErr := io.Copy(output, input) + if err := errors.Join(copyErr, output.Close()); err != nil { return fmt.Errorf("copy legacy profile artifact %q: %w", source, err) } return nil } +func profileArtifactDigest(root string) ([32]byte, error) { + digest := sha256.New() + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.IsDir() && !info.Mode().IsRegular() { + return fmt.Errorf("unexpected recovery artifact %q", path) + } + fmt.Fprintf(digest, "%q %d\n", relative, info.Mode()) + if info.IsDir() { + return nil + } + file, err := os.Open(path) + if err != nil { + return err + } + fmt.Fprintf(digest, "%d\n", info.Size()) + _, err = io.Copy(digest, file) + return errors.Join(err, file.Close()) + }) + var result [32]byte + copy(result[:], digest.Sum(nil)) + return result, err +} + // ListProfiles returns profiles in canonical name order. func ListProfiles(configPath string) ([]Profile, error) { configuration, err := LoadConfig(configPath) From f749d0752f04bb6f8901a85ae3749abeae8af424 Mon Sep 17 00:00:00 2001 From: Tutitoos Date: Fri, 4 Sep 2026 02:16:17 +0200 Subject: [PATCH 02/18] test(profiles): identify freshness stages and document nil input --- internal/config/profile_upgrade_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/config/profile_upgrade_test.go b/internal/config/profile_upgrade_test.go index 71495244..f7f12bbd 100644 --- a/internal/config/profile_upgrade_test.go +++ b/internal/config/profile_upgrade_test.go @@ -212,7 +212,7 @@ func TestProfileUpgradeRejectsUnsafeArtifactsWithoutPublication(t *testing.T) { } } if _, err := LoadProfile(path, ""); err == nil { - t.Fatal("accepted unsafe graph") + t.Fatalf("accepted unsafe graph %q", kind) } if _, err := os.Stat(filepath.Join(state, "profiles", "default")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("published invalid state: %v", err) From 4569bad7a8541e12f7bbf3bcb6f976ff3c1c4ffc Mon Sep 17 00:00:00 2001 From: Tutitoos Date: Sat, 5 Sep 2026 16:54:44 +0200 Subject: [PATCH 03/18] fix(dartloader): observe worker termination Settle pending protocol calls when the analyzer exits, preserve bounded stderr and exit diagnostics, propagate write failures, and close owned processes. The historical broken pipe was not reproduced; the real Dart pass and full rebuild succeeded. --- internal/dartloader/BUILD.bazel | 5 +- internal/dartloader/loader.go | 113 +++++++++++++------------- internal/dartloader/worker.go | 123 +++++++++++++++++++++++++++++ internal/dartloader/worker_test.go | 75 ++++++++++++++++++ 4 files changed, 259 insertions(+), 57 deletions(-) create mode 100644 internal/dartloader/worker.go create mode 100644 internal/dartloader/worker_test.go diff --git a/internal/dartloader/BUILD.bazel b/internal/dartloader/BUILD.bazel index a6252963..8e666c0e 100644 --- a/internal/dartloader/BUILD.bazel +++ b/internal/dartloader/BUILD.bazel @@ -2,7 +2,7 @@ load("@rules_go//go:def.bzl", "go_library", "go_test") go_library( name = "dartloader", - srcs = ["loader.go"], + srcs = ["loader.go", "worker.go"], importpath = "github.com/Luqueee/kivgraph/internal/dartloader", visibility = ["//:__subpackages__"], deps = [ @@ -14,11 +14,12 @@ go_library( go_test( name = "dartloader_test", - srcs = ["loader_test.go"], + srcs = ["loader_test.go", "worker_test.go"], data = ["//:testdata"], embed = [":dartloader"], deps = [ "//internal/facts", + "//internal/testsupport", "//internal/workspace", ], ) diff --git a/internal/dartloader/loader.go b/internal/dartloader/loader.go index 0b5f90ed..a199eef8 100644 --- a/internal/dartloader/loader.go +++ b/internal/dartloader/loader.go @@ -120,7 +120,9 @@ func RunWithOptions(ctx context.Context, options Options) (facts.SemanticPayload return facts.SemanticPayload{}, fmt.Errorf("read Dart file %q: %w", path, err) } contents[path] = data - client.notify("textDocument/didOpen", map[string]any{"textDocument": map[string]any{"uri": fileURI(path), "languageId": "dart", "version": 1, "text": string(data)}}) + if err := client.notify("textDocument/didOpen", map[string]any{"textDocument": map[string]any{"uri": fileURI(path), "languageId": "dart", "version": 1, "text": string(data)}}); err != nil { + return facts.SemanticPayload{}, fmt.Errorf("opening Dart file %q: %w", path, err) + } } payload := facts.SemanticPayload{Version: PayloadVersion, Authoritative: true, Repository: repository.Name, Language: facts.LanguageDart, Package: facts.SemanticPackage{Name: dartPackageName(root), RootPath: root, ManifestPath: filepath.Join(root, "pubspec.yaml")}} @@ -247,11 +249,11 @@ func RunWithOptions(ctx context.Context, options Options) (facts.SemanticPayload } type client struct { - cmd *exec.Cmd - in io.WriteCloser - out <-chan rpcMessage - mu sync.Mutex - next int + worker *workerProcess + in io.WriteCloser + out <-chan rpcMessage + mu sync.Mutex + next int } type rpcMessage struct { ID json.RawMessage @@ -277,22 +279,14 @@ func start(ctx context.Context, command, sdkPath, root string) (*client, error) if isDartProgram(executable) { args = append(args, "language-server", "--protocol=lsp", "--client-id=kivgraph", "--client-version=dev") } - cmd := exec.CommandContext(ctx, executable, args...) - cmd.Dir = root - in, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - stdout, err := cmd.StdoutPipe() + + worker, stdout, err := launchWorker(ctx, executable, args, root) if err != nil { return nil, err } - if err := cmd.Start(); err != nil { - return nil, err - } messages := make(chan rpcMessage, 32) - go readMessages(stdout, messages) - return &client{cmd: cmd, in: in, out: messages, next: 1}, nil + go readMessages(stdout, messages, worker.done) + return &client{worker: worker, in: worker.in, out: messages, next: 1}, nil } // SDKRoot resolves the Dart SDK directory from the configured dart command. @@ -392,7 +386,7 @@ type analyzerMessage struct { } type analyzerClient struct { - cmd *exec.Cmd + worker *workerProcess in io.WriteCloser out <-chan analyzerMessage mu sync.Mutex @@ -431,22 +425,14 @@ func startAnalyzer(ctx context.Context, command, sdkPath, root string) (*analyze if isDartProgram(executable) { args = append(args, "language-server", "--protocol=analyzer", "--client-id=kivgraph", "--client-version=dev") } - cmd := exec.CommandContext(ctx, executable, args...) - cmd.Dir = root - in, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - stdout, err := cmd.StdoutPipe() + + worker, stdout, err := launchWorker(ctx, executable, args, root) if err != nil { return nil, err } - if err := cmd.Start(); err != nil { - return nil, err - } messages := make(chan analyzerMessage, 64) - go readAnalyzerMessages(stdout, messages) - return &analyzerClient{cmd: cmd, in: in, out: messages, next: 1}, nil + go readAnalyzerMessages(stdout, messages, worker.done) + return &analyzerClient{worker: worker, in: worker.in, out: messages, next: 1}, nil } func (c *analyzerClient) nextID() string { @@ -458,12 +444,20 @@ func (c *analyzerClient) nextID() string { } func (c *analyzerClient) sendRequest(id, method string, params any) error { + if err := c.worker.stopped(method); err != nil { + return err + } data, err := json.Marshal(map[string]any{"id": id, "method": method, "params": params}) if err != nil { return err } + c.mu.Lock() _, err = fmt.Fprintf(c.in, "%s\n", data) - return err + c.mu.Unlock() + if err != nil { + return c.worker.failure(method, err) + } + return nil } func (c *analyzerClient) call(ctx context.Context, method string, params any) (json.RawMessage, error) { @@ -486,7 +480,7 @@ func (c *analyzerClient) collect(ctx context.Context, requests map[string]struct return nil, ctx.Err() case message, ok := <-c.out: if !ok { - return nil, fmt.Errorf("closed connection to the Dart analysis server") + return nil, c.worker.failure("read", fmt.Errorf("closed connection to the Dart analysis server")) } if message.Event != "" { c.eventsMu.Lock() @@ -532,7 +526,7 @@ func (c *analyzerClient) drainEvents(ctx context.Context, duration time.Duration return nil case message, ok := <-c.out: if !ok { - return nil + return c.worker.failure("drain events", fmt.Errorf("worker stream closed")) } if message.Event == "" { continue @@ -544,20 +538,20 @@ func (c *analyzerClient) drainEvents(ctx context.Context, duration time.Duration } } -func (c *analyzerClient) close() { - _ = c.in.Close() - _ = c.cmd.Process.Kill() - _ = c.cmd.Wait() -} +func (c *analyzerClient) close() { c.worker.close() } -func readAnalyzerMessages(reader io.Reader, output chan<- analyzerMessage) { +func readAnalyzerMessages(reader io.Reader, output chan<- analyzerMessage, done <-chan struct{}) { defer close(output) scanner := bufio.NewScanner(reader) scanner.Buffer(make([]byte, 4096), 16<<20) for scanner.Scan() { var message analyzerMessage if json.Unmarshal(scanner.Bytes(), &message) == nil && (message.ID != "" || message.Event != "") { - output <- message + select { + case output <- message: + case <-done: + return + } } } } @@ -1096,8 +1090,7 @@ func (c *client) initialize(ctx context.Context, root string) error { if err != nil { return err } - c.notify("initialized", map[string]any{}) - return nil + return c.notify("initialized", map[string]any{}) } func (c *client) call(ctx context.Context, method string, params any) (json.RawMessage, error) { @@ -1114,7 +1107,7 @@ func (c *client) call(ctx context.Context, method string, params any) (json.RawM return nil, ctx.Err() case message, ok := <-c.out: if !ok { - return nil, fmt.Errorf("closed connection to the Dart analysis server") + return nil, c.worker.failure("read", fmt.Errorf("closed connection to the Dart analysis server")) } if string(message.ID) != strconv.Itoa(id) { continue @@ -1127,25 +1120,28 @@ func (c *client) call(ctx context.Context, method string, params any) (json.RawM } } -func (c *client) notify(method string, params any) { - _ = c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params}) +func (c *client) notify(method string, params any) error { + return c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params}) } func (c *client) send(message any) error { + if err := c.worker.stopped("write"); err != nil { + return err + } data, err := json.Marshal(message) if err != nil { return err } + c.mu.Lock() _, err = fmt.Fprintf(c.in, "Content-Length: %d\r\n\r\n%s", len(data), data) - return err -} -func (c *client) close() { - _ = c.send(map[string]any{"jsonrpc": "2.0", "id": c.next, "method": "shutdown", "params": nil}) - _ = c.in.Close() - _ = c.cmd.Process.Kill() - _ = c.cmd.Wait() + c.mu.Unlock() + if err != nil { + return c.worker.failure("write", err) + } + return nil } +func (c *client) close() { c.worker.close() } -func readMessages(reader io.Reader, output chan<- rpcMessage) { +func readMessages(reader io.Reader, output chan<- rpcMessage, done <-chan struct{}) { defer close(output) buffered := bufio.NewReader(reader) for { @@ -1166,6 +1162,9 @@ func readMessages(reader io.Reader, output chan<- rpcMessage) { if length <= 0 { continue } + if length > 16<<20 { + return + } body := make([]byte, length) if _, err := io.ReadFull(buffered, body); err != nil { return @@ -1179,7 +1178,11 @@ func readMessages(reader io.Reader, output chan<- rpcMessage) { } if json.Unmarshal(body, &envelope) == nil { message = rpcMessage{ID: envelope.ID, Method: envelope.Method, Result: envelope.Result, Error: envelope.Error} - output <- message + select { + case output <- message: + case <-done: + return + } } } } diff --git a/internal/dartloader/worker.go b/internal/dartloader/worker.go new file mode 100644 index 00000000..c4d8a613 --- /dev/null +++ b/internal/dartloader/worker.go @@ -0,0 +1,123 @@ +package dartloader + +import ( + "context" + "fmt" + "io" + "os/exec" + "strings" + "sync" + "time" +) + +const stderrLimit = 16 << 10 + +type diagnosticTail struct { + mu sync.Mutex + bytes []byte +} + +func (b *diagnosticTail) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + n := len(p) + if n >= stderrLimit { + b.bytes = append(b.bytes[:0], p[n-stderrLimit:]...) + } else { + b.bytes = append(b.bytes, p...) + if len(b.bytes) > stderrLimit { + b.bytes = append([]byte(nil), b.bytes[len(b.bytes)-stderrLimit:]...) + } + } + return n, nil +} +func (b *diagnosticTail) text() string { + b.mu.Lock() + defer b.mu.Unlock() + return strings.TrimSpace(string(b.bytes)) +} + +// workerProcess owns one analyzer and observes Wait exactly once. The exit +// channel also releases protocol readers whose consumer has already failed. +type workerProcess struct { + ctx context.Context + cancel context.CancelFunc + cmd *exec.Cmd + in io.WriteCloser + stderr diagnosticTail + done chan struct{} + exit error + once sync.Once +} + +func launchWorker(ctx context.Context, executable string, args []string, root string) (*workerProcess, io.Reader, error) { + lifetime, cancel := context.WithCancel(ctx) + cmd := exec.CommandContext(lifetime, executable, args...) + cmd.Dir = root + cmd.WaitDelay = 250 * time.Millisecond + w := &workerProcess{ctx: ctx, cancel: cancel, cmd: cmd, done: make(chan struct{})} + cmd.Stderr = &w.stderr + in, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, nil, err + } + w.in = in + out, err := cmd.StdoutPipe() + if err != nil { + in.Close() + cancel() + return nil, nil, err + } + if err = cmd.Start(); err != nil { + in.Close() + out.Close() + cancel() + return nil, nil, err + } + go func() { w.exit = cmd.Wait(); close(w.done) }() + return w, out, nil +} + +func (w *workerProcess) failure(phase string, cause error) error { + if w == nil { + return fmt.Errorf("Dart analyzer %s: %w", phase, cause) + } + if err := w.ctx.Err(); err != nil { + return fmt.Errorf("Dart analyzer %s: %w", phase, err) + } + select { + case <-w.done: + return fmt.Errorf("Dart analyzer %s: %v; process exit: %v; stderr: %s", phase, cause, w.exit, w.stderr.text()) + case <-time.After(100 * time.Millisecond): + return fmt.Errorf("Dart analyzer %s: %w; stderr: %s", phase, cause, w.stderr.text()) + } +} + +func (w *workerProcess) stopped(phase string) error { + if w == nil { + return nil + } + select { + case <-w.done: + return w.failure(phase, fmt.Errorf("worker terminated")) + default: + return nil + } +} + +func (w *workerProcess) close() { + if w == nil { + return + } + w.once.Do(func() { + _ = w.in.Close() + select { + case <-w.done: + case <-time.After(250 * time.Millisecond): + w.cancel() + <-w.done + } + w.cancel() + }) +} diff --git a/internal/dartloader/worker_test.go b/internal/dartloader/worker_test.go new file mode 100644 index 00000000..3cf5d5d9 --- /dev/null +++ b/internal/dartloader/worker_test.go @@ -0,0 +1,75 @@ +package dartloader + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/Luqueee/kivgraph/internal/testsupport" +) + +func TestDartWorkerCrashHelper(t *testing.T) { + if os.Getenv("KIVGRAPH_DART_CRASH_FIXTURE") != "1" { + return + } + fmt.Fprint(os.Stderr, strings.Repeat("x", 20000)+"\nfixture analyzer crash\n") + os.Exit(23) +} + +func TestDartWorkerFailurePreservesExitAndBoundedStderr(t *testing.T) { + t.Setenv("KIVGRAPH_DART_CRASH_FIXTURE", "1") + command := os.Args[0] + " -test.run=^TestDartWorkerCrashHelper$" + for _, protocol := range []string{"lsp", "analyzer"} { + t.Run(protocol, func(t *testing.T) { + var err error + if protocol == "lsp" { + c, e := start(t.Context(), command, "", testsupport.TempDir(t)) + if e != nil { + t.Fatal(e) + } + defer c.close() + <-c.worker.done + err = c.notify("textDocument/didOpen", map[string]any{}) + } else { + c, e := startAnalyzer(t.Context(), command, "", testsupport.TempDir(t)) + if e != nil { + t.Fatal(e) + } + defer c.close() + <-c.worker.done + _, err = c.call(t.Context(), "analysis.getNavigation", map[string]any{}) + } + if err == nil || !strings.Contains(err.Error(), "exit status 23") || !strings.Contains(err.Error(), "fixture analyzer crash") || len(err.Error()) > 17000 { + t.Fatalf("missing or unbounded worker diagnosis: %v", err) + } + }) + } +} + +func TestDartWorkerCancellationKeepsCallerCause(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + w := &workerProcess{ctx: ctx, done: make(chan struct{})} + if err := w.failure("request", errors.New("broken pipe")); !errors.Is(err, context.Canceled) { + t.Fatal(err) + } +} + +func TestDartReaderStopsWhenNobodyDrainsNotifications(t *testing.T) { + done := make(chan struct{}) + finished := make(chan struct{}) + go func() { + readAnalyzerMessages(strings.NewReader(`{"event":"server.status"}`+"\n"), make(chan analyzerMessage), done) + close(finished) + }() + close(done) + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("reader leaked on a full output channel") + } +} From 58a69732572a7afda2cbeb9a109edcc1dc0cc8d7 Mon Sep 17 00:00:00 2001 From: Tutitoos Date: Sat, 5 Sep 2026 16:54:44 +0200 Subject: [PATCH 04/18] fix(cli): inspect the served profile in doctor Diagnose the default profile used by MCP without migrating or overwriting the legacy backup. Cover profile and legacy layouts. --- cmd/kivgraph/doctor_profile_test.go | 45 +++++++++++++++++++ cmd/kivgraph/main.go | 18 +++++++- .../src/content/docs/guides/maintenance.md | 3 ++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 cmd/kivgraph/doctor_profile_test.go diff --git a/cmd/kivgraph/doctor_profile_test.go b/cmd/kivgraph/doctor_profile_test.go new file mode 100644 index 00000000..85ef469f --- /dev/null +++ b/cmd/kivgraph/doctor_profile_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Luqueee/kivgraph/internal/config" + "github.com/Luqueee/kivgraph/internal/testsupport" +) + +func TestDoctorReadsServedProfileAndPreservesLegacyState(t *testing.T) { + home := t.TempDir() + testsupport.SetHome(t, home) + configPath := filepath.Join(home, "config.yaml") + if _, err := config.Initialize(config.InitOptions{ConfigPath: configPath}); err != nil { + t.Fatal(err) + } + legacy, err := config.Load(configPath) + if err != nil { + t.Fatal(err) + } + root := filepath.Dir(legacy.Config.Storage.DatabasePath) + profile, err := config.LoadProfile(configPath, "") + if err != nil { + t.Fatal(err) + } + // This abandoned legacy marker must never override the current profile. + marker := filepath.Join(root, "CURRENT") + if err := os.WriteFile(marker, []byte("invalid-legacy-generation\n"), 0o600); err != nil { + t.Fatal(err) + } + var out, diagnostic bytes.Buffer + if code := runDoctor([]string{"--config", configPath}, &out, &diagnostic); code != 0 { + t.Fatalf("doctor=%d\n%s\n%s", code, out.String(), diagnostic.String()) + } + if !strings.Contains(out.String(), filepath.Dir(profile.Config.Storage.DatabasePath)) || !strings.Contains(out.String(), "graph.store: PASS (no published generation)") { + t.Fatal(out.String()) + } + if body, err := os.ReadFile(marker); err != nil || string(body) != "invalid-legacy-generation\n" { + t.Fatalf("legacy state changed: %q %v", body, err) + } +} diff --git a/cmd/kivgraph/main.go b/cmd/kivgraph/main.go index 538f141d..718ff514 100644 --- a/cmd/kivgraph/main.go +++ b/cmd/kivgraph/main.go @@ -2063,6 +2063,22 @@ func doctorFlagSet(options *doctorOptions) *flag.FlagSet { return flags } +// Doctor follows the same default profile as serve, without initiating a +// migration during diagnosis. A legacy installation still has its old layout. +func readDoctorConfiguration(configPath string) (config.Loaded, error) { + loaded, err := config.Load(configPath) + if err != nil { + return config.Loaded{}, err + } + profiles := filepath.Join(filepath.Dir(loaded.Config.Storage.DatabasePath), "profiles") + if _, err := os.Stat(profiles); errors.Is(err, os.ErrNotExist) { + return loaded, nil + } else if err != nil { + return config.Loaded{}, fmt.Errorf("inspect profiles: %w", err) + } + return config.ReadProfile(configPath, "") +} + func runDoctor(args []string, stdout, stderr io.Writer) int { var options doctorOptions flags := doctorFlagSet(&options) @@ -2074,7 +2090,7 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { return 2 } - loaded, err := config.Load(options.ConfigPath) + loaded, err := readDoctorConfiguration(options.ConfigPath) if err != nil { writeResult(stdout, false, "config: FAIL (%v)", err) writeResult(stdout, false, "doctor: FAIL") diff --git a/landing/src/content/docs/guides/maintenance.md b/landing/src/content/docs/guides/maintenance.md index 55ea0378..cc986659 100644 --- a/landing/src/content/docs/guides/maintenance.md +++ b/landing/src/content/docs/guides/maintenance.md @@ -17,6 +17,9 @@ whether a repository can be indexed. It also checks `cargo` separately: the bundle carries `rust-analyzer` but no Rust toolchain, and the analyzer cannot load a workspace without cargo. +On installations with profiles, `doctor` inspects the default profile served +by MCP. It preserves the legacy backup and does not initiate a migration. + `graph status` prints `graph.active`, `graph.next` and `graph.backup` with the path each names on disk, plus the full list of retained generations. A store with no active generation reports `graph.active: none`; that is not an error. From e4cbc2a24eb11ed5aef38d47ae80872a99563f04 Mon Sep 17 00:00:00 2001 From: Tutitoos Date: Sat, 5 Sep 2026 16:54:44 +0200 Subject: [PATCH 05/18] fix(integrations): scope system application detection Resolve system application markers from the configured root so isolated client detection does not inherit applications installed on the test host. --- internal/integrations/hooks.go | 6 +++--- internal/integrations/integrations.go | 8 ++++++++ internal/integrations/integrations_test.go | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/integrations/hooks.go b/internal/integrations/hooks.go index 05795155..7285a62f 100644 --- a/internal/integrations/hooks.go +++ b/internal/integrations/hooks.go @@ -242,7 +242,7 @@ func (manager Manager) claudeDesktopMarkers() []string { if manager.goos == "darwin" { return []string{ filepath.Join(manager.homeDir, "Applications", "Claude.app"), - "/Applications/Claude.app", + filepath.Join(manager.systemRoot, "Applications", "Claude.app"), } } if manager.goos == "windows" { @@ -273,9 +273,9 @@ func (manager Manager) claudeDesktopMarkers() []string { } return []string{ filepath.Join(manager.homeDir, ".local", "share", "applications", "com.anthropic.Claude.desktop"), - "/usr/share/applications/com.anthropic.Claude.desktop", + filepath.Join(manager.systemRoot, "usr", "share", "applications", "com.anthropic.Claude.desktop"), filepath.Join(manager.homeDir, ".local", "share", "applications", "claude.desktop"), - "/usr/share/applications/claude.desktop", + filepath.Join(manager.systemRoot, "usr", "share", "applications", "claude.desktop"), } } diff --git a/internal/integrations/integrations.go b/internal/integrations/integrations.go index 69a5a92c..18c8b21a 100644 --- a/internal/integrations/integrations.go +++ b/internal/integrations/integrations.go @@ -100,6 +100,8 @@ func (endpoint Endpoint) validate() error { // real client configuration. Client-specific environment overrides remain // effective unless their corresponding explicit option is set. type Options struct { + // SystemRoot scopes read-only system application detection; empty uses /. + SystemRoot string HomeDir string ProjectDir string Executable string @@ -120,6 +122,7 @@ type Options struct { // Manager applies integration plans for one local user and one project. type Manager struct { + systemRoot string homeDir string projectDir string executable string @@ -308,7 +311,12 @@ func New(options Options) (Manager, error) { if err != nil { return Manager{}, err } + systemRoot := options.SystemRoot + if systemRoot == "" { + systemRoot = string(filepath.Separator) + } return Manager{ + systemRoot: systemRoot, homeDir: homeDir, projectDir: projectDir, executable: executable, diff --git a/internal/integrations/integrations_test.go b/internal/integrations/integrations_test.go index f8e8f9ce..06e4ba08 100644 --- a/internal/integrations/integrations_test.go +++ b/internal/integrations/integrations_test.go @@ -18,6 +18,7 @@ func testManager(t *testing.T) (Manager, string, string) { project := t.TempDir() manager, err := New(Options{ HomeDir: home, + SystemRoot: filepath.Join(home, "system"), ProjectDir: project, Executable: testsupport.InstalledExecutable(), GOOS: "darwin", From 5376e0a1d322a126332427875d63fe69ef0ed5b4 Mon Sep 17 00:00:00 2001 From: Tutitoos Date: Sat, 5 Sep 2026 16:54:44 +0200 Subject: [PATCH 06/18] feat(facts): preserve typed implementation evidence Accept ts-facts-v5 declared and structural evidence without changing canonical identities. Keep legacy v4 input readable with incomplete coverage. Schema 5 requires a full rebuild and a matching executable for rollback. --- docs/adr/0095-typed-implementation-queries.md | 58 + docs/storage/canonical-schema.md | 2 +- internal/facts/codes.go | 62 +- internal/facts/codes_test.go | 1 + internal/facts/facts.go | 10 +- internal/facts/typescript.go | 70 +- internal/facts/typescript_test.go | 46 + .../storage/ladybug/canonical_integrity.go | 2 + internal/storage/ladybug/canonical_schema.go | 4 +- .../storage/ladybug/canonical_schema_test.go | 2 +- schemas/ladybug/005-canonical.cypher | 286 ++++ .../protocol/ts-facts-v5/implementations.json | 1314 +++++++++++++++++ 12 files changed, 1804 insertions(+), 53 deletions(-) create mode 100644 docs/adr/0095-typed-implementation-queries.md create mode 100644 schemas/ladybug/005-canonical.cypher create mode 100644 testdata/protocol/ts-facts-v5/implementations.json diff --git a/docs/adr/0095-typed-implementation-queries.md b/docs/adr/0095-typed-implementation-queries.md new file mode 100644 index 00000000..0f16bfe7 --- /dev/null +++ b/docs/adr/0095-typed-implementation-queries.md @@ -0,0 +1,58 @@ +# ADR 0095: Typed implementation queries and coverage + +Status: accepted for local implementation, 2026-09-05. + +## Decision + +Expose `find_implementations` as a read-only, single-text MCP query. It reads +IMPLEMENTS and OVERRIDES edges of one immutable published generation. It does +not reinterpret calls or name matches as implementations. Go retains its +existing `go/types` proofs. TypeScript emits declared and structural proofs +at index time through the installed native compiler's `isTypeAssignableTo`. + +The worker examines concrete class instance types, interfaces, abstract types, +object type aliases, and concrete type instances observed in type references, +heritage clauses and construction. It never substitutes `any` for unknown type +parameters. Required member names are a conservative prefilter; the compiler +makes the final decision. Native calls are serial, and property/assignability +caches are confined to a project generation. Tests compare this selection to +an exhaustive compiler evaluation. + +Interface method/property declarations now use the same classifier as provider +source lookup. Inherited implementation methods point to the actual declaring +symbol. External type identities reuse resolved imports. External method +identities require the provider's own project and canonical source declaration; +missing source identity is recorded as a coverage limitation. + +## Wire and storage + +`ts-facts-v5` adds `implementations` and `implementationLimitations`. Its relation +rows reuse the proven local/provider target identity shape. The Go decoder also +reads v4 for historical fixtures, with an explicit missing-analysis scope. +Provenance codes 30 and 31 append `TYPESCRIPT_IMPL_DECLARED` and +`TYPESCRIPT_IMPL_STRUCTURAL`; existing codes and stable-key derivation do not +change. Canonical schema 5 requires a full rebuild and attests the new analysis +pass. The previous database and matching executable remain the rollback pair. + +Query pages carry canonical locations, provenance, detection, confidence, +generation, total, cursor and completeness. Cursors bind the query, filters, +profiles and generation. Result path filters run before counting/pagination. +Multi-profile queries retain independently indexed scopes. Legacy generations +return LOWER_BOUND, including when empty. New generations also return +LOWER_BOUND for recorded unresolved scopes across the analyzed corpus. An empty +COMPLETE result establishes absence only within that corpus and type-instance +universe. Inferred files currently contribute symbols/references and explicitly +do not attest implementation coverage. Compiler-error declarations and unknown +provider method identities cannot become exact edges. + +Atenea binds its existing `symbol.implementations` contract to this tool, +preserves local `locations`, and adds optional evidence and pagination fields. +Atenea's maintenance coordinator owns rebuilding; a normal query cannot inherit +an indexing timeout. + +## Validation and activation + +Negative type fixtures, declared/structural/method/generic fixtures, actual v5 +worker output, canonical normalization, scoped pagination and generation/filter +cursor rejection are required. The complete native Ladybug gate and a real +local full rebuild must pass before activation is considered validated. diff --git a/docs/storage/canonical-schema.md b/docs/storage/canonical-schema.md index f4756781..c46e4add 100644 --- a/docs/storage/canonical-schema.md +++ b/docs/storage/canonical-schema.md @@ -1,6 +1,6 @@ # Esquema canónico de LadybugDB -Versión del esquema: `004`. DDL versionado: `schemas/ladybug/004-canonical.cypher`. +Versión del esquema: `005`. DDL versionado: `schemas/ladybug/005-canonical.cypher`. Este documento se genera desde `internal/storage/ladybug.CanonicalSchemaDocumentation`; no se edita a mano. diff --git a/internal/facts/codes.go b/internal/facts/codes.go index e5dcf281..2d0d84fa 100644 --- a/internal/facts/codes.go +++ b/internal/facts/codes.go @@ -91,6 +91,8 @@ const ( CodeJavaScipUse CodeCSharpScipDefinition CodeCSharpScipUse + CodeTypeScriptImplementationDeclared + CodeTypeScriptImplementationStructural ) var edgeKindCodes = map[EdgeKind]uint8{ @@ -126,35 +128,37 @@ var confidenceCodes = map[Confidence]uint8{ } var provenanceCodes = map[Provenance]uint8{ - TypeScriptChecker: CodeTypeScriptChecker, - TypeScriptModuleResolution: CodeTypeScriptModuleResolution, - TypeScriptDeclarationMap: CodeTypeScriptDeclarationMap, - TypeScriptProjectReference: CodeTypeScriptProjectReference, - GoTypesDefinition: CodeGoTypesDefinition, - GoTypesUse: CodeGoTypesUse, - GoTypesSelection: CodeGoTypesSelection, - GoASTCall: CodeGoASTCall, - GoASTCallback: CodeGoASTCallback, - GoObjectPath: CodeGoObjectPath, - TreeSitterSyntax: CodeTreeSitterSyntax, - PackageManifest: CodePackageManifest, - RustAnalyzerDefinition: CodeRustAnalyzerDefinition, - RustAnalyzerUse: CodeRustAnalyzerUse, - RustAnalyzerMoniker: CodeRustAnalyzerMoniker, - RustSyntaxCall: CodeRustSyntaxCall, - RustSyntaxType: CodeRustSyntaxType, - RustSyntaxImplementation: CodeRustSyntaxImplementation, - RustSyntaxCallback: CodeRustSyntaxCallback, - PythonIndexerDefinition: CodePythonIndexerDefinition, - PythonIndexerUse: CodePythonIndexerUse, - PythonSyntaxCall: CodePythonSyntaxCall, - DartAnalyzerDefinition: CodeDartAnalyzerDefinition, - DartAnalyzerUse: CodeDartAnalyzerUse, - DartSyntaxCall: CodeDartSyntaxCall, - JavaScipDefinition: CodeJavaScipDefinition, - JavaScipUse: CodeJavaScipUse, - CSharpScipDefinition: CodeCSharpScipDefinition, - CSharpScipUse: CodeCSharpScipUse, + TypeScriptImplementationDeclared: CodeTypeScriptImplementationDeclared, + TypeScriptImplementationStructural: CodeTypeScriptImplementationStructural, + TypeScriptChecker: CodeTypeScriptChecker, + TypeScriptModuleResolution: CodeTypeScriptModuleResolution, + TypeScriptDeclarationMap: CodeTypeScriptDeclarationMap, + TypeScriptProjectReference: CodeTypeScriptProjectReference, + GoTypesDefinition: CodeGoTypesDefinition, + GoTypesUse: CodeGoTypesUse, + GoTypesSelection: CodeGoTypesSelection, + GoASTCall: CodeGoASTCall, + GoASTCallback: CodeGoASTCallback, + GoObjectPath: CodeGoObjectPath, + TreeSitterSyntax: CodeTreeSitterSyntax, + PackageManifest: CodePackageManifest, + RustAnalyzerDefinition: CodeRustAnalyzerDefinition, + RustAnalyzerUse: CodeRustAnalyzerUse, + RustAnalyzerMoniker: CodeRustAnalyzerMoniker, + RustSyntaxCall: CodeRustSyntaxCall, + RustSyntaxType: CodeRustSyntaxType, + RustSyntaxImplementation: CodeRustSyntaxImplementation, + RustSyntaxCallback: CodeRustSyntaxCallback, + PythonIndexerDefinition: CodePythonIndexerDefinition, + PythonIndexerUse: CodePythonIndexerUse, + PythonSyntaxCall: CodePythonSyntaxCall, + DartAnalyzerDefinition: CodeDartAnalyzerDefinition, + DartAnalyzerUse: CodeDartAnalyzerUse, + DartSyntaxCall: CodeDartSyntaxCall, + JavaScipDefinition: CodeJavaScipDefinition, + JavaScipUse: CodeJavaScipUse, + CSharpScipDefinition: CodeCSharpScipDefinition, + CSharpScipUse: CodeCSharpScipUse, } // reverse builds the decoding table of a coding table. The tables are diff --git a/internal/facts/codes_test.go b/internal/facts/codes_test.go index f85be0ed..3e797b7e 100644 --- a/internal/facts/codes_test.go +++ b/internal/facts/codes_test.go @@ -48,6 +48,7 @@ var goldenCodes = struct { "DART_ANALYZER_USE": 24, "DART_SYNTAX_CALL": 25, "JAVA_SCIP_DEF": 26, "JAVA_SCIP_USE": 27, "CSHARP_SCIP_DEF": 28, "CSHARP_SCIP_USE": 29, + "TYPESCRIPT_IMPL_DECLARED": 30, "TYPESCRIPT_IMPL_STRUCTURAL": 31, }, } diff --git a/internal/facts/facts.go b/internal/facts/facts.go index 3f1efd13..40ac010b 100644 --- a/internal/facts/facts.go +++ b/internal/facts/facts.go @@ -70,10 +70,12 @@ func (confidence Confidence) Exact() bool { type Provenance string const ( - TypeScriptChecker Provenance = "TYPESCRIPT_CHECKER" - TypeScriptModuleResolution Provenance = "TYPESCRIPT_MODULE_RESOLUTION" - TypeScriptDeclarationMap Provenance = "TYPESCRIPT_DECLARATION_MAP" - TypeScriptProjectReference Provenance = "TYPESCRIPT_PROJECT_REFERENCE" + TypeScriptImplementationDeclared Provenance = "TYPESCRIPT_IMPL_DECLARED" + TypeScriptImplementationStructural Provenance = "TYPESCRIPT_IMPL_STRUCTURAL" + TypeScriptChecker Provenance = "TYPESCRIPT_CHECKER" + TypeScriptModuleResolution Provenance = "TYPESCRIPT_MODULE_RESOLUTION" + TypeScriptDeclarationMap Provenance = "TYPESCRIPT_DECLARATION_MAP" + TypeScriptProjectReference Provenance = "TYPESCRIPT_PROJECT_REFERENCE" GoTypesDefinition Provenance = "GO_TYPES_DEF" GoTypesUse Provenance = "GO_TYPES_USE" diff --git a/internal/facts/typescript.go b/internal/facts/typescript.go index 8de34ecb..8e85857a 100644 --- a/internal/facts/typescript.go +++ b/internal/facts/typescript.go @@ -11,26 +11,34 @@ import ( "github.com/Luqueee/kivgraph/internal/workspace" ) -// TypeScriptWireVersion is the version of the `ts-facts-v4` payload. -const TypeScriptWireVersion = 4 +// TypeScriptWireVersion is the version of the `ts-facts-v5` payload. +const TypeScriptWireVersion = 5 // TypeScriptPayload is the fact payload the worker emits for one repository. // // The worker reports identity components and positions; it never computes a // key. Deriving keys on a single side is what keeps one symbol from getting // two identities when a consumer and its provider are indexed separately. +type TypeScriptImplementation struct { + TypeScriptExtends + Detection string `json:"detection"` + Relation EdgeKind `json:"relation"` +} + type TypeScriptPayload struct { - Version int `json:"version"` - Repository TypeScriptRepository `json:"repository"` - Package *TypeScriptPackage `json:"package"` - Files []string `json:"files"` - Symbols []TypeScriptSymbol `json:"symbols"` - References []TypeScriptReference `json:"references"` - Imports []TypeScriptImport `json:"imports"` - Exports []TypeScriptExport `json:"exports"` - Extends []TypeScriptExtends `json:"extends"` - Dependencies []TypeScriptDependency `json:"dependencies"` - Unresolved []TypeScriptUnresolved `json:"unresolved"` + Version int `json:"version"` + Repository TypeScriptRepository `json:"repository"` + Package *TypeScriptPackage `json:"package"` + Files []string `json:"files"` + Symbols []TypeScriptSymbol `json:"symbols"` + References []TypeScriptReference `json:"references"` + Imports []TypeScriptImport `json:"imports"` + Exports []TypeScriptExport `json:"exports"` + Extends []TypeScriptExtends `json:"extends"` + Implementations []TypeScriptImplementation `json:"implementations"` + ImplementationLimitations []string `json:"implementationLimitations"` + Dependencies []TypeScriptDependency `json:"dependencies"` + Unresolved []TypeScriptUnresolved `json:"unresolved"` } // TypeScriptRepository names the repository the payload belongs to. @@ -300,13 +308,13 @@ func escapesRepository(file string) bool { return cleaned == ".." || strings.HasPrefix(cleaned, "../") } -// DecodeTypeScriptPayload parses a `ts-facts-v4` document. +// DecodeTypeScriptPayload parses a `ts-facts-v5` document. func DecodeTypeScriptPayload(data []byte) (TypeScriptPayload, error) { var payload TypeScriptPayload if err := json.Unmarshal(data, &payload); err != nil { return TypeScriptPayload{}, fmt.Errorf("decode typescript facts: %w", err) } - if payload.Version != TypeScriptWireVersion { + if payload.Version != TypeScriptWireVersion && payload.Version != 4 { return TypeScriptPayload{}, fmt.Errorf("%w: unsupported typescript facts version %d", ErrInvalidFacts, payload.Version) } @@ -742,7 +750,23 @@ func NormalizeTypeScript( }) } + type relationFact struct { + TypeScriptExtends + kind EdgeKind + detection string + } + relations := make([]relationFact, 0, len(payload.Extends)+len(payload.Implementations)) for _, ext := range payload.Extends { + relations = append(relations, relationFact{ext, Extends, ""}) + } + for _, impl := range payload.Implementations { + if (impl.Relation != Implements && impl.Relation != Overrides) || (impl.Detection != "declared" && impl.Detection != "structural") { + return Set{}, TypeScriptReport{}, fmt.Errorf("%w: invalid implementation relation or detection", ErrInvalidFacts) + } + relations = append(relations, relationFact{impl.TypeScriptExtends, impl.Relation, impl.Detection}) + } + for _, relation := range relations { + ext := relation.TypeScriptExtends if err := ctx.Err(); err != nil { return Set{}, TypeScriptReport{}, err } @@ -814,12 +838,18 @@ func NormalizeTypeScript( Text: ext.Text, } set.Evidence = append(set.Evidence, evidence) + if relation.detection == "declared" { + provenance = TypeScriptImplementationDeclared + } + if relation.detection == "structural" { + provenance = TypeScriptImplementationStructural + } // TargetKey names a symbol the PROVIDER repository normalises when // the base crosses repositories: this Set alone will fail // Validate() with a dangling edge until the caller merges the // provider's Set in, exactly like an IMPORTS_SYMBOL edge. set.Edges = append(set.Edges, Edge{ - Kind: Extends, + Kind: relation.kind, SourceKey: sourceKey, TargetKey: targetKey, Confidence: confidence, @@ -828,6 +858,14 @@ func NormalizeTypeScript( }) } + limits := append([]string(nil), payload.ImplementationLimitations...) + if payload.Version < 5 { + limits = append(limits, "Legacy TypeScript worker did not analyze implementation relations; rebuild with ts-facts-v5.") + } + for _, detail := range limits { + set.Unresolved = append(set.Unresolved, UnresolvedReference{RepositoryKey: repositoryKey, Language: LanguageTypeScript, Reason: "IMPLEMENTATION_COVERAGE_PARTIAL", Detail: detail, RequestedPackage: payload.Package.Name}) + } + // PACKAGE_DEPENDS_ON needs no symbol lookup at all: both ends are the // package keys the payload and the provider registry already name. // TypeScript has no module concept distinct from its package, so this diff --git a/internal/facts/typescript_test.go b/internal/facts/typescript_test.go index ff944bb8..45813091 100644 --- a/internal/facts/typescript_test.go +++ b/internal/facts/typescript_test.go @@ -1351,3 +1351,49 @@ func TestEscapesRepositoryReadsThePathAndNotItsSpelling(t *testing.T) { } } } + +func TestNormalizeTypeScriptImplementationEvidence(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "protocol", "ts-facts-v5", "implementations.json")) + if err != nil { + t.Fatal(err) + } + payload, err := DecodeTypeScriptPayload(data) + if err != nil { + t.Fatal(err) + } + set, _, err := NormalizeTypeScript(t.Context(), payload, workspace.Repository{RealPath: "/fixtures/implementations"}) + if err != nil { + t.Fatal(err) + } + if err := set.Validate(); err != nil { + t.Fatal(err) + } + names := map[string]string{} + for _, symbol := range set.Symbols { + names[symbol.Key] = symbol.QualifiedName + } + edges := map[string]Provenance{} + for _, edge := range set.Edges { + if edge.Kind == Implements || edge.Kind == Overrides { + if !edge.Confidence.Exact() || edge.EvidenceKey == "" { + t.Fatalf("unproven relationship: %#v", edge) + } + edges[names[edge.SourceKey]+"->"+names[edge.TargetKey]] = edge.Provenance + } + } + for pair, provenance := range map[string]Provenance{"Declared->Reader": TypeScriptImplementationDeclared, "Structural->Reader": TypeScriptImplementationStructural, "Declared.read->Reader.read": TypeScriptImplementationDeclared, "Generic->TextBox": TypeScriptImplementationStructural, "Concrete.read->Abstract.read": TypeScriptImplementationDeclared} { + if edges[pair] != provenance { + t.Errorf("%s provenance=%s want=%s", pair, edges[pair], provenance) + } + } + if _, exists := edges["Wrong->Reader"]; exists { + t.Fatal("incompatible types connected") + } + if len(payload.Implementations) == 0 { + t.Fatal("worker emitted no implementations") + } + payload.Implementations[0].Detection = "guessed" + if _, _, err := NormalizeTypeScript(t.Context(), payload, workspace.Repository{RealPath: "/fixtures/implementations"}); err == nil { + t.Fatal("unknown implementation evidence accepted") + } +} diff --git a/internal/storage/ladybug/canonical_integrity.go b/internal/storage/ladybug/canonical_integrity.go index 9eec2bae..bac26f4c 100644 --- a/internal/storage/ladybug/canonical_integrity.go +++ b/internal/storage/ladybug/canonical_integrity.go @@ -146,6 +146,8 @@ var canonicalProvenanceValues = []facts.Provenance{ facts.JavaScipUse, facts.CSharpScipDefinition, facts.CSharpScipUse, + facts.TypeScriptImplementationDeclared, + facts.TypeScriptImplementationStructural, } // exactConfidenceValues is the subset of canonicalConfidenceValues that diff --git a/internal/storage/ladybug/canonical_schema.go b/internal/storage/ladybug/canonical_schema.go index 31730c30..b941a4e3 100644 --- a/internal/storage/ladybug/canonical_schema.go +++ b/internal/storage/ladybug/canonical_schema.go @@ -7,10 +7,10 @@ import ( // CanonicalSchemaVersion is the version of the definitive graph schema. It is // stored in the database so a rebuild can detect an incompatible layout. -const CanonicalSchemaVersion = 4 +const CanonicalSchemaVersion = 5 // CanonicalSchemaFile is the versioned DDL generated from this metadata. -const CanonicalSchemaFile = "schemas/ladybug/004-canonical.cypher" +const CanonicalSchemaFile = "schemas/ladybug/005-canonical.cypher" // SchemaProperty is one column of a node or relationship table. type SchemaProperty struct { diff --git a/internal/storage/ladybug/canonical_schema_test.go b/internal/storage/ladybug/canonical_schema_test.go index 4fd76b4e..2d437829 100644 --- a/internal/storage/ladybug/canonical_schema_test.go +++ b/internal/storage/ladybug/canonical_schema_test.go @@ -12,7 +12,7 @@ import ( // TestCanonicalSchemaFileMatchesTheMetadata keeps the versioned DDL and the Go // metadata from drifting: the file is generated, never hand edited. func TestCanonicalSchemaFileMatchesTheMetadata(t *testing.T) { - path := filepath.Join("..", "..", "..", "schemas", "ladybug", "004-canonical.cypher") + path := filepath.Join("..", "..", "..", "schemas", "ladybug", "005-canonical.cypher") contents, err := os.ReadFile(path) if err != nil { t.Fatalf("read canonical schema: %v", err) diff --git a/schemas/ladybug/005-canonical.cypher b/schemas/ladybug/005-canonical.cypher new file mode 100644 index 00000000..203cc686 --- /dev/null +++ b/schemas/ladybug/005-canonical.cypher @@ -0,0 +1,286 @@ +// Kivgraph canonical graph schema, version 005. +// Generated from internal/storage/ladybug.CanonicalSchemaStatements. +// Every primary key is a durable Kivgraph key; no key is inferred from a +// display name and none is generated by the database. + +CREATE NODE TABLE IF NOT EXISTS GraphMetadata( + key STRING PRIMARY KEY, + value STRING +); + +CREATE NODE TABLE IF NOT EXISTS Repository( + stable_key STRING PRIMARY KEY, + name STRING, + root_path STRING, + commit STRING, + branch STRING, + dirty BOOL, + languages STRING +); + +CREATE NODE TABLE IF NOT EXISTS Package( + stable_key STRING PRIMARY KEY, + repository_key STRING, + language STRING, + name STRING, + version STRING, + root_path STRING, + manifest_path STRING, + container STRING +); + +CREATE NODE TABLE IF NOT EXISTS File( + stable_key STRING PRIMARY KEY, + repository_key STRING, + package_key STRING, + path STRING, + language STRING, + content_hash STRING, + generated BOOL +); + +CREATE NODE TABLE IF NOT EXISTS Symbol( + stable_key STRING PRIMARY KEY, + canonical_identity STRING, + repository_key STRING, + package_key STRING, + file_key STRING, + language STRING, + name STRING, + qualified_name STRING, + kind STRING, + exported BOOL, + signature STRING, + start_line INT64, + start_column INT64, + start_offset INT64, + end_line INT64, + end_offset INT64 +); + +CREATE NODE TABLE IF NOT EXISTS Evidence( + stable_key STRING PRIMARY KEY, + repository_key STRING, + file_key STRING, + start_line INT64, + start_column INT64, + start_offset INT64, + end_offset INT64, + text STRING +); + +CREATE NODE TABLE IF NOT EXISTS UnresolvedReference( + stable_key STRING PRIMARY KEY, + repository_key STRING, + file_key STRING, + language STRING, + source_symbol_key STRING, + requested_package STRING, + requested_symbol STRING, + reason STRING, + detail STRING, + start_line INT64, + start_column INT64, + start_offset INT64 +); + +CREATE REL TABLE IF NOT EXISTS CONTAINS_PACKAGE( + FROM Repository TO Package, + confidence STRING, + provenance STRING, + ONE_MANY +); + +CREATE REL TABLE IF NOT EXISTS CONTAINS_FILE( + FROM Package TO File, + confidence STRING, + provenance STRING, + ONE_MANY +); + +CREATE REL TABLE IF NOT EXISTS DEFINES( + FROM File TO Symbol, + confidence STRING, + provenance STRING, + ONE_MANY +); + +CREATE REL TABLE IF NOT EXISTS OBSERVED_IN( + FROM Evidence TO File, + MANY_ONE +); + +CREATE REL TABLE IF NOT EXISTS REPORTS_UNRESOLVED( + FROM Repository TO UnresolvedReference, + ONE_MANY +); + +CREATE REL TABLE IF NOT EXISTS PACKAGE_DEPENDS_ON( + FROM Package TO Package, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS MODULE_DEPENDS_ON( + FROM Package TO Package, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS IMPORTS_SYMBOL( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS EXPORTS( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS REEXPORTS( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS REFERENCES( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS CALLS_DIRECT( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS PASSES_AS_CALLBACK( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS ASSIGNS_FUNCTION( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS RETURNS_FUNCTION( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS TYPE_USES( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS IMPLEMENTS( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS EXTENDS( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS EMBEDS( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS OVERRIDES( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS PART_OF( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); + +CREATE REL TABLE IF NOT EXISTS METHOD_OF( + FROM Symbol TO Symbol, + confidence STRING, + provenance STRING, + evidence_key STRING, + source_snapshot INT64, + resolver_version STRING, + MANY_MANY +); diff --git a/testdata/protocol/ts-facts-v5/implementations.json b/testdata/protocol/ts-facts-v5/implementations.json new file mode 100644 index 00000000..1b136f24 --- /dev/null +++ b/testdata/protocol/ts-facts-v5/implementations.json @@ -0,0 +1,1314 @@ +{ + "version": 5, + "repository": { + "name": "implementations" + }, + "package": { + "name": "@fixture/implementations", + "version": "1.0.0", + "rootPath": ".", + "manifestPath": "package.json" + }, + "files": [ + "contracts.ts", + "implementations.ts" + ], + "symbols": [ + { + "file": "contracts.ts", + "name": "Reader", + "qualifiedName": "Reader", + "kind": "interface", + "exported": true, + "signature": "export interface Reader", + "startLine": 1, + "endLine": 1, + "start": 0, + "end": 43 + }, + { + "file": "contracts.ts", + "name": "read", + "qualifiedName": "Reader.read", + "kind": "method", + "exported": false, + "signature": "read(): string;", + "startLine": 1, + "endLine": 1, + "start": 26, + "end": 41 + }, + { + "file": "contracts.ts", + "name": "NamedReader", + "qualifiedName": "NamedReader", + "kind": "interface", + "exported": true, + "signature": "export interface NamedReader extends Reader", + "startLine": 2, + "endLine": 2, + "start": 44, + "end": 105 + }, + { + "file": "contracts.ts", + "name": "name", + "qualifiedName": "NamedReader.name", + "kind": "property", + "exported": false, + "signature": "name: string;", + "startLine": 2, + "endLine": 2, + "start": 90, + "end": 103 + }, + { + "file": "contracts.ts", + "name": "Box", + "qualifiedName": "Box", + "kind": "interface", + "exported": true, + "signature": "export interface Box", + "startLine": 3, + "endLine": 3, + "start": 106, + "end": 143 + }, + { + "file": "contracts.ts", + "name": "T", + "qualifiedName": "Box.T", + "kind": "type_parameter", + "exported": false, + "signature": "T", + "startLine": 3, + "endLine": 3, + "start": 127, + "end": 128 + }, + { + "file": "contracts.ts", + "name": "get", + "qualifiedName": "Box.get", + "kind": "method", + "exported": false, + "signature": "get(): T;", + "startLine": 3, + "endLine": 3, + "start": 132, + "end": 141 + }, + { + "file": "contracts.ts", + "name": "TextBox", + "qualifiedName": "TextBox", + "kind": "type", + "exported": true, + "signature": "export type TextBox = Box;", + "startLine": 4, + "endLine": 4, + "start": 144, + "end": 178 + }, + { + "file": "implementations.ts", + "name": "Declared", + "qualifiedName": "Declared", + "kind": "class", + "exported": true, + "signature": "export class Declared implements Readable", + "startLine": 2, + "endLine": 2, + "start": 76, + "end": 156 + }, + { + "file": "implementations.ts", + "name": "read", + "qualifiedName": "Declared.read", + "kind": "method", + "exported": false, + "signature": "read(): string", + "startLine": 2, + "endLine": 2, + "start": 120, + "end": 154 + }, + { + "file": "implementations.ts", + "name": "Structural", + "qualifiedName": "Structural", + "kind": "class", + "exported": true, + "signature": "export class Structural", + "startLine": 3, + "endLine": 3, + "start": 157, + "end": 219 + }, + { + "file": "implementations.ts", + "name": "read", + "qualifiedName": "Structural.read", + "kind": "method", + "exported": false, + "signature": "read(): string", + "startLine": 3, + "endLine": 3, + "start": 183, + "end": 217 + }, + { + "file": "implementations.ts", + "name": "Inherited", + "qualifiedName": "Inherited", + "kind": "class", + "exported": true, + "signature": "export class Inherited extends Structural", + "startLine": 4, + "endLine": 4, + "start": 220, + "end": 281 + }, + { + "file": "implementations.ts", + "name": "name", + "qualifiedName": "Inherited.name", + "kind": "property", + "exported": false, + "signature": "name = 'named';", + "startLine": 4, + "endLine": 4, + "start": 264, + "end": 279 + }, + { + "file": "implementations.ts", + "name": "Wrong", + "qualifiedName": "Wrong", + "kind": "class", + "exported": true, + "signature": "export class Wrong", + "startLine": 5, + "endLine": 5, + "start": 282, + "end": 333 + }, + { + "file": "implementations.ts", + "name": "read", + "qualifiedName": "Wrong.read", + "kind": "method", + "exported": false, + "signature": "read(): number", + "startLine": 5, + "endLine": 5, + "start": 303, + "end": 331 + }, + { + "file": "implementations.ts", + "name": "StringBox", + "qualifiedName": "StringBox", + "kind": "class", + "exported": true, + "signature": "export class StringBox implements Box", + "startLine": 6, + "endLine": 6, + "start": 334, + "end": 412 + }, + { + "file": "implementations.ts", + "name": "get", + "qualifiedName": "StringBox.get", + "kind": "method", + "exported": false, + "signature": "get(): string", + "startLine": 6, + "endLine": 6, + "start": 382, + "end": 410 + }, + { + "file": "implementations.ts", + "name": "Generic", + "qualifiedName": "Generic", + "kind": "class", + "exported": true, + "signature": "export class Generic", + "startLine": 7, + "endLine": 7, + "start": 413, + "end": 505 + }, + { + "file": "implementations.ts", + "name": "T", + "qualifiedName": "Generic.T", + "kind": "type_parameter", + "exported": false, + "signature": "T", + "startLine": 7, + "endLine": 7, + "start": 434, + "end": 435 + }, + { + "file": "implementations.ts", + "name": "value", + "qualifiedName": "Generic.value", + "kind": "parameter", + "exported": false, + "signature": "private value: T", + "startLine": 7, + "endLine": 7, + "start": 451, + "end": 467 + }, + { + "file": "implementations.ts", + "name": "get", + "qualifiedName": "Generic.get", + "kind": "method", + "exported": false, + "signature": "get(): T", + "startLine": 7, + "endLine": 7, + "start": 472, + "end": 503 + }, + { + "file": "implementations.ts", + "name": "instance", + "qualifiedName": "instance", + "kind": "variable", + "exported": true, + "signature": "instance", + "startLine": 8, + "endLine": 8, + "start": 519, + "end": 558 + }, + { + "file": "implementations.ts", + "name": "Abstract", + "qualifiedName": "Abstract", + "kind": "class", + "exported": true, + "signature": "export abstract class Abstract implements Readable", + "startLine": 9, + "endLine": 9, + "start": 560, + "end": 639 + }, + { + "file": "implementations.ts", + "name": "read", + "qualifiedName": "Abstract.read", + "kind": "method", + "exported": false, + "signature": "abstract read(): string;", + "startLine": 9, + "endLine": 9, + "start": 613, + "end": 637 + }, + { + "file": "implementations.ts", + "name": "Concrete", + "qualifiedName": "Concrete", + "kind": "class", + "exported": true, + "signature": "export class Concrete extends Abstract", + "startLine": 10, + "endLine": 10, + "start": 640, + "end": 712 + }, + { + "file": "implementations.ts", + "name": "read", + "qualifiedName": "Concrete.read", + "kind": "method", + "exported": false, + "signature": "read(): string", + "startLine": 10, + "endLine": 10, + "start": 681, + "end": 710 + }, + { + "file": "implementations.ts", + "name": "named", + "qualifiedName": "named", + "kind": "variable", + "exported": true, + "signature": "named: NamedReader", + "startLine": 11, + "endLine": 11, + "start": 726, + "end": 762 + }, + { + "file": "contracts.ts", + "name": "Reader", + "qualifiedName": "Reader#2", + "kind": "export", + "exported": true, + "signature": "Reader", + "startLine": 1, + "endLine": 1, + "start": 17, + "end": 23 + }, + { + "file": "contracts.ts", + "name": "NamedReader", + "qualifiedName": "NamedReader#2", + "kind": "export", + "exported": true, + "signature": "NamedReader", + "startLine": 2, + "endLine": 2, + "start": 61, + "end": 72 + }, + { + "file": "contracts.ts", + "name": "Box", + "qualifiedName": "Box#2", + "kind": "export", + "exported": true, + "signature": "Box", + "startLine": 3, + "endLine": 3, + "start": 123, + "end": 126 + }, + { + "file": "contracts.ts", + "name": "TextBox", + "qualifiedName": "TextBox#2", + "kind": "export", + "exported": true, + "signature": "TextBox", + "startLine": 4, + "endLine": 4, + "start": 156, + "end": 163 + }, + { + "file": "implementations.ts", + "name": "Declared", + "qualifiedName": "Declared#2", + "kind": "export", + "exported": true, + "signature": "Declared", + "startLine": 2, + "endLine": 2, + "start": 89, + "end": 97 + }, + { + "file": "implementations.ts", + "name": "Structural", + "qualifiedName": "Structural#2", + "kind": "export", + "exported": true, + "signature": "Structural", + "startLine": 3, + "endLine": 3, + "start": 170, + "end": 180 + }, + { + "file": "implementations.ts", + "name": "Inherited", + "qualifiedName": "Inherited#2", + "kind": "export", + "exported": true, + "signature": "Inherited", + "startLine": 4, + "endLine": 4, + "start": 233, + "end": 242 + }, + { + "file": "implementations.ts", + "name": "Wrong", + "qualifiedName": "Wrong#2", + "kind": "export", + "exported": true, + "signature": "Wrong", + "startLine": 5, + "endLine": 5, + "start": 295, + "end": 300 + }, + { + "file": "implementations.ts", + "name": "StringBox", + "qualifiedName": "StringBox#2", + "kind": "export", + "exported": true, + "signature": "StringBox", + "startLine": 6, + "endLine": 6, + "start": 347, + "end": 356 + }, + { + "file": "implementations.ts", + "name": "Generic", + "qualifiedName": "Generic#2", + "kind": "export", + "exported": true, + "signature": "Generic", + "startLine": 7, + "endLine": 7, + "start": 426, + "end": 433 + }, + { + "file": "implementations.ts", + "name": "instance", + "qualifiedName": "instance#2", + "kind": "export", + "exported": true, + "signature": "instance", + "startLine": 8, + "endLine": 8, + "start": 519, + "end": 527 + }, + { + "file": "implementations.ts", + "name": "Abstract", + "qualifiedName": "Abstract#2", + "kind": "export", + "exported": true, + "signature": "Abstract", + "startLine": 9, + "endLine": 9, + "start": 582, + "end": 590 + }, + { + "file": "implementations.ts", + "name": "Concrete", + "qualifiedName": "Concrete#2", + "kind": "export", + "exported": true, + "signature": "Concrete", + "startLine": 10, + "endLine": 10, + "start": 653, + "end": 661 + }, + { + "file": "implementations.ts", + "name": "named", + "qualifiedName": "named#2", + "kind": "export", + "exported": true, + "signature": "named", + "startLine": 11, + "endLine": 11, + "start": 726, + "end": 731 + } + ], + "references": [ + { + "file": "contracts.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "NamedReader", + "targetQualifiedName": "Reader", + "targetFile": "contracts.ts", + "startLine": 2, + "start": 81, + "end": 87, + "text": "Reader" + }, + { + "file": "contracts.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "Box.get", + "targetQualifiedName": "Box.T", + "targetFile": "contracts.ts", + "startLine": 3, + "start": 139, + "end": 140, + "text": "T" + }, + { + "file": "contracts.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "TextBox", + "targetQualifiedName": "Box", + "targetFile": "contracts.ts", + "startLine": 4, + "start": 166, + "end": 169, + "text": "Box" + }, + { + "file": "implementations.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "Declared", + "targetQualifiedName": "Reader", + "targetFile": "contracts.ts", + "startLine": 2, + "start": 109, + "end": 117, + "text": "Readable" + }, + { + "file": "implementations.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "Inherited", + "targetQualifiedName": "Structural", + "targetFile": "implementations.ts", + "startLine": 4, + "start": 251, + "end": 261, + "text": "Structural" + }, + { + "file": "implementations.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "StringBox", + "targetQualifiedName": "Box", + "targetFile": "contracts.ts", + "startLine": 6, + "start": 368, + "end": 371, + "text": "Box" + }, + { + "file": "implementations.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "Generic.value", + "targetQualifiedName": "Generic.T", + "targetFile": "implementations.ts", + "startLine": 7, + "start": 466, + "end": 467, + "text": "T" + }, + { + "file": "implementations.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "Generic.get", + "targetQualifiedName": "Generic.T", + "targetFile": "implementations.ts", + "startLine": 7, + "start": 479, + "end": 480, + "text": "T" + }, + { + "file": "implementations.ts", + "kind": "REFERENCES", + "sourceQualifiedName": "Generic.get", + "targetQualifiedName": "Generic.value", + "targetFile": "implementations.ts", + "startLine": 7, + "start": 495, + "end": 500, + "text": "value" + }, + { + "file": "implementations.ts", + "kind": "CALLS_DIRECT", + "sourceQualifiedName": "instance", + "targetQualifiedName": "Generic", + "targetFile": "implementations.ts", + "startLine": 8, + "start": 534, + "end": 541, + "text": "Generic" + }, + { + "file": "implementations.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "Abstract", + "targetQualifiedName": "Reader", + "targetFile": "contracts.ts", + "startLine": 9, + "start": 602, + "end": 610, + "text": "Readable" + }, + { + "file": "implementations.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "Concrete", + "targetQualifiedName": "Abstract", + "targetFile": "implementations.ts", + "startLine": 10, + "start": 670, + "end": 678, + "text": "Abstract" + }, + { + "file": "implementations.ts", + "kind": "TYPE_USES", + "sourceQualifiedName": "named", + "targetQualifiedName": "NamedReader", + "targetFile": "contracts.ts", + "startLine": 11, + "start": 733, + "end": 744, + "text": "NamedReader" + }, + { + "file": "implementations.ts", + "kind": "CALLS_DIRECT", + "sourceQualifiedName": "named", + "targetQualifiedName": "Inherited", + "targetFile": "implementations.ts", + "startLine": 11, + "start": 751, + "end": 760, + "text": "Inherited" + } + ], + "imports": [], + "exports": [ + { + "file": "contracts.ts", + "kind": "EXPORTS", + "qualifiedName": "Reader#2", + "start": 17, + "end": 23, + "startLine": 1, + "text": "Reader", + "targetQualifiedName": "Reader", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "contracts.ts", + "kind": "EXPORTS", + "qualifiedName": "NamedReader#2", + "start": 61, + "end": 72, + "startLine": 2, + "text": "NamedReader", + "targetQualifiedName": "NamedReader", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "contracts.ts", + "kind": "EXPORTS", + "qualifiedName": "Box#2", + "start": 123, + "end": 126, + "startLine": 3, + "text": "Box", + "targetQualifiedName": "Box", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "contracts.ts", + "kind": "EXPORTS", + "qualifiedName": "TextBox#2", + "start": 156, + "end": 163, + "startLine": 4, + "text": "TextBox", + "targetQualifiedName": "TextBox", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "Declared#2", + "start": 89, + "end": 97, + "startLine": 2, + "text": "Declared", + "targetQualifiedName": "Declared", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "Structural#2", + "start": 170, + "end": 180, + "startLine": 3, + "text": "Structural", + "targetQualifiedName": "Structural", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "Inherited#2", + "start": 233, + "end": 242, + "startLine": 4, + "text": "Inherited", + "targetQualifiedName": "Inherited", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "Wrong#2", + "start": 295, + "end": 300, + "startLine": 5, + "text": "Wrong", + "targetQualifiedName": "Wrong", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "StringBox#2", + "start": 347, + "end": 356, + "startLine": 6, + "text": "StringBox", + "targetQualifiedName": "StringBox", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "Generic#2", + "start": 426, + "end": 433, + "startLine": 7, + "text": "Generic", + "targetQualifiedName": "Generic", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "instance#2", + "start": 519, + "end": 527, + "startLine": 8, + "text": "instance", + "targetQualifiedName": "instance", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "Abstract#2", + "start": 582, + "end": 590, + "startLine": 9, + "text": "Abstract", + "targetQualifiedName": "Abstract", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "Concrete#2", + "start": 653, + "end": 661, + "startLine": 10, + "text": "Concrete", + "targetQualifiedName": "Concrete", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "kind": "EXPORTS", + "qualifiedName": "named#2", + "start": 726, + "end": 731, + "startLine": 11, + "text": "named", + "targetQualifiedName": "named", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + } + ], + "extends": [ + { + "file": "contracts.ts", + "qualifiedName": "NamedReader", + "start": 81, + "end": 87, + "startLine": 2, + "text": "Reader", + "targetQualifiedName": "Reader", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "qualifiedName": "Inherited", + "start": 251, + "end": 261, + "startLine": 4, + "text": "Structural", + "targetQualifiedName": "Structural", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + }, + { + "file": "implementations.ts", + "qualifiedName": "Concrete", + "start": 670, + "end": 678, + "startLine": 10, + "text": "Abstract", + "targetQualifiedName": "Abstract", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null + } + ], + "implementations": [ + { + "file": "implementations.ts", + "qualifiedName": "Concrete", + "start": 640, + "end": 712, + "startLine": 10, + "text": "export class Concrete extends Abstract", + "targetQualifiedName": "Reader", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Concrete", + "start": 640, + "end": 712, + "startLine": 10, + "text": "export class Concrete extends Abstract", + "targetQualifiedName": "Abstract", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "declared", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Concrete.read", + "start": 681, + "end": 710, + "startLine": 10, + "text": "read(): string", + "targetQualifiedName": "Reader.read", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Concrete.read", + "start": 681, + "end": 710, + "startLine": 10, + "text": "read(): string", + "targetQualifiedName": "Abstract.read", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "declared", + "relation": "OVERRIDES" + }, + { + "file": "implementations.ts", + "qualifiedName": "Declared", + "start": 76, + "end": 156, + "startLine": 2, + "text": "export class Declared implements Readable", + "targetQualifiedName": "Reader", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "declared", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Declared", + "start": 76, + "end": 156, + "startLine": 2, + "text": "export class Declared implements Readable", + "targetQualifiedName": "Abstract", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Declared.read", + "start": 120, + "end": 154, + "startLine": 2, + "text": "read(): string", + "targetQualifiedName": "Reader.read", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "declared", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Declared.read", + "start": 120, + "end": 154, + "startLine": 2, + "text": "read(): string", + "targetQualifiedName": "Abstract.read", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "OVERRIDES" + }, + { + "file": "implementations.ts", + "qualifiedName": "Generic", + "start": 413, + "end": 505, + "startLine": 7, + "text": "export class Generic", + "targetQualifiedName": "Box", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Generic", + "start": 413, + "end": 505, + "startLine": 7, + "text": "export class Generic", + "targetQualifiedName": "TextBox", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Generic.get", + "start": 472, + "end": 503, + "startLine": 7, + "text": "get(): T", + "targetQualifiedName": "Box.get", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Inherited", + "start": 220, + "end": 281, + "startLine": 4, + "text": "export class Inherited extends Structural", + "targetQualifiedName": "NamedReader", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Inherited", + "start": 220, + "end": 281, + "startLine": 4, + "text": "export class Inherited extends Structural", + "targetQualifiedName": "Reader", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Inherited", + "start": 220, + "end": 281, + "startLine": 4, + "text": "export class Inherited extends Structural", + "targetQualifiedName": "Abstract", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "StringBox", + "start": 334, + "end": 412, + "startLine": 6, + "text": "export class StringBox implements Box", + "targetQualifiedName": "Box", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "declared", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "StringBox", + "start": 334, + "end": 412, + "startLine": 6, + "text": "export class StringBox implements Box", + "targetQualifiedName": "TextBox", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "StringBox.get", + "start": 382, + "end": 410, + "startLine": 6, + "text": "get(): string", + "targetQualifiedName": "Box.get", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "declared", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Structural", + "start": 157, + "end": 219, + "startLine": 3, + "text": "export class Structural", + "targetQualifiedName": "Reader", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Structural", + "start": 157, + "end": 219, + "startLine": 3, + "text": "export class Structural", + "targetQualifiedName": "Abstract", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Structural.read", + "start": 183, + "end": 217, + "startLine": 3, + "text": "read(): string", + "targetQualifiedName": "Reader.read", + "targetFile": "contracts.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "IMPLEMENTS" + }, + { + "file": "implementations.ts", + "qualifiedName": "Structural.read", + "start": 183, + "end": 217, + "startLine": 3, + "text": "read(): string", + "targetQualifiedName": "Abstract.read", + "targetFile": "implementations.ts", + "target": null, + "requestedPackage": null, + "requestedSymbol": null, + "reason": null, + "detail": null, + "detection": "structural", + "relation": "OVERRIDES" + } + ], + "implementationLimitations": [], + "dependencies": [], + "unresolved": [] +} From 4f52a48cc8027564512390f5d420670692a1669b Mon Sep 17 00:00:00 2001 From: Tutitoos Date: Sat, 5 Sep 2026 16:54:44 +0200 Subject: [PATCH 07/18] feat(typescript): resolve compiler-proven implementations Use the native compiler to prove declared, structural and method relationships for observed type instances. Keep generation-local caches, conservative candidate selection, and incomplete scopes explicit. Compare optimized selection with exhaustive evaluation in negative and cross-repository fixtures. --- .../typescript/implementations/contracts.ts | 4 + .../implementations/implementations.ts | 11 + .../typescript/implementations/package.json | 1 + .../typescript/implementations/tsconfig.json | 1 + ts-worker/src/declaration-classifier.ts | 9 +- ts-worker/src/facts-cli.ts | 56 ++- ts-worker/src/implements-resolver.test.ts | 223 +++++++++ ts-worker/src/implements-resolver.ts | 471 ++++++++++++++++++ ts-worker/src/symbol-extractor.test.ts | 2 + 9 files changed, 764 insertions(+), 14 deletions(-) create mode 100644 testdata/typescript/implementations/contracts.ts create mode 100644 testdata/typescript/implementations/implementations.ts create mode 100644 testdata/typescript/implementations/package.json create mode 100644 testdata/typescript/implementations/tsconfig.json create mode 100644 ts-worker/src/implements-resolver.test.ts create mode 100644 ts-worker/src/implements-resolver.ts diff --git a/testdata/typescript/implementations/contracts.ts b/testdata/typescript/implementations/contracts.ts new file mode 100644 index 00000000..e87089d0 --- /dev/null +++ b/testdata/typescript/implementations/contracts.ts @@ -0,0 +1,4 @@ +export interface Reader { read(): string; } +export interface NamedReader extends Reader { name: string; } +export interface Box { get(): T; } +export type TextBox = Box; diff --git a/testdata/typescript/implementations/implementations.ts b/testdata/typescript/implementations/implementations.ts new file mode 100644 index 00000000..db1ec16b --- /dev/null +++ b/testdata/typescript/implementations/implementations.ts @@ -0,0 +1,11 @@ +import type { Reader as Readable, NamedReader, Box } from './contracts.js'; +export class Declared implements Readable { read(): string { return 'value'; } } +export class Structural { read(): string { return 'value'; } } +export class Inherited extends Structural { name = 'named'; } +export class Wrong { read(): number { return 1; } } +export class StringBox implements Box { get(): string { return ''; } } +export class Generic { constructor(private value: T) {} get(): T { return this.value; } } +export const instance = new Generic('value'); +export abstract class Abstract implements Readable { abstract read(): string; } +export class Concrete extends Abstract { read(): string { return ''; } } +export const named: NamedReader = new Inherited(); diff --git a/testdata/typescript/implementations/package.json b/testdata/typescript/implementations/package.json new file mode 100644 index 00000000..d005243c --- /dev/null +++ b/testdata/typescript/implementations/package.json @@ -0,0 +1 @@ +{"name":"@fixture/implementations","version":"1.0.0","type":"module"} diff --git a/testdata/typescript/implementations/tsconfig.json b/testdata/typescript/implementations/tsconfig.json new file mode 100644 index 00000000..9a1157dd --- /dev/null +++ b/testdata/typescript/implementations/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","module":"nodenext","moduleResolution":"nodenext","strict":true,"noEmit":true},"include":["*.ts"]} diff --git a/ts-worker/src/declaration-classifier.ts b/ts-worker/src/declaration-classifier.ts index 063093d3..29d0ccdd 100644 --- a/ts-worker/src/declaration-classifier.ts +++ b/ts-worker/src/declaration-classifier.ts @@ -28,6 +28,8 @@ import { isIdentifier, isInterfaceDeclaration, isMethodDeclaration, + isMethodSignatureDeclaration, + isPropertySignatureDeclaration, isModuleDeclaration, isObjectBindingPattern, isPropertyDeclaration, @@ -109,11 +111,15 @@ export function declarationCandidate( // `symbol-extractor.ts`'s sibling handling and `classifyDeclarationAt`). nameNode = names[0]; kind = "variable"; - } else if (isPropertyDeclaration(node)) { + } else if ( + isPropertyDeclaration(node) || + isPropertySignatureDeclaration(node) + ) { nameNode = node.name; kind = "property"; } else if ( isMethodDeclaration(node) || + isMethodSignatureDeclaration(node) || isGetAccessorDeclaration(node) || isSetAccessorDeclaration(node) ) { @@ -190,6 +196,7 @@ export function scopeName(node: Node): string | undefined { } if ( isMethodDeclaration(node) || + isMethodSignatureDeclaration(node) || isGetAccessorDeclaration(node) || isSetAccessorDeclaration(node) || isPropertyDeclaration(node) diff --git a/ts-worker/src/facts-cli.ts b/ts-worker/src/facts-cli.ts index 2b5f7849..2df3e11e 100644 --- a/ts-worker/src/facts-cli.ts +++ b/ts-worker/src/facts-cli.ts @@ -1,7 +1,7 @@ /** * Emit the canonical fact payload of one TypeScript repository. * - * The payload is the wire contract `ts-facts-v4` consumed by + * The payload is the wire contract `ts-facts-v5` consumed by * `internal/facts`: the worker reports identity components and positions, and * Go derives the durable keys. Nothing here computes a key, so both languages * cannot drift into two identities for one symbol. @@ -30,8 +30,8 @@ * repository resolves through `targetQualifiedName`/`targetFile`; a base * introduced by an import reuses the exact provider-source identity an * `IMPORTS_SYMBOL` edge for that same binding already carries, never a - * second resolution of its own. `implements` never appears here: see - * `extends-resolver.ts` for why. + * second resolution of its own. `implementations` adds compiler-proven declared and structural relationships; + * `implementationLimitations` records excluded analysis scopes. * * `dependencies` is `PACKAGE_DEPENDS_ON`: one entry per package this * repository's own package really imports from, backed by a checker-resolved @@ -68,15 +68,15 @@ * provider's configuration, and an inferred project has none. The Go side * only passes this when `typescript.include_unclaimed_sources` is on. * - * Regenerate the `ts-facts-v4` goldens, from `ts-worker/`: + * Regenerate the `ts-facts-v5` goldens, from `ts-worker/`: * * pnpm facts shared-library ../testdata/typescript/cross-repository/shared-library \ - * ../testdata/protocol/ts-facts-v4/shared-library.json + * ../testdata/protocol/ts-facts-v5/shared-library.json * pnpm facts consumer-a ../testdata/typescript/cross-repository/consumer-a \ - * ../testdata/protocol/ts-facts-v4/consumer-a.json \ + * ../testdata/protocol/ts-facts-v5/consumer-a.json \ * --provider shared-library=../testdata/typescript/cross-repository/shared-library * pnpm facts consumer-b ../testdata/typescript/cross-repository/consumer-b \ - * ../testdata/protocol/ts-facts-v4/consumer-b.json \ + * ../testdata/protocol/ts-facts-v5/consumer-b.json \ * --provider shared-library=../testdata/typescript/cross-repository/shared-library */ @@ -85,6 +85,7 @@ import path from "node:path"; import { isEntryPoint } from "./entry-point.js"; import { type ExtendsEdge, resolveExtends } from "./extends-resolver.js"; +import { resolveImplementations } from "./implements-resolver.js"; import type { ImportedSymbol, ReexportedSymbol, @@ -108,7 +109,7 @@ import { extractLocalSymbols } from "./symbol-extractor.js"; import { resolveUnresolvedReferences } from "./unresolved-reference-resolver.js"; interface FactsPayload { - readonly version: 4; + readonly version: 5; readonly repository: { readonly name: string }; readonly package: { readonly name: string; @@ -122,6 +123,11 @@ interface FactsPayload { readonly imports: readonly FactImport[]; readonly exports: readonly FactExport[]; readonly extends: readonly FactExtends[]; + readonly implementations: readonly (FactExtends & { + readonly detection: "declared" | "structural"; + readonly relation: "IMPLEMENTS" | "OVERRIDES"; + })[]; + readonly implementationLimitations: readonly string[]; readonly dependencies: readonly FactDependency[]; readonly unresolved: readonly FactUnresolved[]; } @@ -340,6 +346,12 @@ export async function collectFacts( symbols, resolution.symbols, ); + const implementationResolution = await resolveImplementations( + service, + view, + symbols, + resolution.symbols, + ); const importSymbols = importFactSymbols(root, resolution.symbols); // An export's public name frequently repeats the local declaration it // exposes (`export function foo() {}` names both "foo"), unlike an @@ -440,7 +452,7 @@ export async function collectFacts( ].sort(compareUnresolved); return { - version: 4, + version: 5, repository: { name: repositoryName }, package: manifest, files: files.map((file) => relative(root, file)), @@ -510,6 +522,24 @@ export async function collectFacts( }), exports: exportSymbols.exports, extends: extendsFacts.extends, + implementations: implementationResolution.edges.map((edge) => { + const fact = extendsFactSymbols( + root, + [edge], + manifest?.name ?? repositoryName, + ).extends[0]; + if (fact === undefined) + throw new Error("implementation normalization omitted an edge"); + return { ...fact, detection: edge.detection, relation: edge.relation }; + }), + implementationLimitations: [ + ...implementationResolution.limitations, + ...(unclaimed.length > 0 + ? [ + "Inferred files contribute symbols and references, but do not attest implementation coverage.", + ] + : []), + ], dependencies: dependencyFacts, unresolved, }; @@ -1218,7 +1248,7 @@ interface CliArgs { const USAGE = `usage: pnpm facts [--project ] [--provider =]... [--provider-project =]... [--unclaimed ]... -Emits the ts-facts-v4 payload of , named . +Emits the ts-facts-v5 payload of , named . --project TypeScript project to load, relative to the repository root. Defaults to /tsconfig.json. @@ -1233,12 +1263,12 @@ Emits the ts-facts-v4 payload of , named . absolute and inside the repository root. Repeatable. -Example — regenerate the ts-facts-v4 goldens, from ts-worker/: +Example — regenerate the ts-facts-v5 goldens, from ts-worker/: pnpm facts shared-library ../testdata/typescript/cross-repository/shared-library \\ - ../testdata/protocol/ts-facts-v4/shared-library.json + ../testdata/protocol/ts-facts-v5/shared-library.json pnpm facts consumer-a ../testdata/typescript/cross-repository/consumer-a \\ - ../testdata/protocol/ts-facts-v4/consumer-a.json \\ + ../testdata/protocol/ts-facts-v5/consumer-a.json \\ --provider shared-library=../testdata/typescript/cross-repository/shared-library `; diff --git a/ts-worker/src/implements-resolver.test.ts b/ts-worker/src/implements-resolver.test.ts new file mode 100644 index 00000000..96e3a7f7 --- /dev/null +++ b/ts-worker/src/implements-resolver.test.ts @@ -0,0 +1,223 @@ +import { mkdir, rm, writeFile, symlink } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { resolveImportedSymbols } from "./imported-symbol-resolver.js"; +import { createPackageProviderRegistry } from "./package-import-resolver.js"; +import { resolveImplementations } from "./implements-resolver.js"; +import { LanguageService } from "./language-service.js"; +import { extractLocalSymbols } from "./symbol-extractor.js"; +import { temporaryRoot } from "./temporary-root.js"; + +const services: LanguageService[] = []; +const roots: string[] = []; +afterEach(async () => { + await Promise.all(services.splice(0).map((service) => service.close())); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +async function fixture(source: string) { + const root = await temporaryRoot("kivgraph-implementations-"); + roots.push(root); + await mkdir(path.join(root, "src")); + const config = path.join(root, "tsconfig.json"); + await writeFile( + config, + JSON.stringify({ + compilerOptions: { strict: true, target: "ES2022", noEmit: true }, + include: ["src/*.ts"], + }), + ); + await writeFile(path.join(root, "src/main.ts"), source); + const service = LanguageService.create({ cwd: root }); + services.push(service); + await service.openProject(config); + const view = service.project(config); + return { service, view, symbols: await extractLocalSymbols(service, view) }; +} + +it("rejects incompatible and erroneous classes; optimized selection equals the exhaustive compiler oracle", async () => { + const { service, view, symbols } = await fixture(` +export interface Reader { read(): string; } +export class Wrong { read(): number { return 1; } } +export class Missing {} +export class Broken implements Reader { read(): MissingType { throw 1; } } +export abstract class Abstract implements Reader { abstract read(): string; } +export class Declared implements Reader { read(): string { return 'ok'; } } +export class Structural { read(): string { return 'ok'; } } +export class Inherited extends Structural {} +`); + const result = await resolveImplementations(service, view, symbols, []); + const brute = await resolveImplementations(service, view, symbols, [], { + exhaustive: true, + }); + expect(result.edges).toEqual(brute.edges); + const types = result.edges.filter( + (edge) => edge.targetQualifiedName === "Reader", + ); + expect( + types.map((edge) => [edge.base.sourceQualifiedName, edge.detection]), + ).toEqual([ + ["Declared", "declared"], + ["Inherited", "structural"], + ["Structural", "structural"], + ]); + expect( + result.edges.map( + (edge) => `${edge.base.sourceQualifiedName}->${edge.targetQualifiedName}`, + ), + ).toContain("Declared.read->Reader.read"); + expect( + result.edges.some((edge) => + edge.base.sourceQualifiedName.startsWith("Broken"), + ), + ).toBe(false); +}); + +it("uses concrete generic instances without replacing unknown parameters with any", async () => { + const { service, view, symbols } = await fixture(` +export interface Box { get(): T; } +export class StringBox implements Box { get(): string { return ''; } } +export class NumericBox { get(): number { return 0; } } +export class Generic { constructor(private value: T) {} get(): T { return this.value; } } +export const instance = new Generic('value'); +export type TextBox = Box; +`); + const result = await resolveImplementations(service, view, symbols, []); + expect( + result.edges.some( + (edge) => + edge.base.sourceQualifiedName === "StringBox" && + edge.targetQualifiedName === "Box" && + edge.detection === "declared", + ), + ).toBe(true); + expect( + result.edges.some( + (edge) => + edge.base.sourceQualifiedName === "Generic" && + edge.targetQualifiedName === "TextBox", + ), + ).toBe(true); + expect( + result.edges.some( + (edge) => + edge.base.sourceQualifiedName === "NumericBox" && + edge.targetQualifiedName === "TextBox", + ), + ).toBe(false); +}); + +it("retains canonical provider identities for imported interfaces and methods", async () => { + const root = await temporaryRoot("kivgraph-cross-implementations-"); + roots.push(root); + const provider = path.join(root, "provider"); + const consumer = path.join(root, "consumer"); + for (const dir of [ + provider, + path.join(provider, "src"), + path.join(provider, "dist"), + consumer, + path.join(consumer, "node_modules"), + ]) + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(provider, "package.json"), + JSON.stringify({ + name: "contracts", + version: "1.0.0", + types: "./dist/contracts.d.ts", + }), + ); + await writeFile( + path.join(provider, "src/contracts.ts"), + "export interface Reader { read(): string; }\n", + ); + const config = { + compilerOptions: { + strict: true, + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + }, + include: ["*.ts"], + }; + await writeFile( + path.join(provider, "dist/contracts.d.ts"), + "export interface Reader { read(): string; }\n", + ); + await writeFile( + path.join(provider, "tsconfig.json"), + JSON.stringify({ ...config, include: ["src/*.ts"] }), + ); + await writeFile(path.join(consumer, "tsconfig.json"), JSON.stringify(config)); + await symlink(provider, path.join(consumer, "node_modules/contracts"), "dir"); + await writeFile( + path.join(consumer, "main.ts"), + `import type {Reader as External} from 'contracts'; + export class Declared implements External { read(): string {return '';} } + export class Structural { read(): string {return '';} } + export class Wrong { read(): number {return 1;} }`, + ); + const service = LanguageService.create({ cwd: consumer }); + services.push(service); + const project = path.join(consumer, "tsconfig.json"); + await service.openProject(project); + const view = service.project(project); + const symbols = await extractLocalSymbols(service, view); + const imports = await resolveImportedSymbols( + service, + view, + createPackageProviderRegistry([ + { + name: "contracts", + version: "1.0.0", + repository: "provider-repo", + rootPath: provider, + manifestPath: path.join(provider, "package.json"), + projectPath: path.join(provider, "tsconfig.json"), + sourceRoots: [path.join(provider, "src")], + declarationRoots: [path.join(provider, "dist")], + }, + ]), + ); + expect( + imports.symbols[0]?.target.identity?.repository, + JSON.stringify(imports), + ).toBe("provider-repo"); + const result = await resolveImplementations( + service, + view, + symbols, + imports.symbols, + ); + const brute = await resolveImplementations( + service, + view, + symbols, + imports.symbols, + { exhaustive: true }, + ); + expect(result.edges).toEqual(brute.edges); + expect( + result.edges.map((edge) => [ + edge.base.sourceQualifiedName, + edge.identity?.qualifiedName, + edge.detection, + ]), + ).toEqual([ + ["Declared", "Reader", "declared"], + ["Declared.read", "Reader.read", "declared"], + ["Structural", "Reader", "structural"], + ["Structural.read", "Reader.read", "structural"], + ]); + expect( + result.edges.every( + (edge) => + edge.identity?.repository === "provider-repo" && + edge.identity?.file === "src/contracts.ts", + ), + ).toBe(true); +}); diff --git a/ts-worker/src/implements-resolver.ts b/ts-worker/src/implements-resolver.ts new file mode 100644 index 00000000..5990f6ac --- /dev/null +++ b/ts-worker/src/implements-resolver.ts @@ -0,0 +1,471 @@ +/** Compiler-proven relations confined to one live generation. Names only + * eliminate impossible pairs; the native checker decides assignability. */ +import path from "node:path"; +import { ModifierFlags } from "typescript/unstable/ast"; +import type { Node } from "typescript/unstable/ast"; +import { + isClassDeclaration, + isTypeReferenceNode, + isNewExpression, +} from "typescript/unstable/ast/is"; +import { + DiagnosticCategory, + SymbolFlags, + TypeFlags, +} from "typescript/unstable/async"; +import type { Symbol as TSSymbol, Type } from "typescript/unstable/async"; +import { modifierFlags } from "./declaration-classifier.js"; +import type { ExtendsEdge } from "./extends-resolver.js"; +import type { + ImportedSymbol, + ImportedSymbolIdentity, +} from "./imported-symbol-resolver.js"; +import { LanguageService, LanguageServiceError } from "./language-service.js"; +import type { ProjectView } from "./language-service.js"; +import { extractLocalSymbols } from "./symbol-extractor.js"; +import type { LocalSymbol, LocalSymbolExtraction } from "./symbol-extractor.js"; +import { symbolDeclarationKey } from "./symbol-resolution.js"; + +export interface ImplementationEdge extends ExtendsEdge { + readonly detection: "declared" | "structural"; + readonly relation: "IMPLEMENTS" | "OVERRIDES"; +} +export interface ImplementationResolution { + readonly generation: number; + readonly edges: readonly ImplementationEdge[]; + readonly limitations: readonly string[]; +} +interface Target { + symbol: TSSymbol; + local?: LocalSymbol; + imported?: ImportedSymbol; + types: Map; +} + +export async function resolveImplementations( + service: LanguageService, + view: ProjectView, + extraction: LocalSymbolExtraction, + imports: readonly ImportedSymbol[], + options: { exhaustive?: boolean } = {}, +): Promise { + service.assertFresh(view); + if ( + extraction.generation !== view.generation || + extraction.configFileName !== view.configFileName + ) { + throw new LanguageServiceError( + "STALE_GENERATION", + "implementation symbols must belong to this project generation", + ); + } + const checker = view.checker; + const byID = new Map(); + const byDeclaration = new Map(); + for (const local of extraction.symbols) { + byID.set(local.symbolId, local); + for (const declaration of local.symbol.declarations) + byDeclaration.set(symbolDeclarationKey(declaration), local); + } + function localOf(symbol: TSSymbol): LocalSymbol | undefined { + return ( + byID.get(symbol.id) ?? + symbol.declarations + .map((declaration) => + byDeclaration.get(symbolDeclarationKey(declaration)), + ) + .find((value) => value !== undefined) + ); + } + const sources = new Map< + number, + { local: LocalSymbol; types: Map; declared: Set } + >(); + const targets = new Map(); + const badDeclarations = new Set(); + const limitations = new Set(); + const fileNames = [ + ...new Set(extraction.symbols.map((symbol) => symbol.fileName)), + ].sort(); + // Serial native RPC work bounds concurrency. Caches never escape this view. + for (const fileName of fileNames) { + const errors = (await view.program.getSemanticDiagnostics(fileName)).filter( + (diagnostic) => diagnostic.category === DiagnosticCategory.Error, + ); + for (const local of extraction.symbols.filter( + (symbol) => symbol.fileName === fileName, + )) { + if ( + errors.some( + (diagnostic) => + diagnostic.pos < local.end && diagnostic.end > local.start, + ) + ) + badDeclarations.add(local.symbolId); + } + } + if ( + extraction.symbols.some( + (symbol) => + ["class", "interface", "type"].includes(symbol.kind) && + badDeclarations.has(symbol.symbolId), + ) + ) { + limitations.add( + "Type declarations with compiler errors are excluded from implementation proofs.", + ); + } + const valid = (type: Type | undefined): type is Type => + type !== undefined && + !type.isErrorType() && + (type.flags & (TypeFlags.Any | TypeFlags.Unknown | TypeFlags.Never)) === 0; + for (const local of extraction.symbols) { + if ( + !["class", "interface", "type"].includes(local.kind) || + badDeclarations.has(local.symbolId) + ) + continue; + const type = await checker.getDeclaredTypeOfSymbol(local.symbol); + if (!valid(type)) continue; + let abstract = false; + for (const handle of local.symbol.declarations) { + const declaration = await handle.resolve(); + if (declaration !== undefined && isClassDeclaration(declaration)) + abstract ||= + (modifierFlags(declaration) & ModifierFlags.Abstract) !== 0; + } + if (local.kind === "class" && !abstract) { + sources.set(local.symbolId, { + local, + types: new Map([[type.id, type]]), + declared: new Set(), + }); + } else if (type.isObjectType() || type.isIntersectionType()) { + targets.set(local.symbolId, { + symbol: local.symbol, + local, + types: new Map([[type.id, type]]), + }); + } + } + for (const entry of imports) { + if (entry.target.identity === undefined) continue; + const original = await checker.getSymbolAtPosition( + entry.consumer.fileName, + entry.consumer.start, + ); + if (original === undefined) continue; + const symbol = + (original.flags & SymbolFlags.Alias) !== 0 + ? await checker.getAliasedSymbol(original) + : original; + if (await checker.isUnknownSymbol(symbol)) continue; + if (targets.has(symbol.id) || localOf(symbol) !== undefined) continue; + const type = await checker.getDeclaredTypeOfSymbol(symbol); + if (!valid(type) || !(type.isObjectType() || type.isIntersectionType())) + continue; + if (!["interface", "type"].includes(entry.target.identity.kind)) continue; + targets.set(symbol.id, { + symbol, + imported: entry, + types: new Map([[type.id, type]]), + }); + } + for (const fileName of fileNames) { + const file = await view.program.getSourceFile(fileName); + if (file === undefined) continue; + const nodes: Node[] = []; + const visit = (node: Node): void => { + nodes.push(node); + node.forEachChild(visit); + }; + file.forEachChild(visit); + for (const node of nodes) { + if (isClassDeclaration(node) && node.name !== undefined) { + const symbol = await checker.getSymbolAtLocation(node.name); + const source = + symbol === undefined ? undefined : sources.get(symbol.id); + if (source === undefined) continue; + for (const clause of node.heritageClauses ?? []) { + for (const base of clause.types) { + const type = await checker.getTypeAtLocation(base); + let targetSymbol = await checker.getSymbolAtLocation( + base.expression, + ); + if ( + targetSymbol !== undefined && + (targetSymbol.flags & SymbolFlags.Alias) !== 0 + ) + targetSymbol = await checker.getAliasedSymbol(targetSymbol); + const target = + targetSymbol === undefined + ? undefined + : targets.get(targetSymbol.id); + if (target !== undefined && valid(type)) { + target.types.set(type.id, type); + source.declared.add(target.symbol.id); + } + } + } + } + if (!isTypeReferenceNode(node) && !isNewExpression(node)) continue; + const type = isTypeReferenceNode(node) + ? await checker.getTypeFromTypeNode(node) + : await checker.getTypeAtLocation(node); + if (!valid(type)) continue; + const symbol = await type.getSymbol(); + if (symbol === undefined) continue; + sources.get(symbol.id)?.types.set(type.id, type); + targets.get(symbol.id)?.types.set(type.id, type); + } + } + const providerMembers = new Map< + number, + Map + >(); + const providerTargets = new Map(); + for (const target of targets.values()) { + const imported = target.imported; + if ( + imported === undefined || + imported.provider.projectPath === undefined || + imported.target.identity?.repository !== imported.provider.repository + ) + continue; + const group = providerTargets.get(imported.provider.projectPath) ?? []; + group.push(target); + providerTargets.set(imported.provider.projectPath, group); + } + for (const [project, group] of providerTargets) { + const providerService = LanguageService.create({ + cwd: path.dirname(project), + }); + try { + await providerService.openProject(project); + const providerView = providerService.project(project); + const locals = await extractLocalSymbols(providerService, providerView); + const declarations = new Map(); + for (const local of locals.symbols) + for (const handle of local.symbol.declarations) + declarations.set(symbolDeclarationKey(handle), local); + for (const target of group) { + const entry = target.imported; + const identity = entry?.target.identity; + if (entry === undefined || identity === undefined) continue; + const parent = locals.symbols.find( + (local) => + local.fileName === + path.resolve(entry.provider.rootPath, identity.file) && + local.qualifiedName === identity.qualifiedName && + local.signature === identity.signature && + local.kind === identity.kind, + ); + if (parent === undefined) { + limitations.add( + "Provider declaration identity could not be revalidated.", + ); + targets.delete(target.symbol.id); + continue; + } + const diagnostics = await providerView.program.getSemanticDiagnostics( + parent.fileName, + ); + if ( + diagnostics.some( + (diagnostic) => + diagnostic.category === DiagnosticCategory.Error && + diagnostic.pos < parent.end && + diagnostic.end > parent.start, + ) + ) { + limitations.add( + "Provider declarations with compiler errors are excluded.", + ); + targets.delete(target.symbol.id); + continue; + } + const type = await providerView.checker.getDeclaredTypeOfSymbol( + parent.symbol, + ); + if (!valid(type)) continue; + const members = new Map(); + for (const member of await providerView.checker.getPropertiesOfType( + type, + )) { + const local = member.declarations + .map((handle) => declarations.get(symbolDeclarationKey(handle))) + .find((value) => value !== undefined); + if (local === undefined || local.kind !== "method") continue; + const file = path + .relative(entry.provider.rootPath, local.fileName) + .split(path.sep) + .join("/"); + if (file.startsWith("../") || path.isAbsolute(file)) continue; + members.set(member.name, { + repository: identity.repository, + package: identity.package, + qualifiedName: local.qualifiedName, + kind: local.kind, + signature: local.signature, + file, + startLine: local.startLine, + source: "PROVIDER_EXPORT", + }); + } + providerMembers.set(target.symbol.id, members); + } + } finally { + await providerService.close(); + } + } + const properties = new Map(); + async function props(type: Type): Promise { + const cached = properties.get(type.id); + if (cached !== undefined) return cached; + const value = await checker.getPropertiesOfType(type); + properties.set(type.id, value); + return value; + } + const assignability = new Map(); + async function assignable(source: Type, target: Type): Promise { + const key = `${source.id}:${target.id}`; + const cached = assignability.get(key); + if (cached !== undefined) return cached; + const result = await checker.isTypeAssignableTo(source, target); + assignability.set(key, result); + return result; + } + const edges = new Map(); + function emit( + source: LocalSymbol, + target: Target, + detection: ImplementationEdge["detection"], + method = false, + ): void { + const edge: ImplementationEdge = { + base: { + fileName: source.fileName, + sourceQualifiedName: source.qualifiedName, + text: source.signature, + start: source.start, + end: source.end, + startLine: source.startLine, + endLine: source.endLine, + }, + targetQualifiedName: target.local?.qualifiedName, + targetFile: target.local?.fileName, + identity: target.imported?.target.identity, + packageName: target.imported?.packageName, + exportedName: target.imported?.exportedName, + unresolvedReason: undefined, + unresolvedDetail: undefined, + detection, + relation: method ? "OVERRIDES" : "IMPLEMENTS", + }; + const key = `${source.fileName}:${source.qualifiedName}:${target.local?.fileName ?? target.imported?.target.identity?.repository}:${target.local?.qualifiedName ?? target.imported?.target.identity?.qualifiedName}`; + if (edges.get(key)?.detection !== "declared") edges.set(key, edge); + } + for (const source of sources.values()) { + for (const target of targets.values()) { + const detection = source.declared.has(target.symbol.id) + ? "declared" + : "structural"; + let proof: { sourceType: Type; targetType: Type } | undefined; + for (const sourceType of source.types.values()) { + if ( + (await props(sourceType)).some((member) => { + const local = localOf(member); + return local !== undefined && badDeclarations.has(local.symbolId); + }) + ) + continue; + const sourceNames = new Set( + (await props(sourceType)).map((property) => property.name), + ); + for (const targetType of target.types.values()) { + if ( + (await props(targetType)).some((member) => { + const local = localOf(member); + return local !== undefined && badDeclarations.has(local.symbolId); + }) + ) + continue; + if ( + !options.exhaustive && + (await props(targetType)).some( + (property) => + (property.flags & SymbolFlags.Optional) === 0 && + !sourceNames.has(property.name), + ) + ) + continue; + if (await assignable(sourceType, targetType)) { + proof = { sourceType, targetType }; + break; + } + } + if (proof !== undefined) break; + } + if (proof === undefined) continue; + emit(source.local, target, detection); + for (const targetMember of await props(proof.targetType)) { + if ((targetMember.flags & SymbolFlags.Method) === 0) continue; + const targetLocal = localOf(targetMember); + const sourceMember = await checker.getPropertyOfType( + proof.sourceType, + targetMember.name, + ); + const sourceLocal = + sourceMember === undefined ? undefined : localOf(sourceMember); + const memberIdentity = providerMembers + .get(target.symbol.id) + ?.get(targetMember.name); + if ( + targetLocal === undefined && + sourceLocal !== undefined && + memberIdentity !== undefined && + target.imported !== undefined + ) { + emit( + sourceLocal, + { + symbol: targetMember, + imported: { + ...target.imported, + target: { ...target.imported.target, identity: memberIdentity }, + }, + types: new Map(), + }, + detection, + ); + continue; + } + if (targetLocal === undefined || sourceLocal === undefined) { + limitations.add( + "Method declarations outside the analyzed source identity set are excluded.", + ); + continue; + } + if ( + badDeclarations.has(sourceLocal.symbolId) || + sourceLocal.symbolId === targetLocal.symbolId + ) + continue; + emit( + sourceLocal, + { symbol: targetMember, local: targetLocal, types: new Map() }, + detection, + target.local?.kind === "class", + ); + } + } + } + service.assertFresh(view); + return { + generation: view.generation, + edges: [...edges.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([, edge]) => edge), + limitations: [...limitations].sort(), + }; +} diff --git a/ts-worker/src/symbol-extractor.test.ts b/ts-worker/src/symbol-extractor.test.ts index 75b1bc93..20fcc361 100644 --- a/ts-worker/src/symbol-extractor.test.ts +++ b/ts-worker/src/symbol-extractor.test.ts @@ -114,6 +114,7 @@ export default defaultValue; "value", "method", "Shape", + "area", "Alias", "Color", "Red", @@ -135,6 +136,7 @@ export default defaultValue; "parameter", "method", "interface", + "method", "type", "enum", "enum_member", From 82fa6608d9f6a85538ae177b31ceb7c98fdede11 Mon Sep 17 00:00:00 2001 From: Tutitoos Date: Sat, 5 Sep 2026 16:54:44 +0200 Subject: [PATCH 08/18] feat(mcp): expose paginated implementation queries Query exact IMPLEMENTS and OVERRIDES edges with deterministic generation-bound pagination, scope filters, evidence and honest completeness. Expose the freshness input digest for maintenance coordination and retain existing MCP byte budgets. Validation of the complete delivery tree: Go tests and vet, make test-ladybug, make lint-ladybug, the 103 TypeScript tests and worker checks, documentation gate, native bundle build, real full reindex and published-snapshot oracle passed. Runtime-dependent skips and the unreproduced historical Dart failure remain documented in the local audit. No source bytes changed while preparing these commits. --- AGENTS.md | 1 + README.md | 1 + ...d => 0116-typed-implementation-queries.md} | 2 +- docs/protocol/mcp-surface-v3.md | 20 ++- internal/freshness/freshness.go | 8 +- .../integrations/assets/kivgraph/SKILL.md | 4 +- internal/mcp/introspection_test.go | 2 + internal/mcp/server.go | 1 + internal/mcp/surface_test.go | 1 + internal/mcp/tools/BUILD.bazel | 2 + internal/mcp/tools/blast_radius.go | 6 +- internal/mcp/tools/file_outline.go | 10 +- internal/mcp/tools/find_by_intent.go | 6 +- .../mcp/tools/find_cross_repo_consumers.go | 6 +- internal/mcp/tools/find_implementations.go | 118 ++++++++++++++++++ .../mcp/tools/find_implementations_test.go | 53 ++++++++ internal/mcp/tools/find_references.go | 84 +++++++++---- internal/mcp/tools/find_symbol.go | 10 +- internal/mcp/tools/get_source.go | 6 +- internal/mcp/tools/get_symbol.go | 4 +- internal/mcp/tools/repositories.go | 2 +- internal/mcp/tools/status.go | 2 +- internal/mcp/tools/trace_dependencies.go | 6 +- landing/src/content/docs/docs/mcp-tools.md | 2 + .../docs/docs/tools/find-implementations.md | 31 +++++ 25 files changed, 327 insertions(+), 61 deletions(-) rename docs/adr/{0095-typed-implementation-queries.md => 0116-typed-implementation-queries.md} (98%) create mode 100644 internal/mcp/tools/find_implementations.go create mode 100644 internal/mcp/tools/find_implementations_test.go create mode 100644 landing/src/content/docs/docs/tools/find-implementations.md diff --git a/AGENTS.md b/AGENTS.md index 26f0517d..ce738d90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ El campo `instructions` del servidor dice lo mismo, y Zed no lo lee. | la pregunta | la tool | | --- | --- | | quién llama a esto, qué referencia a esto | `find_references` | +| quién implementa un tipo o método | `find_implementations` | | qué se rompe si lo cambio | `get_blast_radius` | | qué alcanza esto hacia fuera | `trace_dependencies` | | quién lo usa desde otro repositorio | `find_cross_repo_consumers` | diff --git a/README.md b/README.md index 2bbd0cd8..41e6da66 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ checkout, which is what a reader on a fork or without a network still has. | the question | the tool | | --- | --- | | who calls this, what references this | `find_references` | +| who implements a type or method | `find_implementations` | | what breaks if I change it | `get_blast_radius` | | what does this reach outward | `trace_dependencies` | | who uses it from another repository | `find_cross_repo_consumers` | diff --git a/docs/adr/0095-typed-implementation-queries.md b/docs/adr/0116-typed-implementation-queries.md similarity index 98% rename from docs/adr/0095-typed-implementation-queries.md rename to docs/adr/0116-typed-implementation-queries.md index 0f16bfe7..03bcab66 100644 --- a/docs/adr/0095-typed-implementation-queries.md +++ b/docs/adr/0116-typed-implementation-queries.md @@ -1,4 +1,4 @@ -# ADR 0095: Typed implementation queries and coverage +# ADR 0116: Typed implementation queries and coverage Status: accepted for local implementation, 2026-09-05. diff --git a/docs/protocol/mcp-surface-v3.md b/docs/protocol/mcp-surface-v3.md index b0c4753f..f2f5d50b 100644 --- a/docs/protocol/mcp-surface-v3.md +++ b/docs/protocol/mcp-surface-v3.md @@ -11,13 +11,14 @@ contrato observable: lo que un agente recibe y en qué puede confiar. Las cifras que aparecen aquí las mide `benchmarks/mcp-token-cost`, con su digest y su generación. Ninguna se declara a mano. -## 1. Las once tools +## 1. Las doce tools ```text list_repositories find_symbol find_by_intent get_symbol get_file_outline find_references find_cross_repo_consumers trace_dependencies get_blast_radius get_source graph_status +find_implementations ``` Una `serve` configurada añade tres controles de indexado: @@ -53,7 +54,7 @@ CLI. **Sin generación publicada no hay superficie.** El servidor completa el handshake, publica cero tools de consulta y pone el comando de reconstrucción en `instructions`. Un cliente lanza este proceso él mismo, así que salir se lee como -una caída; y publicar once tools que contestan `INDEX_NOT_READY` a todo enseña al +una caída; y publicar doce tools que contestan `INDEX_NOT_READY` a todo enseña al agente que las tools no funcionan. **Salvo que se pida lo contrario.** `kivgraph serve --introspection` publica el @@ -64,9 +65,9 @@ registro o la herramienta de desarrollo que sólo puede leer lo que devuelve Lo que la opción cambia es qué se **lista**, y nada más. No crea un índice, no fabrica un grafo vacío, no relaja ninguna comprobación de espacio en disco y no -toca la puerta de consentimiento de las dos mutaciones. Las once tools de -consulta del grafo que expone -siguen contestando `INDEX_NOT_READY` hasta que haya generación -- `graph_status` +toca la puerta de consentimiento de las dos mutaciones. Las doce tools de +consulta del grafo que expone siguen contestando `INDEX_NOT_READY` hasta que +haya generación -- `graph_status` es la excepción de siempre, porque la tool que explica por qué las demás se niegan no puede negarse ella-- y el handshake sigue llevando las instrucciones de reparación: decirle al cliente que hay grafo cuando no lo hay sería la única @@ -290,3 +291,12 @@ Los de PLAN.md 17.5, sin cambios. `get_file_outline` usa `REPOSITORY_NOT_FOUND` para un repositorio que no está en el grafo y `SYMBOL_NOT_FOUND` para una ruta que no existe bajo él: una página vacía se leería como «aquí no hay nada declarado», que es una respuesta distinta. + +## Implementaciones tipadas + +`find_implementations` consulta relaciones IMPLEMENTS y OVERRIDES, con evidencia +declarada o estructural de TypeScript. Devuelve `results.subject` e +`results.implementations`, identidades canónicas, generación, cursor y +completitud. Los filtros `repo`, `language`, `paths` y `detection` se aplican +antes de paginar. Una generación anterior al esquema 5 devuelve LOWER_BOUND. +Véase ADR 0116 para el ámbito de tipos y la compatibilidad del protocolo. diff --git a/internal/freshness/freshness.go b/internal/freshness/freshness.go index f8087958..715b6cb5 100644 --- a/internal/freshness/freshness.go +++ b/internal/freshness/freshness.go @@ -21,9 +21,10 @@ import ( ) type Status struct { - Generation uint64 `json:"generation"` - State string `json:"state"` - Detail string `json:"detail,omitempty"` + InputDigest string `json:"input_digest,omitempty"` + Generation uint64 `json:"generation"` + State string `json:"state"` + Detail string `json:"detail,omitempty"` } type Record struct { @@ -323,6 +324,7 @@ func Check(ctx context.Context, root string, generation uint64, repositories []w status.Detail = err.Error() return status } + status.InputDigest = digest status.State = "fresh" if digest != record.Digest { status.State = "stale" diff --git a/internal/integrations/assets/kivgraph/SKILL.md b/internal/integrations/assets/kivgraph/SKILL.md index 8498a77b..7713a7bf 100644 --- a/internal/integrations/assets/kivgraph/SKILL.md +++ b/internal/integrations/assets/kivgraph/SKILL.md @@ -93,7 +93,9 @@ repository/path/qualified-name triple remains portable across profiles. opening it, and `get_source` answers in prose rather than JSON and takes a list of selectors, so one call reads several declarations. 4. **Follow the edges.** `find_references` for direct incoming or outgoing - references, `trace_dependencies` for bounded dependency paths, + references, `find_implementations` for typed implementations (declared and + structural TypeScript, with generation and completeness), + `trace_dependencies` for bounded dependency paths, `find_cross_repo_consumers` for consumers in another repository, and `get_blast_radius` for bounded impact analysis. Read the last one's `coverage` carefully: `exact` and `candidate` count consumers of the symbol diff --git a/internal/mcp/introspection_test.go b/internal/mcp/introspection_test.go index 7b178bae..2faa0e91 100644 --- a/internal/mcp/introspection_test.go +++ b/internal/mcp/introspection_test.go @@ -21,6 +21,7 @@ import ( var introspectionCatalog = []string{ "find_by_intent", "find_cross_repo_consumers", + "find_implementations", "find_references", "find_symbol", "get_blast_radius", @@ -147,6 +148,7 @@ var smallestValidCall = map[string]map[string]any{ "get_source": {"symbols": []any{map[string]any{"repository": "repo-a", "path": "a.go", "qualified_name": "pkg.Thing"}}}, "get_file_outline": {"repository": "repo-a", "path": "a.go"}, "find_references": {"name": "Thing"}, + "find_implementations": {"name": "Thing"}, "find_cross_repo_consumers": {"repository": "repo-a", "path": "a.go", "qualified_name": "pkg.Thing"}, "trace_dependencies": {"repository": "repo-a", "path": "a.go", "qualified_name": "pkg.Thing"}, "get_blast_radius": {"repository": "repo-a", "path": "a.go", "qualified_name": "pkg.Thing"}, diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 48915186..b7506d48 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -253,6 +253,7 @@ func registerQueryTools( tools.RegisterGetSourceWithObserverAndSnapshotStore(server, observer, snapshotStore, callObserver) tools.RegisterGetFileOutlineWithObserverAndSnapshotStore(server, observer, snapshotStore, callObserver) tools.RegisterFindReferencesWithObserverAndSnapshotStore(server, observer, snapshotStore, callObserver) + tools.RegisterFindImplementationsWithObserverAndSnapshotStore(server, observer, snapshotStore, callObserver) tools.RegisterFindCrossRepoConsumersWithObserverAndSnapshotStore(server, observer, snapshotStore, callObserver) tools.RegisterTraceDependenciesWithObserverAndSnapshotStore(server, observer, snapshotStore, callObserver) tools.RegisterGetBlastRadiusWithObserverAndSnapshotStore(server, observer, snapshotStore, callObserver) diff --git a/internal/mcp/surface_test.go b/internal/mcp/surface_test.go index 65873373..0a92d2ec 100644 --- a/internal/mcp/surface_test.go +++ b/internal/mcp/surface_test.go @@ -25,6 +25,7 @@ import ( var allowedTools = []string{ "find_by_intent", "find_cross_repo_consumers", + "find_implementations", "find_references", "find_symbol", "get_blast_radius", diff --git a/internal/mcp/tools/BUILD.bazel b/internal/mcp/tools/BUILD.bazel index f7665a80..11d5b027 100644 --- a/internal/mcp/tools/BUILD.bazel +++ b/internal/mcp/tools/BUILD.bazel @@ -12,6 +12,7 @@ go_library( "find_by_intent.go", "find_cross_repo_consumers.go", "find_references.go", + "find_implementations.go", "find_symbol.go", "get_source.go", "get_symbol.go", @@ -62,6 +63,7 @@ go_test( "find_by_intent_test.go", "find_cross_repo_consumers_test.go", "find_references_test.go", + "find_implementations_test.go", "find_symbol_test.go", "freshness_profiles_test.go", "get_source_test.go", diff --git a/internal/mcp/tools/blast_radius.go b/internal/mcp/tools/blast_radius.go index b336ae3f..9da52569 100644 --- a/internal/mcp/tools/blast_radius.go +++ b/internal/mcp/tools/blast_radius.go @@ -42,7 +42,7 @@ const ( // (the default) spells the columns every row shares once in the header, `full` // repeats them on every row. type GetBlastRadiusInput struct { - Profile []string `json:"profile,omitempty" jsonschema:"Profiles to query; omit for the default, or use * alone for all."` + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` StableKey string `json:"stable_key,omitempty" jsonschema:"The root symbol durable key, as a detailed result returns it. The triple works instead."` QualifiedName string `json:"qualified_name,omitempty" jsonschema:"The root symbol fully qualified name, as every row of this surface carries it."` Repository string `json:"repository,omitempty" jsonschema:"The repository that declares the root symbol."` @@ -54,8 +54,8 @@ type GetBlastRadiusInput struct { Kinds []string `json:"kinds,omitempty" jsonschema:"Symbol kinds to report. Empty excludes variable and field, the local bindings a walk passes through; * reports every kind."` IncludeDerived bool `json:"include_derived,omitempty" jsonschema:"Include affected symbols of the derived provider, which is withheld by default."` Limit int `json:"limit,omitempty" jsonschema:"Affected symbols in one page. Defaults to 50."` - Cursor string `json:"cursor,omitempty" jsonschema:"The next_cursor of the previous page. Every other argument must stay the same."` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (the default) omits the derived identifiers; detailed returns them."` + Cursor string `json:"cursor,omitempty" jsonschema:"Previous next_cursor; keep other arguments unchanged."` + ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (default), or detailed with derived identifiers."` View string `json:"view,omitempty" jsonschema:"Granularity, never a different answer: compact (the default) states once what every row shares, full repeats it on each."` } diff --git a/internal/mcp/tools/file_outline.go b/internal/mcp/tools/file_outline.go index 56c999cf..e242eeb2 100644 --- a/internal/mcp/tools/file_outline.go +++ b/internal/mcp/tools/file_outline.go @@ -31,18 +31,18 @@ const ( // row-per-declaration shape; "files" answers only which files hold the page's // declarations and how many each holds. type GetFileOutlineInput struct { - Profile []string `json:"profile,omitempty" jsonschema:"Profiles to query; omit for the default, or use * alone for all."` + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` Repository string `json:"repository" jsonschema:"The repository that holds the path, as list_repositories names it."` Path string `json:"path" jsonschema:"A repository-relative file, or a directory whose files are all wanted."` - Kind string `json:"kind,omitempty" jsonschema:"Keep only declarations of this kind, such as function, struct or interface."` + Kind string `json:"kind,omitempty" jsonschema:"Filter kind, e.g. function, struct or interface."` // IncludeMembers adds struct fields, properties and enum members. They // are off by default because they are not declarations a reader chooses // between: they are the shape of the type above them, and on a real file // they are half the payload. - IncludeMembers bool `json:"include_members,omitempty" jsonschema:"Also list struct fields, properties and enum members. Off by default: on a real file they are half the payload."` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (the default) omits the derived identifiers; detailed returns them."` + IncludeMembers bool `json:"include_members,omitempty" jsonschema:"Include struct fields, properties and enum members; default false."` + ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (default), or detailed with derived identifiers."` Limit int `json:"limit,omitempty" jsonschema:"Declarations in one page. Defaults to 200."` - Cursor string `json:"cursor,omitempty" jsonschema:"The next_cursor of the previous page. Every other argument must stay the same."` + Cursor string `json:"cursor,omitempty" jsonschema:"Previous next_cursor; keep other arguments unchanged."` View string `json:"view,omitempty" jsonschema:"Granularity, never a different answer: compact (the default), full, or files for which files hold the declarations."` } diff --git a/internal/mcp/tools/find_by_intent.go b/internal/mcp/tools/find_by_intent.go index 90659f54..4e34ac0d 100644 --- a/internal/mcp/tools/find_by_intent.go +++ b/internal/mcp/tools/find_by_intent.go @@ -39,15 +39,15 @@ const ( // surface, a retrieval has no reachability to preserve and a narrower corpus is // simply a narrower question. type FindByIntentInput struct { - Profile []string `json:"profile,omitempty" jsonschema:"Profiles to query; omit for the default, or use * alone for all."` + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` Intent string `json:"intent" jsonschema:"What the code you are looking for does, in plain language."` Keywords []string `json:"keywords,omitempty" jsonschema:"Extra terms the code itself uses, when they differ from the words of the question."` Repo string `json:"repo,omitempty" jsonschema:"Consider only candidates in this repository. It narrows the question, not just the page."` PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Consider only candidates under this repository-relative path prefix."` Kind string `json:"kind,omitempty" jsonschema:"Consider only symbols of this kind, such as function, struct or interface."` Limit int `json:"limit,omitempty" jsonschema:"Candidates in one page. Defaults to 10, maximum 50."` - Cursor string `json:"cursor,omitempty" jsonschema:"The next_cursor of the previous page. Every other argument must stay the same."` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (the default) omits the derived identifiers; detailed returns them."` + Cursor string `json:"cursor,omitempty" jsonschema:"Previous next_cursor; keep other arguments unchanged."` + ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (default), or detailed with derived identifiers."` View string `json:"view,omitempty" jsonschema:"Granularity, never a different answer: compact (the default) states once what every row shares, full repeats it on each."` } diff --git a/internal/mcp/tools/find_cross_repo_consumers.go b/internal/mcp/tools/find_cross_repo_consumers.go index 04e4ef0f..c2d12753 100644 --- a/internal/mcp/tools/find_cross_repo_consumers.go +++ b/internal/mcp/tools/find_cross_repo_consumers.go @@ -37,7 +37,7 @@ const ( // dependencies of a repository into one entry, and `full` repeats every field // on every row. `files` is rejected: this answer is a set of repositories. type FindCrossRepoConsumersInput struct { - Profile []string `json:"profile,omitempty" jsonschema:"Profiles to query; omit for the default, or use * alone for all."` + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` StableKey string `json:"stable_key,omitempty" jsonschema:"The target symbol durable key, as a detailed result returns it. The triple works instead."` QualifiedName string `json:"qualified_name,omitempty" jsonschema:"The target symbol fully qualified name, as every row of this surface carries it."` Repository string `json:"repository,omitempty" jsonschema:"The repository that declares the target symbol, the provider side of the question."` @@ -45,8 +45,8 @@ type FindCrossRepoConsumersInput struct { Repo string `json:"repo,omitempty" jsonschema:"Keep only consumers found in this repository."` Language string `json:"language,omitempty" jsonschema:"Keep only consumers written in this language."` Limit int `json:"limit,omitempty" jsonschema:"Consumers in one page. Defaults to 50."` - Cursor string `json:"cursor,omitempty" jsonschema:"The next_cursor of the previous page. Every other argument must stay the same."` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (the default) omits the derived identifiers; detailed returns them."` + Cursor string `json:"cursor,omitempty" jsonschema:"Previous next_cursor; keep other arguments unchanged."` + ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (default), or detailed with derived identifiers."` View string `json:"view,omitempty" jsonschema:"Granularity, never a different answer: compact (the default) groups the package dependencies of a repository, full repeats every field. files is rejected."` } diff --git a/internal/mcp/tools/find_implementations.go b/internal/mcp/tools/find_implementations.go new file mode 100644 index 00000000..36dd1c16 --- /dev/null +++ b/internal/mcp/tools/find_implementations.go @@ -0,0 +1,118 @@ +package tools + +import ( + "context" + "path" + "slices" + "strings" + "time" + + "github.com/Luqueee/kivgraph/internal/facts" + "github.com/Luqueee/kivgraph/internal/hotsnapshot" + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const findImplementationsToolName = "find_implementations" + +// FindImplementationsInput selects a declaration and a page of exact typed relations. +type FindImplementationsInput struct { + Paths []string `json:"paths,omitempty" jsonschema:"Repository-relative files or directories to include in implementation rows."` + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` + StableKey string `json:"stable_key,omitempty" jsonschema:"Canonical subject key; alternatively use name or qualified_name."` + Name string `json:"name,omitempty" jsonschema:"Subject name. Ambiguity returns candidates."` + QualifiedName string `json:"qualified_name,omitempty" jsonschema:"Fully qualified subject name."` + Repository string `json:"repository,omitempty" jsonschema:"Repository declaring the subject."` + Path string `json:"path,omitempty" jsonschema:"Repository-relative file declaring the subject."` + Repo string `json:"repo,omitempty" jsonschema:"Filter implementation rows by repository."` + Language string `json:"language,omitempty" jsonschema:"Filter rows by language."` + Detection string `json:"detection,omitempty" jsonschema:"Filter evidence: declared or structural; omit for both and other typed mechanisms."` + Limit int `json:"limit,omitempty" jsonschema:"Rows per page; default 50."` + Cursor string `json:"cursor,omitempty" jsonschema:"Previous next_cursor; keep filters unchanged. Bound to the served generation."` +} + +type ImplementationSummary struct { + ReferenceSummary + Detection string `json:"detection"` +} + +type ImplementationResult struct { + Subject ReferenceSubject `json:"subject"` + Implementations []ImplementationSummary `json:"implementations"` + Scope string `json:"scope"` +} + +func implementationDetection(provenance facts.Provenance) string { + switch provenance { + case facts.TypeScriptImplementationDeclared: + return "declared" + case facts.TypeScriptImplementationStructural: + return "structural" + case facts.GoTypesSelection, facts.GoTypesUse: + return "structural" + default: + return "typed" + } +} + +func findImplementations(ctx context.Context, request *sdkmcp.CallToolRequest, arguments FindImplementationsInput, store *hotsnapshot.SnapshotStore) (*sdkmcp.CallToolResult, Response[ImplementationResult], error) { + if arguments.Detection != "" && arguments.Detection != "declared" && arguments.Detection != "structural" { + return nil, Response[ImplementationResult]{}, NewToolError(CodeInvalidArgument, "detection must be declared or structural") + } + paths := slices.Clone(arguments.Paths) + for _, prefix := range paths { + if prefix == "" || path.IsAbs(prefix) || path.Clean(prefix) != strings.TrimSuffix(prefix, "/") || prefix == ".." || strings.HasPrefix(prefix, "../") { + return nil, Response[ImplementationResult]{}, NewToolError(CodeInvalidArgument, "paths must be clean repository-relative paths") + } + } + slices.Sort(paths) + query := FindReferencesInput{implementationPaths: paths, Profile: arguments.Profile, StableKey: arguments.StableKey, Name: arguments.Name, QualifiedName: arguments.QualifiedName, Repository: arguments.Repository, Path: arguments.Path, Repo: arguments.Repo, Language: arguments.Language, Limit: arguments.Limit, Cursor: arguments.Cursor, EdgeKinds: []string{string(facts.Implements), string(facts.Overrides)}, Direction: FindReferencesDirectionIncoming, ResponseFormat: ResponseFormatDetailed, View: ViewFull, implementationsOnly: true, implementationDetection: arguments.Detection} + selected, count, err := resolveProfileSelection(store, arguments.Profile, arguments.StableKey) + if err != nil { + return nil, Response[ImplementationResult]{}, err + } + for i := range selected { + selected[i].Store = hotsnapshot.NewSnapshotStore(selected[i].Store.Load()) + } + var raw *sdkmcp.CallToolResult + var references Response[ReferenceResult] + if len(selected) > 1 { + raw, references, err = findReferencesAcrossProfiles(ctx, request, query, selected) + } else { + raw, references, err = findReferences(ctx, request, query, selected[0].Store) + scopeResponse(&references, selected[0].Name, count) + } + if err != nil { + return raw, Response[ImplementationResult]{}, err + } + rows := make([]ImplementationSummary, 0, len(references.Results.References)) + for _, row := range references.Results.References { + rows = append(rows, ImplementationSummary{ReferenceSummary: row, Detection: implementationDetection(facts.Provenance(row.Provenance))}) + } + // Schema 5 attests that this generation ran implementation analysis. An old + // graph can still answer Go relations, but its empty TS page proves nothing. + for _, profile := range selected { + if snapshot := profile.Store.Load(); snapshot != nil && snapshot.Metadata().SchemaVersion < 5 { + if references.Completeness == nil { + references.Completeness = &Completeness{} + } + references.Completeness.Verdict = VerdictLowerBound + for i := range references.Profiles { + if references.Profiles[i].Name == profile.Name { + references.Profiles[i].Completeness = &Completeness{Verdict: VerdictLowerBound, InvisibleScopes: []BlindSpot{{Reason: "IMPLEMENTATIONS_NOT_ANALYZED", Detail: "Rebuild this legacy generation with implementation analysis."}}} + } + } + references.Completeness.InvisibleScopes = append(references.Completeness.InvisibleScopes, BlindSpot{Reason: "IMPLEMENTATIONS_NOT_ANALYZED", Detail: "Rebuild this legacy generation with implementation analysis."}) + } + } + return raw, Response[ImplementationResult]{SnapshotID: references.SnapshotID, Profile: references.Profile, Profiles: references.Profiles, CrossProfileEdges: references.CrossProfileEdges, SnapshotAgeMS: references.SnapshotAgeMS, Total: references.Total, Returned: references.Returned, Truncated: references.Truncated, NextCursor: references.NextCursor, Coverage: references.Coverage, Completeness: references.Completeness, Guidance: "Absence is established only for COMPLETE coverage within the analyzed corpus. Concrete classes and observed type instances; no unknown generic arguments are replaced with any.", View: ViewFull, Results: ImplementationResult{Subject: references.Results.Subject, Implementations: rows, Scope: "Typed IMPLEMENTS and OVERRIDES relations in the selected published generation(s)."}}, nil +} + +func RegisterFindImplementationsWithObserverAndSnapshotStore(server *sdkmcp.Server, observer Observer, store *hotsnapshot.SnapshotStore, callObservers ...CallObserver) { + handler := func(ctx context.Context, request *sdkmcp.CallToolRequest, arguments FindImplementationsInput) (*sdkmcp.CallToolResult, Response[ImplementationResult], error) { + start := time.Now() + result, response, err := findImplementations(ctx, request, arguments, store) + observe(observer, firstCallObserver(callObservers), findImplementationsToolName, start, response, err) + return result, response, err + } + addQueryTool(server, &sdkmcp.Tool{Name: findImplementationsToolName, Description: "Implementations of types or methods, including structural TypeScript. Paged, with compiler evidence and coverage.", Annotations: readOnlyClosedWorld(), Meta: alwaysLoadMeta()}, handler) +} diff --git a/internal/mcp/tools/find_implementations_test.go b/internal/mcp/tools/find_implementations_test.go new file mode 100644 index 00000000..8ec81ddd --- /dev/null +++ b/internal/mcp/tools/find_implementations_test.go @@ -0,0 +1,53 @@ +package tools + +import ( + "context" + "testing" +) + +func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { + store := dispatchSnapshot(t, 200, 2) + args := FindImplementationsInput{StableKey: "iface-shared", Limit: 1} + _, first, err := findImplementations(context.Background(), nil, args, store) + if err != nil { + t.Fatal(err) + } + if first.Total != 2 || first.Returned != 1 || first.NextCursor == nil { + t.Fatalf("page: %#v", first) + } + if first.Results.Implementations[0].EdgeKind != "IMPLEMENTS" || first.Results.Implementations[0].Detection != "structural" { + t.Fatalf("untyped result: %#v", first.Results) + } + args.Cursor = *first.NextCursor + _, second, err := findImplementations(context.Background(), nil, args, store) + if err != nil || second.Returned != 1 || second.NextCursor != nil { + t.Fatalf("second: %#v %v", second, err) + } + if first.Results.Implementations[0].StableKey == second.Results.Implementations[0].StableKey { + t.Fatal("duplicate page") + } + args.Detection = "declared" + if _, _, err := findImplementations(context.Background(), nil, args, store); err == nil { + t.Fatal("cursor accepted changed filters") + } + args.Detection = "" + if _, _, err := findImplementations(context.Background(), nil, args, dispatchSnapshot(t, 201, 2)); err == nil { + t.Fatal("cursor crossed generations") + } + _, concrete, err := findImplementations(context.Background(), nil, FindImplementationsInput{StableKey: "impl-sole"}, store) + if err != nil || concrete.Total != 0 { + t.Fatalf("dispatch calls leaked into implementations: %#v %v", concrete, err) + } + if concrete.Completeness == nil || concrete.Completeness.Verdict != VerdictLowerBound { + t.Fatal("legacy generation falsely attested complete coverage") + } + _, filtered, err := findImplementations(context.Background(), nil, FindImplementationsInput{StableKey: "iface-shared", Paths: []string{"disk.go"}}, store) + if err != nil || filtered.Total != 1 || filtered.Results.Implementations[0].FilePath != "disk.go" { + t.Fatalf("paths filter: %#v %v", filtered, err) + } + for _, args := range []FindImplementationsInput{{StableKey: "iface-sole", Detection: "guess"}, {StableKey: "iface-sole", Paths: []string{"../outside"}}} { + if _, _, err := findImplementations(context.Background(), nil, args, store); err == nil { + t.Fatalf("invalid arguments accepted: %#v", args) + } + } +} diff --git a/internal/mcp/tools/find_references.go b/internal/mcp/tools/find_references.go index e0549e37..c03f4cd0 100644 --- a/internal/mcp/tools/find_references.go +++ b/internal/mcp/tools/find_references.go @@ -35,22 +35,25 @@ const ( // hoists whatever every row shares, `full` keeps the field-per-row shape, and // `files` answers only which files hold references and how many each holds. type FindReferencesInput struct { - Profile []string `json:"profile,omitempty" jsonschema:"Profiles to query; omit for the default, or use * alone for all."` - StableKey string `json:"stable_key,omitempty" jsonschema:"The subject durable key, as a detailed result returns it. A name or the triple works instead."` - QualifiedName string `json:"qualified_name,omitempty" jsonschema:"The subject fully qualified name, as every row of this surface carries it."` - Name string `json:"name,omitempty" jsonschema:"The subject unqualified name. Enough on its own: an ambiguous one answers with its candidates rather than picking."` - Repository string `json:"repository,omitempty" jsonschema:"The repository that declares the subject, to separate an ambiguous name."` - Path string `json:"path,omitempty" jsonschema:"The repository-relative file that declares the subject, to separate an ambiguous name."` - Direction string `json:"direction,omitempty" jsonschema:"incoming (the default) answers who uses the subject; outgoing answers what it uses."` - Repo string `json:"repo,omitempty" jsonschema:"Keep only rows from this repository. Naming the derived provider also opts it in."` - Language string `json:"language,omitempty" jsonschema:"Keep only rows written in this language."` - EdgeKinds []string `json:"edge_kinds,omitempty" jsonschema:"Relation kinds to return, such as CALLS or IMPORTS_SYMBOL. EXPORTS and REEXPORTS are withheld by default; * returns every kind."` - Confidence string `json:"confidence,omitempty" jsonschema:"Return only edges resolved at this confidence, such as EXACT_TYPECHECKED or CANDIDATE."` - IncludeDerived bool `json:"include_derived,omitempty" jsonschema:"Include rows of the derived provider, which is withheld by default."` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (the default) omits the derived identifiers; detailed returns them."` - View string `json:"view,omitempty" jsonschema:"Granularity, never a different answer: compact (the default), full, or files for which files hold references and how many each holds."` - Limit int `json:"limit,omitempty" jsonschema:"Rows in one page. Defaults to 50."` - Cursor string `json:"cursor,omitempty" jsonschema:"The next_cursor of the previous page. Every other argument must stay the same."` + implementationsOnly bool + implementationDetection string + implementationPaths []string + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` + StableKey string `json:"stable_key,omitempty" jsonschema:"Subject stable key; alternatively use name or the repository/path/qualified_name triple."` + QualifiedName string `json:"qualified_name,omitempty" jsonschema:"Subject fully qualified name."` + Name string `json:"name,omitempty" jsonschema:"Bare name; ambiguity returns candidates."` + Repository string `json:"repository,omitempty" jsonschema:"Repository declaring the subject."` + Path string `json:"path,omitempty" jsonschema:"Repository-relative subject file."` + Direction string `json:"direction,omitempty" jsonschema:"incoming (default): who uses it; outgoing: what it uses."` + Repo string `json:"repo,omitempty" jsonschema:"Filter rows by repository; naming the derived provider opts it in."` + Language string `json:"language,omitempty" jsonschema:"Keep only rows written in this language."` + EdgeKinds []string `json:"edge_kinds,omitempty" jsonschema:"Edge kinds. Default excludes EXPORTS/REEXPORTS; * includes all."` + Confidence string `json:"confidence,omitempty" jsonschema:"Filter confidence, e.g. EXACT_TYPECHECKED or CANDIDATE."` + IncludeDerived bool `json:"include_derived,omitempty" jsonschema:"Include the normally withheld derived provider."` + ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (default), or detailed with derived identifiers."` + View string `json:"view,omitempty" jsonschema:"compact (default), full rows, or files with counts. Same facts."` + Limit int `json:"limit,omitempty" jsonschema:"Rows in one page. Defaults to 50."` + Cursor string `json:"cursor,omitempty" jsonschema:"Previous next_cursor; keep all other arguments unchanged."` } // ReferenceSubject is the symbol the query asked about. It is stated once per @@ -352,7 +355,10 @@ type referenceFileCount struct { } type findReferencesOptions struct { - Selector symbolSelector + implementationsOnly bool + implementationDetection string + implementationPaths []string + Selector symbolSelector // Name is the unqualified name to resolve to its one declaration. It is // resolved against the snapshot, so it never reaches the query hash: the // hash covers the qualified name it resolved to, and a page stays valid @@ -369,6 +375,8 @@ type findReferencesOptions struct { } type findReferencesQuery struct { + Paths []string `json:"paths,omitempty"` + Detection string `json:"detection,omitempty"` Tool string `json:"tool"` StableKey string `json:"stable_key,omitempty"` QualifiedName string `json:"qualified_name,omitempty"` @@ -462,7 +470,7 @@ func RegisterFindReferencesWithObserverAndSnapshotStore( // is 2,480 tokens against 912 for the same files, the same precision and // the same recall -- and one page instead of two where 66 references // collapse into 9 files. Both were already supported; nothing said so. - Description: "Who calls or references a symbol, or what it uses with direction outgoing. Type-checked, not name-matched: grep cannot separate homonyms, and an empty answer means nobody calls it. A bare name suffices.", + Description: "Typed references to a symbol; direction outgoing shows its uses. A bare name suffices. Check completeness before interpreting absence.", Annotations: readOnlyClosedWorld(), Meta: alwaysLoadMeta(), }, handler) @@ -496,7 +504,7 @@ func findReferencesAcrossProfiles( Tool string `json:"tool"` Profiles []string `json:"profiles"` Query FindReferencesInput `json:"query"` - }{findReferencesToolName, names, queryArguments}) + }{arguments.queryToolName(), names, queryArguments}) if err != nil { return nil, Response[ReferenceResult]{}, err } @@ -638,7 +646,7 @@ func findReferences( } queryHash, err := HashQuery(findReferencesQuery{ - Tool: findReferencesToolName, StableKey: options.Selector.StableKey, + Tool: arguments.queryToolName(), Detection: arguments.implementationDetection, Paths: arguments.implementationPaths, StableKey: options.Selector.StableKey, QualifiedName: options.Selector.QualifiedName, Repository: options.Selector.Repository, Path: options.Selector.Path, Direction: options.Direction, Repo: options.Repo, Language: options.Language, EdgeKinds: options.EdgeKinds.applied, @@ -754,7 +762,12 @@ func findReferences( subjectRepository = file.Repository } } - completeness, unresolvedRelated, err := completenessFor(snapshot, subject.Name, subjectRepository) + completeness, unresolvedRelated, err := completenessFor(snapshot, subject.Name, func() hotsnapshot.RepositoryID { + if options.implementationsOnly { + return hotsnapshot.InvalidRepositoryID + } + return subjectRepository + }()) if err != nil { return nil, Response[ReferenceResult]{}, WrapToolError( CodeSnapshotUnavailable, @@ -865,6 +878,7 @@ func normalizeFindReferencesInput(arguments FindReferencesInput) (findReferences limit = MaximumReferenceLimit } return findReferencesOptions{ + implementationsOnly: arguments.implementationsOnly, implementationDetection: arguments.implementationDetection, implementationPaths: arguments.implementationPaths, Selector: selector, Name: name, View: view, Direction: direction, Repo: repo, Language: language, EdgeKinds: edgeKinds, Confidence: confidence, Limit: limit, Derived: newDerivedFilter(arguments.IncludeDerived, repo), @@ -1069,6 +1083,25 @@ func referenceMatches( decoded decodedReferenceEdge, options findReferencesOptions, ) (bool, error) { + if options.implementationsOnly && (!decoded.Confidence.Exact() || (options.implementationDetection != "" && implementationDetection(decoded.Provenance) != options.implementationDetection)) { + return false, nil + } + if options.implementationsOnly && len(options.implementationPaths) > 0 { + _, file, _, _, err := symbolReferenceLocation(snapshot, sourceID) + if err != nil { + return false, err + } + matches := false + for _, prefix := range options.implementationPaths { + if file.path == prefix || strings.HasPrefix(file.path, strings.TrimSuffix(prefix, "/")+"/") { + matches = true + break + } + } + if !matches { + return false, nil + } + } if !options.EdgeKinds.keeps(string(decoded.Kind)) { return false, nil } @@ -1362,7 +1395,7 @@ func referenceCandidates( for _, edge := range direct { candidates = append(candidates, referenceCandidate{edge: edge}) } - if options.Direction == FindReferencesDirectionOutgoing { + if options.Direction == FindReferencesDirectionOutgoing || options.implementationsOnly { return candidates, nil, nil } implemented, implementsCode, err := solelyImplementedMethods(snapshot, startID) @@ -1421,3 +1454,10 @@ func solelyImplementedMethods( } return out, implementsCode, nil } + +func (arguments FindReferencesInput) queryToolName() string { + if arguments.implementationsOnly { + return findImplementationsToolName + ":" + arguments.implementationDetection + ":" + strings.Join(arguments.implementationPaths, "\x00") + } + return findReferencesToolName +} diff --git a/internal/mcp/tools/find_symbol.go b/internal/mcp/tools/find_symbol.go index e804c59d..c01a509a 100644 --- a/internal/mcp/tools/find_symbol.go +++ b/internal/mcp/tools/find_symbol.go @@ -37,17 +37,17 @@ const ( // field-per-row shape of SymbolSummary. `files` is rejected here: find_symbol // answers declarations, not files. type FindSymbolInput struct { - Profile []string `json:"profile,omitempty" jsonschema:"Profiles to query; omit for the default, or use * alone for all."` + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` Name string `json:"name" jsonschema:"The name to look for, matched the way mode says."` Mode string `json:"mode,omitempty" jsonschema:"How name is matched: exact (the default), qualified_exact, prefix or substring."` - Kind string `json:"kind,omitempty" jsonschema:"Keep only symbols of this kind, such as function, struct or interface."` + Kind string `json:"kind,omitempty" jsonschema:"Filter kind, e.g. function, struct or interface."` Repo string `json:"repo,omitempty" jsonschema:"Keep only symbols from this repository. Naming the derived provider also opts it in."` IncludeDerived bool `json:"include_derived,omitempty" jsonschema:"Include symbols of the derived provider, which is withheld by default."` PathPrefix string `json:"path_prefix,omitempty" jsonschema:"Keep only symbols under this repository-relative path prefix."` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (the default) omits the derived identifiers; detailed returns them."` - View string `json:"view,omitempty" jsonschema:"Granularity, never a different answer: compact (the default) or full. files is rejected, since this answer is declarations."` + ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (default), or detailed with derived identifiers."` + View string `json:"view,omitempty" jsonschema:"compact (default) or full declarations; files is unsupported."` Limit int `json:"limit,omitempty" jsonschema:"Declarations in one page. Defaults to 50."` - Cursor string `json:"cursor,omitempty" jsonschema:"The next_cursor of the previous page. Every other argument must stay the same."` + Cursor string `json:"cursor,omitempty" jsonschema:"Previous next_cursor; keep other arguments unchanged."` } // SymbolSummary is the stable public result shape for symbol discovery. It diff --git a/internal/mcp/tools/get_source.go b/internal/mcp/tools/get_source.go index 0ed57059..96a0b83e 100644 --- a/internal/mcp/tools/get_source.go +++ b/internal/mcp/tools/get_source.go @@ -41,10 +41,10 @@ const ( // key, or the repository, path and qualified name of a row the caller already // read. type GetSourceInput struct { - Profile []string `json:"profile,omitempty" jsonschema:"Profiles to query; omit for the default, or use * alone for all."` + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` Symbols []SourceRequest `json:"symbols" jsonschema:"The symbols whose code you want, up to 20 in one call, across any files and repositories."` ContextLines int `json:"context_lines,omitempty" jsonschema:"Source lines to add around each declaration. Defaults to 0, maximum 100."` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (the default) omits the derived identifiers; detailed returns them."` + ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (default), or detailed with derived identifiers."` } // SourceRequest names one symbol. @@ -151,7 +151,7 @@ func RegisterGetSourceWithObserverAndSnapshotStore( } addQueryTool(server, &sdkmcp.Tool{ Name: getSourceToolName, - Description: "The code of several symbols in one call. Prefer it to reading each range: no line numbers, one call across files and repositories.", + Description: "Source of several symbols across files and repositories in one call; no line numbers needed.", Annotations: readOnlyClosedWorld(), Meta: alwaysLoadMeta(), }, handler) diff --git a/internal/mcp/tools/get_symbol.go b/internal/mcp/tools/get_symbol.go index 4ea73baa..66a83f2d 100644 --- a/internal/mcp/tools/get_symbol.go +++ b/internal/mcp/tools/get_symbol.go @@ -16,12 +16,12 @@ const getSymbolToolName = "get_symbol" // GetSymbolInput identifies one symbol, either by its durable stable key or by // the repository, path and qualified name every row of this surface carries. type GetSymbolInput struct { - Profile []string `json:"profile,omitempty" jsonschema:"Profiles to query; omit for the default, or use * alone for all."` + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` StableKey string `json:"stable_key,omitempty" jsonschema:"The symbol durable key, as a detailed result returns it. The triple below works instead."` QualifiedName string `json:"qualified_name,omitempty" jsonschema:"The symbol fully qualified name, as every row of this surface carries it."` Repository string `json:"repository,omitempty" jsonschema:"The repository that declares the symbol. Pass it with qualified_name and path."` Path string `json:"path,omitempty" jsonschema:"The repository-relative file that declares the symbol."` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (the default) omits the derived identifiers; detailed returns them."` + ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (default), or detailed with derived identifiers."` } // SymbolDetails is the public detail shape returned for one symbol. The diff --git a/internal/mcp/tools/repositories.go b/internal/mcp/tools/repositories.go index 00e10900..7971096b 100644 --- a/internal/mcp/tools/repositories.go +++ b/internal/mcp/tools/repositories.go @@ -43,7 +43,7 @@ type RepositorySummary struct { // list_repositories. type ListRepositoriesInput struct { Profile []string `json:"profile,omitempty" jsonschema:"Profiles to list; omit or use * alone for all."` - Cursor string `json:"cursor,omitempty" jsonschema:"The next_cursor of the previous page. Every other argument must stay the same."` + Cursor string `json:"cursor,omitempty" jsonschema:"Previous next_cursor; keep other arguments unchanged."` Limit int `json:"limit,omitempty" jsonschema:"Repositories in one page. Defaults to 50, maximum 500."` } diff --git a/internal/mcp/tools/status.go b/internal/mcp/tools/status.go index 85e3659e..f003708c 100644 --- a/internal/mcp/tools/status.go +++ b/internal/mcp/tools/status.go @@ -253,7 +253,7 @@ func RegisterGraphStatusWithObserverAndSnapshotStoreAndMetrics( } addQueryTool(server, &sdkmcp.Tool{ Name: graphStatusToolName, - Description: "The published generation: counts, provenance, and whether a repository moved since it was indexed. Call it when an answer looks stale.", + Description: "Published generation, counts, provenance and repository freshness. Use to diagnose stale answers.", Annotations: readOnlyClosedWorld(), }, handler) } diff --git a/internal/mcp/tools/trace_dependencies.go b/internal/mcp/tools/trace_dependencies.go index 532e4f91..5bb4c6f9 100644 --- a/internal/mcp/tools/trace_dependencies.go +++ b/internal/mcp/tools/trace_dependencies.go @@ -42,7 +42,7 @@ const ( // applied, because a route missing a link is not a route. "compact" is refused // for the same reason: it groups rows by file, and the order is the answer. type TraceDependenciesInput struct { - Profile []string `json:"profile,omitempty" jsonschema:"Profiles to query; omit for the default, or use * alone for all."` + Profile []string `json:"profile,omitempty" jsonschema:"Profiles; omit for default or use * alone for all."` StableKey string `json:"stable_key,omitempty" jsonschema:"The root symbol durable key, as a detailed result returns it. The triple works instead."` QualifiedName string `json:"qualified_name,omitempty" jsonschema:"The root symbol fully qualified name, as every row of this surface carries it."` Repository string `json:"repository,omitempty" jsonschema:"The repository that declares the root symbol."` @@ -57,8 +57,8 @@ type TraceDependenciesInput struct { Confidence string `json:"confidence,omitempty" jsonschema:"Follow only edges resolved at this confidence, such as EXACT_TYPECHECKED. It gates what is reachable."` IncludeDerived bool `json:"include_derived,omitempty" jsonschema:"Include reached symbols of the derived provider, which is withheld by default. Refused with to."` Limit int `json:"limit,omitempty" jsonschema:"Reached symbols in one page. Defaults to 50."` - Cursor string `json:"cursor,omitempty" jsonschema:"The next_cursor of the previous page. Every other argument must stay the same."` - ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (the default) omits the derived identifiers; detailed returns them."` + Cursor string `json:"cursor,omitempty" jsonschema:"Previous next_cursor; keep other arguments unchanged."` + ResponseFormat string `json:"response_format,omitempty" jsonschema:"concise (default), or detailed with derived identifiers."` View string `json:"view,omitempty" jsonschema:"Granularity, never a different answer: compact (the default) or full. files is rejected, and compact is refused with to, since the order is the answer."` } diff --git a/landing/src/content/docs/docs/mcp-tools.md b/landing/src/content/docs/docs/mcp-tools.md index 5a9fe1ef..18880dc4 100644 --- a/landing/src/content/docs/docs/mcp-tools.md +++ b/landing/src/content/docs/docs/mcp-tools.md @@ -20,6 +20,7 @@ open the database for those queries and does not run the TypeScript worker. | --- | --- | | I do not know its name; which files do I open | [`find_by_intent`](/docs/tools/find-by-intent/) | | Who calls this, what references this | [`find_references`](/docs/tools/find-references/) | +| Who implements a type or method | [`find_implementations`](/docs/tools/find-implementations/) | | What breaks if I change this | [`get_blast_radius`](/docs/tools/get-blast-radius/) | | What does this reach outwards | [`trace_dependencies`](/docs/tools/trace-dependencies/) | | Who uses it from another repository | [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) | @@ -65,6 +66,7 @@ Read-only, symbols and source: Read-only, graph traversal: - [`find_references`](/docs/tools/find-references/) +- [`find_implementations`](/docs/tools/find-implementations/) - [`find_cross_repo_consumers`](/docs/tools/find-cross-repo-consumers/) - [`trace_dependencies`](/docs/tools/trace-dependencies/) - [`get_blast_radius`](/docs/tools/get-blast-radius/) diff --git a/landing/src/content/docs/docs/tools/find-implementations.md b/landing/src/content/docs/docs/tools/find-implementations.md new file mode 100644 index 00000000..d94c4624 --- /dev/null +++ b/landing/src/content/docs/docs/tools/find-implementations.md @@ -0,0 +1,31 @@ +--- +title: find_implementations +description: Find compiler-proven implementations of types and methods, with generation and coverage. +--- + +`find_implementations` returns typed implementations of an interface, abstract +type or method. Go uses existing `go/types` relations. TypeScript includes both +declared relationships and structurally compatible concrete class instances. +The compiler decides compatibility during indexing. + +```json +{"name":"Reader","repository":"my-library","limit":50} +``` + +Use `stable_key`, a bare `name`, or `repository`, `path` and `qualified_name` to +select the subject. `repo`, `language`, `paths` and `detection` filter result +rows. Detection accepts `declared` or `structural`; omitting it includes every +supported typed mechanism. `paths` contains repository-relative files or +directories. `profile` selects one or more independently indexed graphs. + +The result contains `subject` and `implementations`. Each row carries its +canonical location, stable key, relationship kind, confidence, provenance and +detection. The envelope includes generation, totals, completeness and a +`next_cursor`. Keep filters unchanged on the next page. A changed generation +requires a fresh first page. + +Read `completeness` before interpreting zero rows. `LOWER_BOUND` identifies an +incomplete analysis or a legacy generation; it cannot establish absence. +`COMPLETE` applies only to the analyzed corpus and observed type instances. +Unknown generic arguments are never replaced with `any`. Inferred sources and +provider members without canonical source identity carry explicit limitations. From 4170ff3ff731fe47870ed32463b2f3cf5cb4df4d Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 18:02:41 +0200 Subject: [PATCH 09/18] fix: adapt implementation queries to current main --- internal/config/profile_upgrade_test.go | 16 ++++++++++++++++ internal/config/profiles.go | 21 ++++++++++++++++++++- internal/mcp/tools/find_implementations.go | 2 +- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/internal/config/profile_upgrade_test.go b/internal/config/profile_upgrade_test.go index f7f12bbd..807ab223 100644 --- a/internal/config/profile_upgrade_test.go +++ b/internal/config/profile_upgrade_test.go @@ -181,6 +181,22 @@ func TestProfileUpgradeResumesOnlyAnIdenticalBackup(t *testing.T) { } } +func TestReadProfileCannotMigrateLegacyState(t *testing.T) { + path, state := legacyProfileFixture(t) + if _, err := ReadProfile(path, ""); err == nil { + t.Fatalf("ReadProfile(config=%q) accepted unmigrated state", path) + } + for _, candidate := range []string{ + filepath.Join(state, "profiles"), + state + ".pre-profiles", + state + ".profile-migration.lock", + } { + if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("ReadProfile(config=%q) created %q: %v", path, candidate, err) + } + } +} + func TestProfileUpgradeRejectsUnsafeArtifactsWithoutPublication(t *testing.T) { for _, kind := range []string{"current-parent", "current-file", "graph-symlink", "unexpected-socket", "unreadable-graph"} { t.Run(kind, func(t *testing.T) { diff --git a/internal/config/profiles.go b/internal/config/profiles.go index 7fdd75b2..7fdafbbd 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -341,6 +341,10 @@ func ListProfiles(configPath string) ([]Profile, error) { if err := ensureDefaultProfile(configuration, configuration.Workspace.RepositoriesFile); err != nil { return nil, err } + return listProfiles(configuration) +} + +func listProfiles(configuration Config) ([]Profile, error) { entries, err := os.ReadDir(profilesRoot(configuration)) if err != nil { return nil, fmt.Errorf("list profiles: %w", err) @@ -372,6 +376,16 @@ func ListProfiles(configPath string) ([]Profile, error) { // its independently published graph. Analyzer targets, the event log and the // content-addressed fact cache remain shared at installation scope. func LoadProfile(configPath, name string) (Loaded, error) { + return loadProfile(configPath, name, true) +} + +// ReadProfile reads an already migrated profile without creating directories, +// acquiring write locks, or initiating a migration. +func ReadProfile(configPath, name string) (Loaded, error) { + return loadProfile(configPath, name, false) +} + +func loadProfile(configPath, name string, migrate bool) (Loaded, error) { loaded, err := Load(configPath) if err != nil { return Loaded{}, err @@ -383,7 +397,12 @@ func LoadProfile(configPath, name string) (Loaded, error) { return Loaded{}, fmt.Errorf("profile name: %w", err) } profile := profileAt(loaded.Config, name) - profiles, err := ListProfiles(configPath) + if migrate { + if err := ensureDefaultProfile(loaded.Config, loaded.RepositoriesPath); err != nil { + return Loaded{}, err + } + } + profiles, err := listProfiles(loaded.Config) if err != nil { return Loaded{}, err } diff --git a/internal/mcp/tools/find_implementations.go b/internal/mcp/tools/find_implementations.go index 36dd1c16..8f975a88 100644 --- a/internal/mcp/tools/find_implementations.go +++ b/internal/mcp/tools/find_implementations.go @@ -111,7 +111,7 @@ func RegisterFindImplementationsWithObserverAndSnapshotStore(server *sdkmcp.Serv handler := func(ctx context.Context, request *sdkmcp.CallToolRequest, arguments FindImplementationsInput) (*sdkmcp.CallToolResult, Response[ImplementationResult], error) { start := time.Now() result, response, err := findImplementations(ctx, request, arguments, store) - observe(observer, firstCallObserver(callObservers), findImplementationsToolName, start, response, err) + observe(observer, firstCallObserver(callObservers), findImplementationsToolName, request, start, response, err) return result, response, err } addQueryTool(server, &sdkmcp.Tool{Name: findImplementationsToolName, Description: "Implementations of types or methods, including structural TypeScript. Paged, with compiler evidence and coverage.", Annotations: readOnlyClosedWorld(), Meta: alwaysLoadMeta()}, handler) From d449544269001f0502950960c86b96e9ecd0aef6 Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 18:13:39 +0200 Subject: [PATCH 10/18] fix: address reduced PR review findings --- docs/adr/0116-typed-implementation-queries.md | 12 +++---- docs/protocol/mcp-surface-v3.md | 6 ++-- internal/config/profile_upgrade_test.go | 2 +- internal/dartloader/loader.go | 25 ++++++++++---- internal/dartloader/worker_test.go | 21 +++++++++++- internal/facts/typescript_test.go | 2 +- .../mcp/tools/find_implementations_test.go | 8 ++--- .../storage/ladybug/canonical_schema_test.go | 2 +- ts-worker/src/facts-cli.ts | 13 +++---- ts-worker/src/implements-resolver.test.ts | 34 +++++++------------ 10 files changed, 75 insertions(+), 50 deletions(-) diff --git a/docs/adr/0116-typed-implementation-queries.md b/docs/adr/0116-typed-implementation-queries.md index 03bcab66..53c63e73 100644 --- a/docs/adr/0116-typed-implementation-queries.md +++ b/docs/adr/0116-typed-implementation-queries.md @@ -5,7 +5,7 @@ Status: accepted for local implementation, 2026-09-05. ## Decision Expose `find_implementations` as a read-only, single-text MCP query. It reads -IMPLEMENTS and OVERRIDES edges of one immutable published generation. It does +`IMPLEMENTS` and `OVERRIDES` edges of one immutable published generation. It does not reinterpret calls or name matches as implementations. Go retains its existing `go/types` proofs. TypeScript emits declared and structural proofs at index time through the installed native compiler's `isTypeAssignableTo`. @@ -29,18 +29,18 @@ missing source identity is recorded as a coverage limitation. `ts-facts-v5` adds `implementations` and `implementationLimitations`. Its relation rows reuse the proven local/provider target identity shape. The Go decoder also reads v4 for historical fixtures, with an explicit missing-analysis scope. -Provenance codes 30 and 31 append `TYPESCRIPT_IMPL_DECLARED` and +Provenance codes `30` and `31` append `TYPESCRIPT_IMPL_DECLARED` and `TYPESCRIPT_IMPL_STRUCTURAL`; existing codes and stable-key derivation do not -change. Canonical schema 5 requires a full rebuild and attests the new analysis +change. Canonical schema `5` requires a full rebuild and attests the new analysis pass. The previous database and matching executable remain the rollback pair. Query pages carry canonical locations, provenance, detection, confidence, generation, total, cursor and completeness. Cursors bind the query, filters, profiles and generation. Result path filters run before counting/pagination. Multi-profile queries retain independently indexed scopes. Legacy generations -return LOWER_BOUND, including when empty. New generations also return -LOWER_BOUND for recorded unresolved scopes across the analyzed corpus. An empty -COMPLETE result establishes absence only within that corpus and type-instance +return `LOWER_BOUND`, including when empty. New generations also return +`LOWER_BOUND` for recorded unresolved scopes across the analyzed corpus. An empty +`COMPLETE` result establishes absence only within that corpus and type-instance universe. Inferred files currently contribute symbols/references and explicitly do not attest implementation coverage. Compiler-error declarations and unknown provider method identities cannot become exact edges. diff --git a/docs/protocol/mcp-surface-v3.md b/docs/protocol/mcp-surface-v3.md index f2f5d50b..cbb40b01 100644 --- a/docs/protocol/mcp-surface-v3.md +++ b/docs/protocol/mcp-surface-v3.md @@ -294,9 +294,9 @@ leería como «aquí no hay nada declarado», que es una respuesta distinta. ## Implementaciones tipadas -`find_implementations` consulta relaciones IMPLEMENTS y OVERRIDES, con evidencia +`find_implementations` consulta relaciones `IMPLEMENTS` y `OVERRIDES`, con evidencia declarada o estructural de TypeScript. Devuelve `results.subject` e `results.implementations`, identidades canónicas, generación, cursor y completitud. Los filtros `repo`, `language`, `paths` y `detection` se aplican -antes de paginar. Una generación anterior al esquema 5 devuelve LOWER_BOUND. -Véase ADR 0116 para el ámbito de tipos y la compatibilidad del protocolo. +antes de paginar. Una generación anterior al esquema `5` devuelve `LOWER_BOUND`. +Véase ADR `0116` para el ámbito de tipos y la compatibilidad del protocolo. diff --git a/internal/config/profile_upgrade_test.go b/internal/config/profile_upgrade_test.go index 807ab223..41096679 100644 --- a/internal/config/profile_upgrade_test.go +++ b/internal/config/profile_upgrade_test.go @@ -106,7 +106,7 @@ func TestProfileUpgradeRefusesActiveWritersAndCanRetry(t *testing.T) { } t.Cleanup(func() { _ = lock.Release() }) if _, err := LoadProfile(path, ""); err == nil { - t.Fatal("migrated during active write") + t.Fatalf("migrated while %q was held", name) } if _, err := os.Stat(filepath.Join(state, "profiles", "default")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("published on refusal: %v", err) diff --git a/internal/dartloader/loader.go b/internal/dartloader/loader.go index a199eef8..7e5928d0 100644 --- a/internal/dartloader/loader.go +++ b/internal/dartloader/loader.go @@ -547,15 +547,30 @@ func readAnalyzerMessages(reader io.Reader, output chan<- analyzerMessage, done for scanner.Scan() { var message analyzerMessage if json.Unmarshal(scanner.Bytes(), &message) == nil && (message.ID != "" || message.Event != "") { - select { - case output <- message: - case <-done: + if !deliverBeforeShutdown(output, message, done) { return } } } } +// deliverBeforeShutdown preserves a decoded final frame when the worker exit +// and the reader become observable together. It still lets an undrained reader +// stop once delivering the frame would block. +func deliverBeforeShutdown[T any](output chan<- T, value T, done <-chan struct{}) bool { + select { + case output <- value: + return true + default: + } + select { + case output <- value: + return true + case <-done: + return false + } +} + func appendNavigationReferences(ctx context.Context, lsp *client, command, sdkPath, root string, files []string, contents map[string][]byte, byURI map[string][]facts.SemanticSymbol, symbols []facts.SemanticSymbol, selectionOffsets map[string]int, providers []workspace.Repository, packageRoots []string, waitForAnalysis bool, payload *facts.SemanticPayload) error { server, err := startAnalyzer(ctx, command, sdkPath, root) if err != nil { @@ -1178,9 +1193,7 @@ func readMessages(reader io.Reader, output chan<- rpcMessage, done <-chan struct } if json.Unmarshal(body, &envelope) == nil { message = rpcMessage{ID: envelope.ID, Method: envelope.Method, Result: envelope.Result, Error: envelope.Error} - select { - case output <- message: - case <-done: + if !deliverBeforeShutdown(output, message, done) { return } } diff --git a/internal/dartloader/worker_test.go b/internal/dartloader/worker_test.go index 3cf5d5d9..48aaf549 100644 --- a/internal/dartloader/worker_test.go +++ b/internal/dartloader/worker_test.go @@ -70,6 +70,25 @@ func TestDartReaderStopsWhenNobodyDrainsNotifications(t *testing.T) { select { case <-finished: case <-time.After(time.Second): - t.Fatal("reader leaked on a full output channel") + t.Fatal("analyzer reader did not stop after done closed while output was blocked") + } +} + +func TestDartReadersDeliverDecodedFrameBeforeObservedShutdown(t *testing.T) { + done := make(chan struct{}) + close(done) + + analyzerOutput := make(chan analyzerMessage, 1) + readAnalyzerMessages(strings.NewReader(`{"event":"server.status"}`+"\n"), analyzerOutput, done) + if message, ok := <-analyzerOutput; !ok || message.Event != "server.status" { + t.Fatalf("analyzer final frame = %#v, open = %v", message, ok) + } + + lspOutput := make(chan rpcMessage, 1) + body := `{"jsonrpc":"2.0","id":1,"result":{"ready":true}}` + framed := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body) + readMessages(strings.NewReader(framed), lspOutput, done) + if message, ok := <-lspOutput; !ok || string(message.ID) != "1" { + t.Fatalf("LSP final frame = %#v, open = %v", message, ok) } } diff --git a/internal/facts/typescript_test.go b/internal/facts/typescript_test.go index 45813091..820d6259 100644 --- a/internal/facts/typescript_test.go +++ b/internal/facts/typescript_test.go @@ -1355,7 +1355,7 @@ func TestEscapesRepositoryReadsThePathAndNotItsSpelling(t *testing.T) { func TestNormalizeTypeScriptImplementationEvidence(t *testing.T) { data, err := os.ReadFile(filepath.Join("..", "..", "testdata", "protocol", "ts-facts-v5", "implementations.json")) if err != nil { - t.Fatal(err) + t.Fatalf("read implementations.json: %v", err) } payload, err := DecodeTypeScriptPayload(data) if err != nil { diff --git a/internal/mcp/tools/find_implementations_test.go b/internal/mcp/tools/find_implementations_test.go index 8ec81ddd..50965a03 100644 --- a/internal/mcp/tools/find_implementations_test.go +++ b/internal/mcp/tools/find_implementations_test.go @@ -10,7 +10,7 @@ func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { args := FindImplementationsInput{StableKey: "iface-shared", Limit: 1} _, first, err := findImplementations(context.Background(), nil, args, store) if err != nil { - t.Fatal(err) + t.Fatalf("find implementations with %#v: %v", args, err) } if first.Total != 2 || first.Returned != 1 || first.NextCursor == nil { t.Fatalf("page: %#v", first) @@ -21,18 +21,18 @@ func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { args.Cursor = *first.NextCursor _, second, err := findImplementations(context.Background(), nil, args, store) if err != nil || second.Returned != 1 || second.NextCursor != nil { - t.Fatalf("second: %#v %v", second, err) + t.Fatalf("second page with %#v: %#v %v", args, second, err) } if first.Results.Implementations[0].StableKey == second.Results.Implementations[0].StableKey { t.Fatal("duplicate page") } args.Detection = "declared" if _, _, err := findImplementations(context.Background(), nil, args, store); err == nil { - t.Fatal("cursor accepted changed filters") + t.Fatalf("cursor accepted changed filters: %#v", args) } args.Detection = "" if _, _, err := findImplementations(context.Background(), nil, args, dispatchSnapshot(t, 201, 2)); err == nil { - t.Fatal("cursor crossed generations") + t.Fatalf("cursor crossed generations: %#v", args) } _, concrete, err := findImplementations(context.Background(), nil, FindImplementationsInput{StableKey: "impl-sole"}, store) if err != nil || concrete.Total != 0 { diff --git a/internal/storage/ladybug/canonical_schema_test.go b/internal/storage/ladybug/canonical_schema_test.go index 2d437829..3270220b 100644 --- a/internal/storage/ladybug/canonical_schema_test.go +++ b/internal/storage/ladybug/canonical_schema_test.go @@ -15,7 +15,7 @@ func TestCanonicalSchemaFileMatchesTheMetadata(t *testing.T) { path := filepath.Join("..", "..", "..", "schemas", "ladybug", "005-canonical.cypher") contents, err := os.ReadFile(path) if err != nil { - t.Fatalf("read canonical schema: %v", err) + t.Fatalf("read canonical schema %q: %v", path, err) } if string(contents) != CanonicalSchemaDocument() { t.Fatalf("%s is out of date; regenerate it from CanonicalSchemaDocument", path) diff --git a/ts-worker/src/facts-cli.ts b/ts-worker/src/facts-cli.ts index 2df3e11e..98708673 100644 --- a/ts-worker/src/facts-cli.ts +++ b/ts-worker/src/facts-cli.ts @@ -394,6 +394,11 @@ export async function collectFacts( extendsResolution.extends, manifest?.name ?? repositoryName, ); + const implementationFacts = extendsFactSymbols( + root, + implementationResolution.edges, + manifest?.name ?? repositoryName, + ).extends; const dependencyEvidenceFiles = dependencyResolution.dependencies .map((dependency) => dependency.imports[0]?.fileName) @@ -522,12 +527,8 @@ export async function collectFacts( }), exports: exportSymbols.exports, extends: extendsFacts.extends, - implementations: implementationResolution.edges.map((edge) => { - const fact = extendsFactSymbols( - root, - [edge], - manifest?.name ?? repositoryName, - ).extends[0]; + implementations: implementationResolution.edges.map((edge, index) => { + const fact = implementationFacts[index]; if (fact === undefined) throw new Error("implementation normalization omitted an edge"); return { ...fact, detection: edge.detection, relation: edge.relation }; diff --git a/ts-worker/src/implements-resolver.test.ts b/ts-worker/src/implements-resolver.test.ts index 96e3a7f7..25bba563 100644 --- a/ts-worker/src/implements-resolver.test.ts +++ b/ts-worker/src/implements-resolver.test.ts @@ -86,27 +86,19 @@ export type TextBox = Box; `); const result = await resolveImplementations(service, view, symbols, []); expect( - result.edges.some( - (edge) => - edge.base.sourceQualifiedName === "StringBox" && - edge.targetQualifiedName === "Box" && - edge.detection === "declared", - ), - ).toBe(true); - expect( - result.edges.some( - (edge) => - edge.base.sourceQualifiedName === "Generic" && - edge.targetQualifiedName === "TextBox", - ), - ).toBe(true); - expect( - result.edges.some( - (edge) => - edge.base.sourceQualifiedName === "NumericBox" && - edge.targetQualifiedName === "TextBox", - ), - ).toBe(false); + result.edges.map((edge) => [ + edge.base.sourceQualifiedName, + edge.targetQualifiedName, + edge.detection, + ]), + ).toEqual([ + ["Generic", "Box", "structural"], + ["Generic", "TextBox", "structural"], + ["Generic.get", "Box.get", "structural"], + ["StringBox", "Box", "declared"], + ["StringBox", "TextBox", "structural"], + ["StringBox.get", "Box.get", "declared"], + ]); }); it("retains canonical provider identities for imported interfaces and methods", async () => { From e7f9770cb4945c855a91af51a4e5adfada2ce1c0 Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 18:29:26 +0200 Subject: [PATCH 11/18] fix: harden profile migration and implementation indexing --- README.md | 2 +- internal/config/profile_upgrade_test.go | 10 ++++- internal/config/profiles.go | 12 +++-- internal/dartloader/worker.go | 2 +- internal/dartloader/worker_test.go | 12 ++++- internal/facts/codes.go | 4 +- internal/facts/typescript.go | 21 +++++---- internal/facts/typescript_test.go | 29 ++++++++++++ internal/integrations/hooks_test.go | 21 +++++++++ .../mcp/tools/find_implementations_test.go | 2 +- ts-worker/src/facts-cli.ts | 7 ++- ts-worker/src/implements-resolver.test.ts | 21 ++++++--- ts-worker/src/implements-resolver.ts | 45 +++++++++++++++++-- 13 files changed, 156 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 41e6da66..4858f313 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ checkout, which is what a reader on a fork or without a network still has. | everything about this one symbol | `get_symbol` | | what is indexed, and is the graph current | `list_repositories`, `graph_status` | -Eleven read-only tools, plus one consent-gated mutation (`index_project`) that a +Twelve read-only tools, plus one consent-gated mutation (`index_project`) that a client has to authorize before it can register a repository or publish a generation. diff --git a/internal/config/profile_upgrade_test.go b/internal/config/profile_upgrade_test.go index 41096679..e1e5ca6c 100644 --- a/internal/config/profile_upgrade_test.go +++ b/internal/config/profile_upgrade_test.go @@ -172,10 +172,16 @@ func TestProfileUpgradeResumesOnlyAnIdenticalBackup(t *testing.T) { } _, err := LoadProfile(path, "") if changed && err == nil { - t.Fatal("overwrote mismatched recovery point") + t.Fatalf("changed=%t: overwrote mismatched recovery point", changed) + } + if changed { + contents, readErr := os.ReadFile(filepath.Join(state+".pre-profiles", "CURRENT")) + if readErr != nil || string(contents) != "different\n" { + t.Fatalf("changed=%t: recovery CURRENT = %q, %v", changed, contents, readErr) + } } if !changed && err != nil { - t.Fatalf("resume: %v", err) + t.Fatalf("changed=%t: resume: %v", changed, err) } }) } diff --git a/internal/config/profiles.go b/internal/config/profiles.go index 7fdafbbd..93271aa5 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -283,13 +283,19 @@ func copyProfileArtifact(source, destination string) error { if err != nil { return fmt.Errorf("read legacy profile artifact %q: %w", source, err) } - defer input.Close() + openedInfo, err := input.Stat() + if err != nil { + return errors.Join(fmt.Errorf("inspect opened legacy profile artifact %q: %w", source, err), input.Close()) + } + if !openedInfo.Mode().IsRegular() || !os.SameFile(info, openedInfo) { + return errors.Join(fmt.Errorf("legacy profile artifact %q changed while it was opened", source), input.Close()) + } output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) if err != nil { - return fmt.Errorf("create copied artifact %q: %w", destination, err) + return errors.Join(fmt.Errorf("create copied artifact %q: %w", destination, err), input.Close()) } _, copyErr := io.Copy(output, input) - if err := errors.Join(copyErr, output.Close()); err != nil { + if err := errors.Join(copyErr, output.Close(), input.Close()); err != nil { return fmt.Errorf("copy legacy profile artifact %q: %w", source, err) } return nil diff --git a/internal/dartloader/worker.go b/internal/dartloader/worker.go index c4d8a613..e098a928 100644 --- a/internal/dartloader/worker.go +++ b/internal/dartloader/worker.go @@ -88,7 +88,7 @@ func (w *workerProcess) failure(phase string, cause error) error { } select { case <-w.done: - return fmt.Errorf("Dart analyzer %s: %v; process exit: %v; stderr: %s", phase, cause, w.exit, w.stderr.text()) + return fmt.Errorf("Dart analyzer %s: %w; process exit: %v; stderr: %s", phase, cause, w.exit, w.stderr.text()) case <-time.After(100 * time.Millisecond): return fmt.Errorf("Dart analyzer %s: %w; stderr: %s", phase, cause, w.stderr.text()) } diff --git a/internal/dartloader/worker_test.go b/internal/dartloader/worker_test.go index 48aaf549..619a4239 100644 --- a/internal/dartloader/worker_test.go +++ b/internal/dartloader/worker_test.go @@ -43,8 +43,16 @@ func TestDartWorkerFailurePreservesExitAndBoundedStderr(t *testing.T) { <-c.worker.done _, err = c.call(t.Context(), "analysis.getNavigation", map[string]any{}) } - if err == nil || !strings.Contains(err.Error(), "exit status 23") || !strings.Contains(err.Error(), "fixture analyzer crash") || len(err.Error()) > 17000 { - t.Fatalf("missing or unbounded worker diagnosis: %v", err) + if err == nil { + t.Fatalf("%s: request after worker exit returned no error", protocol) + } + for _, want := range []string{"exit status 23", "fixture analyzer crash"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%s: diagnosis missing %q: %v", protocol, want, err) + } + } + if size := len(err.Error()); size > 17000 { + t.Errorf("%s: diagnosis is %d bytes, want at most 17000", protocol, size) } }) } diff --git a/internal/facts/codes.go b/internal/facts/codes.go index 2d0d84fa..0559dad1 100644 --- a/internal/facts/codes.go +++ b/internal/facts/codes.go @@ -128,8 +128,6 @@ var confidenceCodes = map[Confidence]uint8{ } var provenanceCodes = map[Provenance]uint8{ - TypeScriptImplementationDeclared: CodeTypeScriptImplementationDeclared, - TypeScriptImplementationStructural: CodeTypeScriptImplementationStructural, TypeScriptChecker: CodeTypeScriptChecker, TypeScriptModuleResolution: CodeTypeScriptModuleResolution, TypeScriptDeclarationMap: CodeTypeScriptDeclarationMap, @@ -159,6 +157,8 @@ var provenanceCodes = map[Provenance]uint8{ JavaScipUse: CodeJavaScipUse, CSharpScipDefinition: CodeCSharpScipDefinition, CSharpScipUse: CodeCSharpScipUse, + TypeScriptImplementationDeclared: CodeTypeScriptImplementationDeclared, + TypeScriptImplementationStructural: CodeTypeScriptImplementationStructural, } // reverse builds the decoding table of a coding table. The tables are diff --git a/internal/facts/typescript.go b/internal/facts/typescript.go index 8e85857a..8fc0b1a1 100644 --- a/internal/facts/typescript.go +++ b/internal/facts/typescript.go @@ -19,12 +19,6 @@ const TypeScriptWireVersion = 5 // The worker reports identity components and positions; it never computes a // key. Deriving keys on a single side is what keeps one symbol from getting // two identities when a consumer and its provider are indexed separately. -type TypeScriptImplementation struct { - TypeScriptExtends - Detection string `json:"detection"` - Relation EdgeKind `json:"relation"` -} - type TypeScriptPayload struct { Version int `json:"version"` Repository TypeScriptRepository `json:"repository"` @@ -41,6 +35,12 @@ type TypeScriptPayload struct { Unresolved []TypeScriptUnresolved `json:"unresolved"` } +type TypeScriptImplementation struct { + TypeScriptExtends + Detection string `json:"detection"` + Relation EdgeKind `json:"relation"` +} + // TypeScriptRepository names the repository the payload belongs to. type TypeScriptRepository struct { Name string `json:"name"` @@ -294,7 +294,10 @@ type TypeScriptReport struct { // key the provider assigns its own declaration. What is retired here are the // uses whose **source** file is the provider's output -- facts about the // provider, which the provider's own pass is the one to report. -const UnresolvedFileOutsideRepository = "FILE_OUTSIDE_REPOSITORY" +const ( + UnresolvedFileOutsideRepository = "FILE_OUTSIDE_REPOSITORY" + UnresolvedImplementationCoverage = "IMPLEMENTATION_COVERAGE_PARTIAL" +) // escapesRepository reports whether a repository-relative path leaves its own // repository. Cleaning first is what makes `src/../../x` and `../x` the same @@ -761,7 +764,7 @@ func NormalizeTypeScript( } for _, impl := range payload.Implementations { if (impl.Relation != Implements && impl.Relation != Overrides) || (impl.Detection != "declared" && impl.Detection != "structural") { - return Set{}, TypeScriptReport{}, fmt.Errorf("%w: invalid implementation relation or detection", ErrInvalidFacts) + return Set{}, TypeScriptReport{}, fmt.Errorf("%w: implementation %q in %q has relation %q and detection %q", ErrInvalidFacts, impl.QualifiedName, impl.File, impl.Relation, impl.Detection) } relations = append(relations, relationFact{impl.TypeScriptExtends, impl.Relation, impl.Detection}) } @@ -863,7 +866,7 @@ func NormalizeTypeScript( limits = append(limits, "Legacy TypeScript worker did not analyze implementation relations; rebuild with ts-facts-v5.") } for _, detail := range limits { - set.Unresolved = append(set.Unresolved, UnresolvedReference{RepositoryKey: repositoryKey, Language: LanguageTypeScript, Reason: "IMPLEMENTATION_COVERAGE_PARTIAL", Detail: detail, RequestedPackage: payload.Package.Name}) + set.Unresolved = append(set.Unresolved, UnresolvedReference{RepositoryKey: repositoryKey, Language: LanguageTypeScript, Reason: UnresolvedImplementationCoverage, Detail: detail, RequestedPackage: payload.Package.Name}) } // PACKAGE_DEPENDS_ON needs no symbol lookup at all: both ends are the diff --git a/internal/facts/typescript_test.go b/internal/facts/typescript_test.go index 820d6259..6e3f2860 100644 --- a/internal/facts/typescript_test.go +++ b/internal/facts/typescript_test.go @@ -1392,8 +1392,37 @@ func TestNormalizeTypeScriptImplementationEvidence(t *testing.T) { if len(payload.Implementations) == 0 { t.Fatal("worker emitted no implementations") } + limited := payload + limited.ImplementationLimitations = []string{"provider source unavailable"} + limitedSet, _, err := NormalizeTypeScript(t.Context(), limited, workspace.Repository{RealPath: "/fixtures/implementations"}) + if err != nil { + t.Fatalf("NormalizeTypeScript(explicit limitation) error = %v", err) + } + if got := implementationCoverageRows(limitedSet.Unresolved); len(got) != 1 || got[0].Detail != "provider source unavailable" || got[0].RequestedPackage != "@fixture/implementations" { + t.Fatalf("explicit implementation limitations = %#v", got) + } + legacy := payload + legacy.Version = 4 + legacy.Implementations = nil + legacySet, _, err := NormalizeTypeScript(t.Context(), legacy, workspace.Repository{RealPath: "/fixtures/implementations"}) + if err != nil { + t.Fatalf("NormalizeTypeScript(v4) error = %v", err) + } + if got := implementationCoverageRows(legacySet.Unresolved); len(got) != 1 || got[0].Detail != "Legacy TypeScript worker did not analyze implementation relations; rebuild with ts-facts-v5." || got[0].RequestedPackage != "@fixture/implementations" { + t.Fatalf("legacy implementation limitations = %#v", got) + } payload.Implementations[0].Detection = "guessed" if _, _, err := NormalizeTypeScript(t.Context(), payload, workspace.Repository{RealPath: "/fixtures/implementations"}); err == nil { t.Fatal("unknown implementation evidence accepted") } } + +func implementationCoverageRows(rows []UnresolvedReference) []UnresolvedReference { + var coverage []UnresolvedReference + for _, row := range rows { + if row.Reason == UnresolvedImplementationCoverage { + coverage = append(coverage, row) + } + } + return coverage +} diff --git a/internal/integrations/hooks_test.go b/internal/integrations/hooks_test.go index af2b4e2a..0eb73cd0 100644 --- a/internal/integrations/hooks_test.go +++ b/internal/integrations/hooks_test.go @@ -650,6 +650,27 @@ func TestClaudeDesktopIsDetectedByItsOwnEntry(t *testing.T) { } } +func TestClaudeDesktopDetectionUsesConfiguredSystemRoot(t *testing.T) { + manager, _, _ := testManager(t) + entry := filepath.Join(manager.systemRoot, "Applications", "Claude.app") + if err := os.MkdirAll(entry, 0o700); err != nil { + t.Fatal(err) + } + detections, err := manager.DetectHookTargets(ScopeUser) + if err != nil { + t.Fatalf("DetectHookTargets() error = %v", err) + } + for _, detection := range detections { + if detection.Target == TargetClaudeDesktop { + if !detection.Detected { + t.Fatalf("system application below configured root %q was not detected", manager.systemRoot) + } + return + } + } + t.Fatal("claude-desktop is not offered as a hook target") +} + // TestOhMyPiProjectIsDetectedByItsAgentRoot keeps project selection from // treating the extension file as the installation marker. The project root is // what Oh My Pi owns even before a Kivgraph extension is written. diff --git a/internal/mcp/tools/find_implementations_test.go b/internal/mcp/tools/find_implementations_test.go index 50965a03..c030859f 100644 --- a/internal/mcp/tools/find_implementations_test.go +++ b/internal/mcp/tools/find_implementations_test.go @@ -24,7 +24,7 @@ func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { t.Fatalf("second page with %#v: %#v %v", args, second, err) } if first.Results.Implementations[0].StableKey == second.Results.Implementations[0].StableKey { - t.Fatal("duplicate page") + t.Fatalf("duplicate page for %#v: first=%q second=%q", args, first.Results.Implementations[0].StableKey, second.Results.Implementations[0].StableKey) } args.Detection = "declared" if _, _, err := findImplementations(context.Background(), nil, args, store); err == nil { diff --git a/ts-worker/src/facts-cli.ts b/ts-worker/src/facts-cli.ts index 98708673..0ec3d706 100644 --- a/ts-worker/src/facts-cli.ts +++ b/ts-worker/src/facts-cli.ts @@ -394,11 +394,14 @@ export async function collectFacts( extendsResolution.extends, manifest?.name ?? repositoryName, ); - const implementationFacts = extendsFactSymbols( + const implementationNormalization = extendsFactSymbols( root, implementationResolution.edges, manifest?.name ?? repositoryName, - ).extends; + ); + if (implementationNormalization.unresolved.length !== 0) + throw new Error("resolved implementations produced unresolved facts"); + const implementationFacts = implementationNormalization.extends; const dependencyEvidenceFiles = dependencyResolution.dependencies .map((dependency) => dependency.imports[0]?.fileName) diff --git a/ts-worker/src/implements-resolver.test.ts b/ts-worker/src/implements-resolver.test.ts index 25bba563..648c9338 100644 --- a/ts-worker/src/implements-resolver.test.ts +++ b/ts-worker/src/implements-resolver.test.ts @@ -85,6 +85,10 @@ export const instance = new Generic('value'); export type TextBox = Box; `); const result = await resolveImplementations(service, view, symbols, []); + const brute = await resolveImplementations(service, view, symbols, [], { + exhaustive: true, + }); + expect(result.edges).toEqual(brute.edges); expect( result.edges.map((edge) => [ edge.base.sourceQualifiedName, @@ -206,10 +210,15 @@ it("retains canonical provider identities for imported interfaces and methods", ["Structural.read", "Reader.read", "structural"], ]); expect( - result.edges.every( - (edge) => - edge.identity?.repository === "provider-repo" && - edge.identity?.file === "src/contracts.ts", - ), - ).toBe(true); + result.edges.map((edge) => [ + edge.base.sourceQualifiedName, + edge.identity?.repository, + edge.identity?.file, + ]), + ).toEqual([ + ["Declared", "provider-repo", "src/contracts.ts"], + ["Declared.read", "provider-repo", "src/contracts.ts"], + ["Structural", "provider-repo", "src/contracts.ts"], + ["Structural.read", "provider-repo", "src/contracts.ts"], + ]); }); diff --git a/ts-worker/src/implements-resolver.ts b/ts-worker/src/implements-resolver.ts index 5990f6ac..408e6d45 100644 --- a/ts-worker/src/implements-resolver.ts +++ b/ts-worker/src/implements-resolver.ts @@ -287,7 +287,12 @@ export async function resolveImplementations( const type = await providerView.checker.getDeclaredTypeOfSymbol( parent.symbol, ); - if (!valid(type)) continue; + if (!valid(type)) { + limitations.add( + "Provider declared types that fail validation contribute no member provenance.", + ); + continue; + } const members = new Map(); for (const member of await providerView.checker.getPropertiesOfType( type, @@ -362,11 +367,45 @@ export async function resolveImplementations( detection, relation: method ? "OVERRIDES" : "IMPLEMENTS", }; - const key = `${source.fileName}:${source.qualifiedName}:${target.local?.fileName ?? target.imported?.target.identity?.repository}:${target.local?.qualifiedName ?? target.imported?.target.identity?.qualifiedName}`; + const identity = target.imported?.target.identity; + const targetLocation = + target.local?.fileName ?? + (identity === undefined + ? undefined + : `${identity.repository}\u0000${identity.file}`); + const key = `${source.fileName}:${source.qualifiedName}:${targetLocation}:${target.local?.qualifiedName ?? identity?.qualifiedName}`; if (edges.get(key)?.detection !== "declared") edges.set(key, edge); } + + const targetIDsByRequiredName = new Map>(); + for (const target of targets.values()) { + for (const targetType of target.types.values()) { + for (const property of await props(targetType)) { + if ((property.flags & SymbolFlags.Optional) !== 0) continue; + const matching = + targetIDsByRequiredName.get(property.name) ?? new Set(); + matching.add(target.symbol.id); + targetIDsByRequiredName.set(property.name, matching); + } + } + } for (const source of sources.values()) { - for (const target of targets.values()) { + const candidateIDs = new Set(source.declared); + for (const sourceType of source.types.values()) { + for (const property of await props(sourceType)) { + for (const targetID of targetIDsByRequiredName.get(property.name) ?? + []) { + candidateIDs.add(targetID); + } + } + } + const candidates = options.exhaustive + ? targets.values() + : [...candidateIDs] + .sort((left, right) => left - right) + .map((targetID) => targets.get(targetID)) + .filter((target): target is Target => target !== undefined); + for (const target of candidates) { const detection = source.declared.has(target.symbol.id) ? "declared" : "structural"; From c916070c334afaaef69659babc8667e53be1b2a0 Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 18:46:14 +0200 Subject: [PATCH 12/18] fix: complete profile and implementation contracts --- README.md | 6 +- cmd/kivgraph/BUILD.bazel | 1 + cmd/kivgraph/doctor_profile_test.go | 39 +++++- cmd/kivgraph/main.go | 19 ++- docs/protocol/mcp-surface-v3.md | 4 +- internal/config/BUILD.bazel | 1 + internal/config/profiles.go | 22 ++- internal/facts/typescript_test.go | 6 +- internal/integrations/hooks_test.go | 2 +- internal/integrations/integrations.go | 5 + internal/integrations/integrations_test.go | 28 ++++ .../mcp/tools/find_implementations_test.go | 2 +- ts-worker/src/facts-cli.ts | 130 +++++++++++------- ts-worker/src/implements-resolver.test.ts | 39 ++++++ ts-worker/src/implements-resolver.ts | 25 +++- 15 files changed, 259 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 4858f313..a5936518 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ checkout, which is what a reader on a fork or without a network still has. | everything about this one symbol | `get_symbol` | | what is indexed, and is the graph current | `list_repositories`, `graph_status` | -Twelve read-only tools, plus one consent-gated mutation (`index_project`) that a -client has to authorize before it can register a repository or publish a -generation. +Twelve read-only tools, plus two consent-gated mutations (`index_project` and +`start_index_project`) that a client has to authorize before either can register +a repository or publish a generation. Every row that names a symbol carries its repository, path, qualified name and line range, so it can be opened without a second call, and every tool accepts diff --git a/cmd/kivgraph/BUILD.bazel b/cmd/kivgraph/BUILD.bazel index 78b04958..eb57f606 100644 --- a/cmd/kivgraph/BUILD.bazel +++ b/cmd/kivgraph/BUILD.bazel @@ -96,6 +96,7 @@ go_test( "complete_test.go", "configure_test.go", "daemon_test.go", + "doctor_profile_test.go", "endpoint_test.go", "events_test.go", "hook_test.go", diff --git a/cmd/kivgraph/doctor_profile_test.go b/cmd/kivgraph/doctor_profile_test.go index 85ef469f..3de140e1 100644 --- a/cmd/kivgraph/doctor_profile_test.go +++ b/cmd/kivgraph/doctor_profile_test.go @@ -36,10 +36,45 @@ func TestDoctorReadsServedProfileAndPreservesLegacyState(t *testing.T) { if code := runDoctor([]string{"--config", configPath}, &out, &diagnostic); code != 0 { t.Fatalf("doctor=%d\n%s\n%s", code, out.String(), diagnostic.String()) } - if !strings.Contains(out.String(), filepath.Dir(profile.Config.Storage.DatabasePath)) || !strings.Contains(out.String(), "graph.store: PASS (no published generation)") { - t.Fatal(out.String()) + profileRoot := filepath.Dir(profile.Config.Storage.DatabasePath) + if !strings.Contains(out.String(), profileRoot) { + t.Fatalf("doctor did not report profile root %q:\n%s", profileRoot, out.String()) + } + if !strings.Contains(out.String(), "graph.store: PASS (no published generation)") { + t.Fatalf("doctor did not report an unpublished graph.store:\n%s", out.String()) } if body, err := os.ReadFile(marker); err != nil || string(body) != "invalid-legacy-generation\n" { t.Fatalf("legacy state changed: %q %v", body, err) } } + +func TestDoctorReportsIncompleteProfilesAndContinues(t *testing.T) { + home := t.TempDir() + testsupport.SetHome(t, home) + configPath := filepath.Join(home, "config.yaml") + if _, err := config.Initialize(config.InitOptions{ConfigPath: configPath}); err != nil { + t.Fatal(err) + } + loaded, err := config.Load(configPath) + if err != nil { + t.Fatal(err) + } + profiles := filepath.Join(filepath.Dir(loaded.Config.Storage.DatabasePath), "profiles") + if err := os.RemoveAll(profiles); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(profiles, 0o700); err != nil { + t.Fatal(err) + } + + var out, diagnostic bytes.Buffer + if code := runDoctor([]string{"--config", configPath}, &out, &diagnostic); code != 1 { + t.Fatalf("doctor=%d, want 1\n%s\n%s", code, out.String(), diagnostic.String()) + } + if !strings.Contains(out.String(), "config: PASS") || !strings.Contains(out.String(), "profiles: FAIL") { + t.Fatalf("doctor did not preserve config checks and report profile failure:\n%s", out.String()) + } + if !strings.Contains(out.String(), "state.database_parent:") { + t.Fatalf("doctor stopped before installation checks:\n%s", out.String()) + } +} diff --git a/cmd/kivgraph/main.go b/cmd/kivgraph/main.go index 718ff514..e1c6306a 100644 --- a/cmd/kivgraph/main.go +++ b/cmd/kivgraph/main.go @@ -2065,18 +2065,22 @@ func doctorFlagSet(options *doctorOptions) *flag.FlagSet { // Doctor follows the same default profile as serve, without initiating a // migration during diagnosis. A legacy installation still has its old layout. -func readDoctorConfiguration(configPath string) (config.Loaded, error) { +func readDoctorConfiguration(configPath string) (config.Loaded, error, error) { loaded, err := config.Load(configPath) if err != nil { - return config.Loaded{}, err + return config.Loaded{}, nil, err } profiles := filepath.Join(filepath.Dir(loaded.Config.Storage.DatabasePath), "profiles") if _, err := os.Stat(profiles); errors.Is(err, os.ErrNotExist) { - return loaded, nil + return loaded, nil, nil } else if err != nil { - return config.Loaded{}, fmt.Errorf("inspect profiles: %w", err) + return config.Loaded{}, nil, fmt.Errorf("inspect profiles: %w", err) + } + profile, err := config.ReadProfile(configPath, "") + if err != nil { + return loaded, err, nil } - return config.ReadProfile(configPath, "") + return profile, nil, nil } func runDoctor(args []string, stdout, stderr io.Writer) int { @@ -2090,7 +2094,7 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { return 2 } - loaded, err := readDoctorConfiguration(options.ConfigPath) + loaded, profileErr, err := readDoctorConfiguration(options.ConfigPath) if err != nil { writeResult(stdout, false, "config: FAIL (%v)", err) writeResult(stdout, false, "doctor: FAIL") @@ -2108,6 +2112,9 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { } } doctorResult("config", true, fmt.Sprintf("schema=%d", loaded.Config.Version)) + if profileErr != nil { + doctorResult("profiles", false, profileErr.Error()) + } // A retired key is not a defect in the store and not a reason to fail: the // file was valid when it was written and the key never did anything. Saying // so is what lets someone delete it; silence would leave it there forever. diff --git a/docs/protocol/mcp-surface-v3.md b/docs/protocol/mcp-surface-v3.md index cbb40b01..82e4e61a 100644 --- a/docs/protocol/mcp-surface-v3.md +++ b/docs/protocol/mcp-surface-v3.md @@ -298,5 +298,7 @@ leería como «aquí no hay nada declarado», que es una respuesta distinta. declarada o estructural de TypeScript. Devuelve `results.subject` e `results.implementations`, identidades canónicas, generación, cursor y completitud. Los filtros `repo`, `language`, `paths` y `detection` se aplican -antes de paginar. Una generación anterior al esquema `5` devuelve `LOWER_BOUND`. +antes de paginar. Una generación anterior al esquema `5` devuelve `LOWER_BOUND`, +y una del esquema `5` también lo devuelve cuando quedan ámbitos sin resolver +registrados. Un `COMPLETE` vacío sólo demuestra ausencia dentro de ese corpus. Véase ADR `0116` para el ámbito de tipos y la compatibilidad del protocolo. diff --git a/internal/config/BUILD.bazel b/internal/config/BUILD.bazel index 19f6ecef..2d963fd7 100644 --- a/internal/config/BUILD.bazel +++ b/internal/config/BUILD.bazel @@ -13,6 +13,7 @@ go_library( importpath = "github.com/Luqueee/kivgraph/internal/config", visibility = ["//:__subpackages__"], deps = [ + "//internal/durable", "//internal/filelock", "//internal/topology", "@in_gopkg_yaml_v3//:yaml_v3", diff --git a/internal/config/profiles.go b/internal/config/profiles.go index 93271aa5..4e1a17eb 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -15,6 +15,7 @@ import ( "syscall" "time" + "github.com/Luqueee/kivgraph/internal/durable" "github.com/Luqueee/kivgraph/internal/filelock" "gopkg.in/yaml.v3" ) @@ -140,6 +141,9 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) (result if err := validateMigratedProfile(temporaryProfile); err != nil { return err } + if err := durable.Directory(temporaryProfile); err != nil { + return fmt.Errorf("sync temporary default profile: %w", err) + } if _, err := os.Lstat(backupRoot); err == nil { candidate, err := profileArtifactDigest(temporaryProfile) if err != nil { @@ -159,16 +163,28 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) (result if err := copyProfileArtifact(temporaryProfile, temporaryBackup); err != nil { return fmt.Errorf("prepare legacy profile backup: %w", err) } + if err := durable.Directory(temporaryParent); err != nil { + return fmt.Errorf("sync temporary profile migration directory: %w", err) + } if err := os.Rename(temporaryBackup, backupRoot); err != nil { return fmt.Errorf("retain legacy profile state: %w", err) } + if err := durable.Directory(filepath.Dir(backupRoot)); err != nil { + return fmt.Errorf("sync legacy profile backup publication: %w", err) + } } if err := os.MkdirAll(root, 0o700); err != nil { return fmt.Errorf("create profiles directory: %w", err) } + if err := durable.Directory(temporaryParent); err != nil { + return fmt.Errorf("sync temporary profile migration directory: %w", err) + } if err := os.Rename(temporaryProfile, profile.StateDirectory); err != nil { return fmt.Errorf("publish migrated profile: %w", err) } + if err := errors.Join(durable.Directory(root), durable.Directory(temporaryParent)); err != nil { + return fmt.Errorf("sync migrated profile publication: %w", err) + } // Load remains the compatibility seam for installation-level diagnostics. // Keep its configured directory checks valid without leaving any profile // facts there; profile-aware operations use LoadProfile and the directories @@ -274,6 +290,9 @@ func copyProfileArtifact(source, destination string) error { return err } } + if err := durable.Directory(destination); err != nil { + return fmt.Errorf("sync copied legacy profile directory %q: %w", destination, err) + } return nil } if !info.Mode().IsRegular() { @@ -295,7 +314,8 @@ func copyProfileArtifact(source, destination string) error { return errors.Join(fmt.Errorf("create copied artifact %q: %w", destination, err), input.Close()) } _, copyErr := io.Copy(output, input) - if err := errors.Join(copyErr, output.Close(), input.Close()); err != nil { + syncErr := output.Sync() + if err := errors.Join(copyErr, syncErr, output.Close(), input.Close()); err != nil { return fmt.Errorf("copy legacy profile artifact %q: %w", source, err) } return nil diff --git a/internal/facts/typescript_test.go b/internal/facts/typescript_test.go index 6e3f2860..6c4b2d42 100644 --- a/internal/facts/typescript_test.go +++ b/internal/facts/typescript_test.go @@ -1359,14 +1359,14 @@ func TestNormalizeTypeScriptImplementationEvidence(t *testing.T) { } payload, err := DecodeTypeScriptPayload(data) if err != nil { - t.Fatal(err) + t.Fatalf("decode implementations.json: %v", err) } set, _, err := NormalizeTypeScript(t.Context(), payload, workspace.Repository{RealPath: "/fixtures/implementations"}) if err != nil { - t.Fatal(err) + t.Fatalf("normalize implementations.json: %v", err) } if err := set.Validate(); err != nil { - t.Fatal(err) + t.Fatalf("validate implementations.json: %v", err) } names := map[string]string{} for _, symbol := range set.Symbols { diff --git a/internal/integrations/hooks_test.go b/internal/integrations/hooks_test.go index 0eb73cd0..d8765ca1 100644 --- a/internal/integrations/hooks_test.go +++ b/internal/integrations/hooks_test.go @@ -668,7 +668,7 @@ func TestClaudeDesktopDetectionUsesConfiguredSystemRoot(t *testing.T) { return } } - t.Fatal("claude-desktop is not offered as a hook target") + t.Fatalf("claude-desktop is not offered as a hook target with system root %q", manager.systemRoot) } // TestOhMyPiProjectIsDetectedByItsAgentRoot keeps project selection from diff --git a/internal/integrations/integrations.go b/internal/integrations/integrations.go index 18c8b21a..a52d5794 100644 --- a/internal/integrations/integrations.go +++ b/internal/integrations/integrations.go @@ -314,6 +314,11 @@ func New(options Options) (Manager, error) { systemRoot := options.SystemRoot if systemRoot == "" { systemRoot = string(filepath.Separator) + } else { + systemRoot, err = absolutePath(systemRoot, "system application root") + if err != nil { + return Manager{}, err + } } return Manager{ systemRoot: systemRoot, diff --git a/internal/integrations/integrations_test.go b/internal/integrations/integrations_test.go index 06e4ba08..75fb929b 100644 --- a/internal/integrations/integrations_test.go +++ b/internal/integrations/integrations_test.go @@ -29,6 +29,34 @@ func testManager(t *testing.T) (Manager, string, string) { return manager, home, project } +func TestSystemApplicationRootIsResolved(t *testing.T) { + t.Setenv("CODEX_HOME", "") + t.Setenv("PI_CODING_AGENT_DIR", "") + working := t.TempDir() + previous, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(working); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(previous) }) + manager, err := New(Options{ + HomeDir: t.TempDir(), + SystemRoot: "system", + ProjectDir: t.TempDir(), + Executable: testsupport.InstalledExecutable(), + GOOS: "darwin", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + want := filepath.Join(working, "system") + if manager.systemRoot != want { + t.Fatalf("systemRoot = %q, want %q", manager.systemRoot, want) + } +} + func TestInstallJSONIsIdempotentAndBacksUpOnRemoval(t *testing.T) { // The mode is the claim here, and only a platform that keeps one can // answer it. Where it does not, the file is narrowed with an ACL and diff --git a/internal/mcp/tools/find_implementations_test.go b/internal/mcp/tools/find_implementations_test.go index c030859f..4b18ce55 100644 --- a/internal/mcp/tools/find_implementations_test.go +++ b/internal/mcp/tools/find_implementations_test.go @@ -39,7 +39,7 @@ func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { t.Fatalf("dispatch calls leaked into implementations: %#v %v", concrete, err) } if concrete.Completeness == nil || concrete.Completeness.Verdict != VerdictLowerBound { - t.Fatal("legacy generation falsely attested complete coverage") + t.Fatalf("legacy generation falsely attested complete coverage for stable_key %q", "impl-sole") } _, filtered, err := findImplementations(context.Background(), nil, FindImplementationsInput{StableKey: "iface-shared", Paths: []string{"disk.go"}}, store) if err != nil || filtered.Total != 1 || filtered.Results.Implementations[0].FilePath != "disk.go" { diff --git a/ts-worker/src/facts-cli.ts b/ts-worker/src/facts-cli.ts index 0ec3d706..17c5e0cf 100644 --- a/ts-worker/src/facts-cli.ts +++ b/ts-worker/src/facts-cli.ts @@ -85,7 +85,10 @@ import path from "node:path"; import { isEntryPoint } from "./entry-point.js"; import { type ExtendsEdge, resolveExtends } from "./extends-resolver.js"; -import { resolveImplementations } from "./implements-resolver.js"; +import { + type ImplementationEdge, + resolveImplementations, +} from "./implements-resolver.js"; import type { ImportedSymbol, ReexportedSymbol, @@ -394,14 +397,13 @@ export async function collectFacts( extendsResolution.extends, manifest?.name ?? repositoryName, ); - const implementationNormalization = extendsFactSymbols( + const implementationNormalization = implementationFactSymbols( root, implementationResolution.edges, manifest?.name ?? repositoryName, ); if (implementationNormalization.unresolved.length !== 0) throw new Error("resolved implementations produced unresolved facts"); - const implementationFacts = implementationNormalization.extends; const dependencyEvidenceFiles = dependencyResolution.dependencies .map((dependency) => dependency.imports[0]?.fileName) @@ -530,12 +532,7 @@ export async function collectFacts( }), exports: exportSymbols.exports, extends: extendsFacts.extends, - implementations: implementationResolution.edges.map((edge, index) => { - const fact = implementationFacts[index]; - if (fact === undefined) - throw new Error("implementation normalization omitted an edge"); - return { ...fact, detection: edge.detection, relation: edge.relation }; - }), + implementations: implementationNormalization.implementations, implementationLimitations: [ ...implementationResolution.limitations, ...(unclaimed.length > 0 @@ -956,50 +953,87 @@ function extendsFactSymbols( const unresolved: FactUnresolved[] = []; for (const edge of edges) { - const identity = edge.identity; - facts.push({ - file: relative(root, edge.base.fileName), - qualifiedName: edge.base.sourceQualifiedName, - start: edge.base.start, - end: edge.base.end, - startLine: edge.base.startLine, - text: edge.base.text, - targetQualifiedName: edge.targetQualifiedName ?? null, - targetFile: - edge.targetFile === undefined ? null : relative(root, edge.targetFile), - target: - identity === undefined - ? null - : { - repository: identity.repository, - package: identity.package, - qualifiedName: identity.qualifiedName, - kind: identity.kind, - signature: identity.signature, - file: identity.file, - startLine: identity.startLine, - source: identity.source, - }, - requestedPackage: edge.packageName ?? null, - requestedSymbol: edge.exportedName ?? null, - reason: edge.unresolvedReason ?? null, - detail: edge.unresolvedDetail ?? null, - }); - if (edge.targetQualifiedName === undefined && identity === undefined) { - unresolved.push({ - file: relative(root, edge.base.fileName), - reason: edge.unresolvedReason ?? "PROVIDER_SOURCE_UNAVAILABLE", - requestedPackage: edge.packageName ?? localPackage, - requestedSymbol: edge.exportedName ?? edge.base.text, - detail: edge.unresolvedDetail ?? null, - start: edge.base.start, - }); - } + const normalized = normalizeExtendsFact(root, edge, localPackage); + facts.push(normalized.fact); + if (normalized.unresolved !== undefined) + unresolved.push(normalized.unresolved); } return { extends: facts, unresolved }; } +function implementationFactSymbols( + root: string, + edges: readonly ImplementationEdge[], + localPackage: string, +): { + readonly implementations: FactsPayload["implementations"]; + readonly unresolved: readonly FactUnresolved[]; +} { + const implementations: Array = []; + const unresolved: FactUnresolved[] = []; + for (const edge of edges) { + const normalized = normalizeExtendsFact(root, edge, localPackage); + implementations.push({ + ...normalized.fact, + detection: edge.detection, + relation: edge.relation, + }); + if (normalized.unresolved !== undefined) + unresolved.push(normalized.unresolved); + } + return { implementations, unresolved }; +} + +function normalizeExtendsFact( + root: string, + edge: ExtendsEdge, + localPackage: string, +): { readonly fact: FactExtends; readonly unresolved?: FactUnresolved } { + const identity = edge.identity; + const fact: FactExtends = { + file: relative(root, edge.base.fileName), + qualifiedName: edge.base.sourceQualifiedName, + start: edge.base.start, + end: edge.base.end, + startLine: edge.base.startLine, + text: edge.base.text, + targetQualifiedName: edge.targetQualifiedName ?? null, + targetFile: + edge.targetFile === undefined ? null : relative(root, edge.targetFile), + target: + identity === undefined + ? null + : { + repository: identity.repository, + package: identity.package, + qualifiedName: identity.qualifiedName, + kind: identity.kind, + signature: identity.signature, + file: identity.file, + startLine: identity.startLine, + source: identity.source, + }, + requestedPackage: edge.packageName ?? null, + requestedSymbol: edge.exportedName ?? null, + reason: edge.unresolvedReason ?? null, + detail: edge.unresolvedDetail ?? null, + }; + if (edge.targetQualifiedName !== undefined || identity !== undefined) + return { fact }; + return { + fact, + unresolved: { + file: relative(root, edge.base.fileName), + reason: edge.unresolvedReason ?? "PROVIDER_SOURCE_UNAVAILABLE", + requestedPackage: edge.packageName ?? localPackage, + requestedSymbol: edge.exportedName ?? edge.base.text, + detail: edge.unresolvedDetail ?? null, + start: edge.base.start, + }, + }; +} + function compareUnresolved( left: FactUnresolved, right: FactUnresolved, diff --git a/ts-worker/src/implements-resolver.test.ts b/ts-worker/src/implements-resolver.test.ts index 648c9338..b44a82bf 100644 --- a/ts-worker/src/implements-resolver.test.ts +++ b/ts-worker/src/implements-resolver.test.ts @@ -47,6 +47,8 @@ export abstract class Abstract implements Reader { abstract read(): string; } export class Declared implements Reader { read(): string { return 'ok'; } } export class Structural { read(): string { return 'ok'; } } export class Inherited extends Structural {} +export abstract class Base { abstract run(): string; } +export class Concrete extends Base { run(): string { return 'ok'; } } `); const result = await resolveImplementations(service, view, symbols, []); const brute = await resolveImplementations(service, view, symbols, [], { @@ -73,6 +75,21 @@ export class Inherited extends Structural {} edge.base.sourceQualifiedName.startsWith("Broken"), ), ).toBe(false); + expect( + result.edges + .filter((edge) => edge.base.sourceQualifiedName.startsWith("Concrete")) + .map((edge) => [ + edge.base.sourceQualifiedName, + edge.targetQualifiedName, + edge.relation, + ]), + ).toEqual([ + ["Concrete", "Base", "IMPLEMENTS"], + ["Concrete.run", "Base.run", "OVERRIDES"], + ]); + expect(result.limitations).toContain( + "Type declarations with compiler errors are excluded from implementation proofs.", + ); }); it("uses concrete generic instances without replacing unknown parameters with any", async () => { @@ -105,6 +122,28 @@ export type TextBox = Box; ]); }); +it("keeps empty and fully optional targets aligned with the exhaustive oracle", async () => { + const { service, view, symbols } = await fixture(` +export interface Empty {} +export interface Optional { read?(): string; } +export class Blank {} +`); + const result = await resolveImplementations(service, view, symbols, []); + const brute = await resolveImplementations(service, view, symbols, [], { + exhaustive: true, + }); + expect(result.edges).toEqual(brute.edges); + expect( + result.edges.map((edge) => [ + edge.base.sourceQualifiedName, + edge.targetQualifiedName, + ]), + ).toEqual([ + ["Blank", "Empty"], + ["Blank", "Optional"], + ]); +}); + it("retains canonical provider identities for imported interfaces and methods", async () => { const root = await temporaryRoot("kivgraph-cross-implementations-"); roots.push(root); diff --git a/ts-worker/src/implements-resolver.ts b/ts-worker/src/implements-resolver.ts index 408e6d45..ddcd66c1 100644 --- a/ts-worker/src/implements-resolver.ts +++ b/ts-worker/src/implements-resolver.ts @@ -87,14 +87,18 @@ export async function resolveImplementations( const fileNames = [ ...new Set(extraction.symbols.map((symbol) => symbol.fileName)), ].sort(); + const symbolsByFile = new Map(); + for (const symbol of extraction.symbols) { + const group = symbolsByFile.get(symbol.fileName) ?? []; + group.push(symbol); + symbolsByFile.set(symbol.fileName, group); + } // Serial native RPC work bounds concurrency. Caches never escape this view. for (const fileName of fileNames) { const errors = (await view.program.getSemanticDiagnostics(fileName)).filter( (diagnostic) => diagnostic.category === DiagnosticCategory.Error, ); - for (const local of extraction.symbols.filter( - (symbol) => symbol.fileName === fileName, - )) { + for (const local of symbolsByFile.get(fileName) ?? []) { if ( errors.some( (diagnostic) => @@ -319,6 +323,11 @@ export async function resolveImplementations( } providerMembers.set(target.symbol.id, members); } + } catch { + limitations.add( + "A provider project could not be analyzed, so its members contribute no provenance.", + ); + for (const target of group) targets.delete(target.symbol.id); } finally { await providerService.close(); } @@ -378,19 +387,27 @@ export async function resolveImplementations( } const targetIDsByRequiredName = new Map>(); + const targetsWithoutRequiredMembers = new Set(); for (const target of targets.values()) { + let hasRequiredMembers = false; for (const targetType of target.types.values()) { for (const property of await props(targetType)) { if ((property.flags & SymbolFlags.Optional) !== 0) continue; + hasRequiredMembers = true; const matching = targetIDsByRequiredName.get(property.name) ?? new Set(); matching.add(target.symbol.id); targetIDsByRequiredName.set(property.name, matching); } } + if (!hasRequiredMembers) + targetsWithoutRequiredMembers.add(target.symbol.id); } for (const source of sources.values()) { - const candidateIDs = new Set(source.declared); + const candidateIDs = new Set([ + ...source.declared, + ...targetsWithoutRequiredMembers, + ]); for (const sourceType of source.types.values()) { for (const property of await props(sourceType)) { for (const targetID of targetIDsByRequiredName.get(property.name) ?? From a07b9b3541376ab38351995cfd806e547517252a Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 18:52:47 +0200 Subject: [PATCH 13/18] fix: close migration and resolver review gaps --- README.md | 2 +- internal/config/profiles.go | 6 ++--- internal/integrations/integrations_test.go | 2 +- .../mcp/tools/find_implementations_test.go | 19 +++++++++------ ts-worker/src/declaration-classifier.ts | 3 ++- ts-worker/src/facts-cli.ts | 3 +++ ts-worker/src/implements-resolver.test.ts | 24 +++++++++++++++---- ts-worker/src/implements-resolver.ts | 8 ++++++- 8 files changed, 49 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a5936518..93f6a43d 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ checkout, which is what a reader on a fork or without a network still has. | everything about this one symbol | `get_symbol` | | what is indexed, and is the graph current | `list_repositories`, `graph_status` | -Twelve read-only tools, plus two consent-gated mutations (`index_project` and +Thirteen read-only tools, plus two consent-gated mutations (`index_project` and `start_index_project`) that a client has to authorize before either can register a repository or publish a generation. diff --git a/internal/config/profiles.go b/internal/config/profiles.go index 4e1a17eb..34e1b1a6 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -86,9 +86,6 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) (result } else if err != nil { return fmt.Errorf("inspect legacy profile state %q: %w", stateRoot, err) } - if err := inspectLegacyRuntime(stateRoot); err != nil { - return err - } for _, name := range []string{"analyzer-targets.lock", "resync.lock", "publish.lock"} { lock, acquired, err := filelock.Acquire(filepath.Join(stateRoot, name)) if err != nil { @@ -99,6 +96,9 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) (result } defer func() { resultErr = errors.Join(resultErr, lock.Release()) }() } + if err := inspectLegacyRuntime(stateRoot); err != nil { + return err + } root := profilesRoot(configuration) if entries, err := os.ReadDir(root); err == nil { for _, entry := range entries { diff --git a/internal/integrations/integrations_test.go b/internal/integrations/integrations_test.go index 75fb929b..cc963be2 100644 --- a/internal/integrations/integrations_test.go +++ b/internal/integrations/integrations_test.go @@ -53,7 +53,7 @@ func TestSystemApplicationRootIsResolved(t *testing.T) { } want := filepath.Join(working, "system") if manager.systemRoot != want { - t.Fatalf("systemRoot = %q, want %q", manager.systemRoot, want) + t.Fatalf("systemRoot for SystemRoot %q = %q, want %q", "system", manager.systemRoot, want) } } diff --git a/internal/mcp/tools/find_implementations_test.go b/internal/mcp/tools/find_implementations_test.go index 4b18ce55..eedd99b5 100644 --- a/internal/mcp/tools/find_implementations_test.go +++ b/internal/mcp/tools/find_implementations_test.go @@ -13,16 +13,19 @@ func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { t.Fatalf("find implementations with %#v: %v", args, err) } if first.Total != 2 || first.Returned != 1 || first.NextCursor == nil { - t.Fatalf("page: %#v", first) + t.Fatalf("page for %#v: %#v", args, first) } if first.Results.Implementations[0].EdgeKind != "IMPLEMENTS" || first.Results.Implementations[0].Detection != "structural" { - t.Fatalf("untyped result: %#v", first.Results) + t.Fatalf("untyped result for %#v: %#v", args, first.Results) } args.Cursor = *first.NextCursor _, second, err := findImplementations(context.Background(), nil, args, store) if err != nil || second.Returned != 1 || second.NextCursor != nil { t.Fatalf("second page with %#v: %#v %v", args, second, err) } + if second.Results.Implementations[0].EdgeKind != "IMPLEMENTS" || second.Results.Implementations[0].Detection != "structural" { + t.Fatalf("untyped second result for %#v: %#v", args, second.Results) + } if first.Results.Implementations[0].StableKey == second.Results.Implementations[0].StableKey { t.Fatalf("duplicate page for %#v: first=%q second=%q", args, first.Results.Implementations[0].StableKey, second.Results.Implementations[0].StableKey) } @@ -34,16 +37,18 @@ func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { if _, _, err := findImplementations(context.Background(), nil, args, dispatchSnapshot(t, 201, 2)); err == nil { t.Fatalf("cursor crossed generations: %#v", args) } - _, concrete, err := findImplementations(context.Background(), nil, FindImplementationsInput{StableKey: "impl-sole"}, store) + concreteArgs := FindImplementationsInput{StableKey: "impl-sole"} + _, concrete, err := findImplementations(context.Background(), nil, concreteArgs, store) if err != nil || concrete.Total != 0 { - t.Fatalf("dispatch calls leaked into implementations: %#v %v", concrete, err) + t.Fatalf("dispatch calls leaked for %#v: %#v %v", concreteArgs, concrete, err) } if concrete.Completeness == nil || concrete.Completeness.Verdict != VerdictLowerBound { - t.Fatalf("legacy generation falsely attested complete coverage for stable_key %q", "impl-sole") + t.Fatalf("legacy generation falsely attested complete coverage for %#v", concreteArgs) } - _, filtered, err := findImplementations(context.Background(), nil, FindImplementationsInput{StableKey: "iface-shared", Paths: []string{"disk.go"}}, store) + filteredArgs := FindImplementationsInput{StableKey: "iface-shared", Paths: []string{"disk.go"}} + _, filtered, err := findImplementations(context.Background(), nil, filteredArgs, store) if err != nil || filtered.Total != 1 || filtered.Results.Implementations[0].FilePath != "disk.go" { - t.Fatalf("paths filter: %#v %v", filtered, err) + t.Fatalf("paths filter for %#v: %#v %v", filteredArgs, filtered, err) } for _, args := range []FindImplementationsInput{{StableKey: "iface-sole", Detection: "guess"}, {StableKey: "iface-sole", Paths: []string{"../outside"}}} { if _, _, err := findImplementations(context.Background(), nil, args, store); err == nil { diff --git a/ts-worker/src/declaration-classifier.ts b/ts-worker/src/declaration-classifier.ts index 29d0ccdd..522b4904 100644 --- a/ts-worker/src/declaration-classifier.ts +++ b/ts-worker/src/declaration-classifier.ts @@ -199,7 +199,8 @@ export function scopeName(node: Node): string | undefined { isMethodSignatureDeclaration(node) || isGetAccessorDeclaration(node) || isSetAccessorDeclaration(node) || - isPropertyDeclaration(node) + isPropertyDeclaration(node) || + isPropertySignatureDeclaration(node) ) { return displayName(node.name); } diff --git a/ts-worker/src/facts-cli.ts b/ts-worker/src/facts-cli.ts index 17c5e0cf..a4b37667 100644 --- a/ts-worker/src/facts-cli.ts +++ b/ts-worker/src/facts-cli.ts @@ -402,6 +402,9 @@ export async function collectFacts( implementationResolution.edges, manifest?.name ?? repositoryName, ); + // Implementation targets come only from local declarations or imports + // whose provider identity was proven, so an unresolved row contradicts + // resolveImplementations rather than describing a partial result. if (implementationNormalization.unresolved.length !== 0) throw new Error("resolved implementations produced unresolved facts"); diff --git a/ts-worker/src/implements-resolver.test.ts b/ts-worker/src/implements-resolver.test.ts index b44a82bf..bfa52b75 100644 --- a/ts-worker/src/implements-resolver.test.ts +++ b/ts-worker/src/implements-resolver.test.ts @@ -241,12 +241,13 @@ it("retains canonical provider identities for imported interfaces and methods", edge.base.sourceQualifiedName, edge.identity?.qualifiedName, edge.detection, + edge.relation, ]), ).toEqual([ - ["Declared", "Reader", "declared"], - ["Declared.read", "Reader.read", "declared"], - ["Structural", "Reader", "structural"], - ["Structural.read", "Reader.read", "structural"], + ["Declared", "Reader", "declared", "IMPLEMENTS"], + ["Declared.read", "Reader.read", "declared", "IMPLEMENTS"], + ["Structural", "Reader", "structural", "IMPLEMENTS"], + ["Structural.read", "Reader.read", "structural", "IMPLEMENTS"], ]); expect( result.edges.map((edge) => [ @@ -260,4 +261,19 @@ it("retains canonical provider identities for imported interfaces and methods", ["Structural", "provider-repo", "src/contracts.ts"], ["Structural.read", "provider-repo", "src/contracts.ts"], ]); + + await writeFile( + path.join(provider, "src/contracts.ts"), + "export interface Different { read(): string; }\n", + ); + const unavailable = await resolveImplementations( + service, + view, + symbols, + imports.symbols, + ); + expect(unavailable.edges).toEqual([]); + expect(unavailable.limitations).toContain( + "Provider declaration identity could not be revalidated.", + ); }); diff --git a/ts-worker/src/implements-resolver.ts b/ts-worker/src/implements-resolver.ts index ddcd66c1..72602911 100644 --- a/ts-worker/src/implements-resolver.ts +++ b/ts-worker/src/implements-resolver.ts @@ -180,7 +180,12 @@ export async function resolveImplementations( if (file === undefined) continue; const nodes: Node[] = []; const visit = (node: Node): void => { - nodes.push(node); + if ( + isClassDeclaration(node) || + isTypeReferenceNode(node) || + isNewExpression(node) + ) + nodes.push(node); node.forEachChild(visit); }; file.forEachChild(visit); @@ -488,6 +493,7 @@ export async function resolveImplementations( symbol: targetMember, imported: { ...target.imported, + exportedName: targetMember.name, target: { ...target.imported.target, identity: memberIdentity }, }, types: new Map(), From 0f6b9c9c33e79614b87fb2ff0b3329610b0e2f0a Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 19:04:35 +0200 Subject: [PATCH 14/18] fix: finalize reduced PR review --- README.md | 4 ++-- cmd/kivgraph/main.go | 18 +++++++-------- docs/protocol/mcp-surface-v3.md | 9 ++++---- internal/config/profile_upgrade_test.go | 2 +- internal/config/profiles.go | 12 ++++++++-- internal/facts/typescript_test.go | 5 ++-- internal/integrations/hooks_test.go | 2 +- internal/integrations/integrations_test.go | 27 ++++++++++++++++++---- ts-worker/src/facts-cli.ts | 7 ++++-- ts-worker/src/implements-resolver.test.ts | 14 +++++------ ts-worker/src/implements-resolver.ts | 2 +- 11 files changed, 66 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 93f6a43d..f8c80741 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,8 @@ backlog and the acceptance gate of every phase are in [`TASKS.md`](TASKS.md). - **Semantic dependencies:** Python and Dart imports can publish a package dependency when exactly one registered provider owns the requested package; symbol-level cross-repository edges require an explicit provider identity. -- **Surface:** eleven read-only tools over STDIO, plus one consent-gated - mutation (`index_project`). The contract is +- **Surface:** thirteen read-only tools over STDIO, plus two consent-gated + mutations (`index_project` and `start_index_project`). The contract is [docs/protocol/mcp-surface-v3.md](docs/protocol/mcp-surface-v3.md). - **Storage:** LadybugDB is canonical; queries are served from an immutable HotSnapshot published atomically, never from the database. diff --git a/cmd/kivgraph/main.go b/cmd/kivgraph/main.go index e1c6306a..3eb3d81d 100644 --- a/cmd/kivgraph/main.go +++ b/cmd/kivgraph/main.go @@ -2065,20 +2065,20 @@ func doctorFlagSet(options *doctorOptions) *flag.FlagSet { // Doctor follows the same default profile as serve, without initiating a // migration during diagnosis. A legacy installation still has its old layout. -func readDoctorConfiguration(configPath string) (config.Loaded, error, error) { - loaded, err := config.Load(configPath) - if err != nil { - return config.Loaded{}, nil, err +func readDoctorConfiguration(configPath string) (loadedConfiguration config.Loaded, profileErr, configurationErr error) { + loadedConfiguration, configurationErr = config.Load(configPath) + if configurationErr != nil { + return config.Loaded{}, nil, configurationErr } - profiles := filepath.Join(filepath.Dir(loaded.Config.Storage.DatabasePath), "profiles") + profiles := filepath.Join(filepath.Dir(loadedConfiguration.Config.Storage.DatabasePath), "profiles") if _, err := os.Stat(profiles); errors.Is(err, os.ErrNotExist) { - return loaded, nil, nil + return loadedConfiguration, nil, nil } else if err != nil { return config.Loaded{}, nil, fmt.Errorf("inspect profiles: %w", err) } - profile, err := config.ReadProfile(configPath, "") - if err != nil { - return loaded, err, nil + profile, profileErr := config.ReadProfile(configPath, "") + if profileErr != nil { + return loadedConfiguration, profileErr, nil } return profile, nil, nil } diff --git a/docs/protocol/mcp-surface-v3.md b/docs/protocol/mcp-surface-v3.md index 82e4e61a..0021e904 100644 --- a/docs/protocol/mcp-surface-v3.md +++ b/docs/protocol/mcp-surface-v3.md @@ -280,10 +280,11 @@ alternativa nativa compite y **dónde pierde**. Nada de eso puede llevar un núm derivado del grafo: reescribiría bytes del prompt de sistema de un cliente en cada reindexado e invalidaría su caché. -`TestServerSurfaceStaysCheapToLoad` fija el techo del esquema en `8.000` -caracteres y falla si una tool vuelve a publicar `outputSchema`; -`TestServerSurfaceStaysCheapToKeepResident` fija el residente en `1.900` bytes y -falla si una descripción contiene un dígito. +La superficie residente de las doce tools mide `1.874` bytes con la fórmula del +test, bajo un techo de `1.900` bytes. `TestServerSurfaceStaysCheapToLoad` fija el +techo del esquema en `18.000` bytes y falla si una tool vuelve a publicar +`outputSchema`; `TestServerSurfaceStaysCheapToKeepResident` guarda el techo +residente y falla si una descripción contiene un dígito. ## 9. Códigos de error diff --git a/internal/config/profile_upgrade_test.go b/internal/config/profile_upgrade_test.go index e1e5ca6c..6f420606 100644 --- a/internal/config/profile_upgrade_test.go +++ b/internal/config/profile_upgrade_test.go @@ -77,7 +77,7 @@ func TestProfileUpgradePreservesRuntimeAndFreshness(t *testing.T) { t.Fatal(err) } if _, err := os.Lstat(filepath.Join(root, "daemon.sock")); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("copied runtime socket: %v", err) + t.Fatalf("copied runtime socket into %q: %v", root, err) } } socketAfter, err := os.Lstat(filepath.Join(state, "daemon.sock")) diff --git a/internal/config/profiles.go b/internal/config/profiles.go index 34e1b1a6..e57be778 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -70,7 +70,11 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) (result if !acquired { return errors.New("profile migration is in progress; retry after it completes") } - defer func() { resultErr = errors.Join(resultErr, lock.Release()) }() + defer func() { + if releaseErr := lock.Release(); releaseErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("release profile migration lock: %w", releaseErr)) + } + }() if _, err := os.Stat(profile.StateDirectory); err == nil { return validateMigratedProfile(profile.StateDirectory) } @@ -94,7 +98,11 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) (result if !acquired { return fmt.Errorf("profile migration blocked by active writer (%s); stop writers and retry", name) } - defer func() { resultErr = errors.Join(resultErr, lock.Release()) }() + defer func(lockName string) { + if releaseErr := lock.Release(); releaseErr != nil { + resultErr = errors.Join(resultErr, fmt.Errorf("release legacy %s: %w", lockName, releaseErr)) + } + }(name) } if err := inspectLegacyRuntime(stateRoot); err != nil { return err diff --git a/internal/facts/typescript_test.go b/internal/facts/typescript_test.go index 6c4b2d42..3cd256f0 100644 --- a/internal/facts/typescript_test.go +++ b/internal/facts/typescript_test.go @@ -1375,10 +1375,11 @@ func TestNormalizeTypeScriptImplementationEvidence(t *testing.T) { edges := map[string]Provenance{} for _, edge := range set.Edges { if edge.Kind == Implements || edge.Kind == Overrides { + pair := names[edge.SourceKey] + "->" + names[edge.TargetKey] if !edge.Confidence.Exact() || edge.EvidenceKey == "" { - t.Fatalf("unproven relationship: %#v", edge) + t.Fatalf("%s: unproven relationship: %#v", pair, edge) } - edges[names[edge.SourceKey]+"->"+names[edge.TargetKey]] = edge.Provenance + edges[pair] = edge.Provenance } } for pair, provenance := range map[string]Provenance{"Declared->Reader": TypeScriptImplementationDeclared, "Structural->Reader": TypeScriptImplementationStructural, "Declared.read->Reader.read": TypeScriptImplementationDeclared, "Generic->TextBox": TypeScriptImplementationStructural, "Concrete.read->Abstract.read": TypeScriptImplementationDeclared} { diff --git a/internal/integrations/hooks_test.go b/internal/integrations/hooks_test.go index d8765ca1..b05dd439 100644 --- a/internal/integrations/hooks_test.go +++ b/internal/integrations/hooks_test.go @@ -658,7 +658,7 @@ func TestClaudeDesktopDetectionUsesConfiguredSystemRoot(t *testing.T) { } detections, err := manager.DetectHookTargets(ScopeUser) if err != nil { - t.Fatalf("DetectHookTargets() error = %v", err) + t.Fatalf("DetectHookTargets(%q, systemRoot=%q) error = %v", ScopeUser, manager.systemRoot, err) } for _, detection := range detections { if detection.Target == TargetClaudeDesktop { diff --git a/internal/integrations/integrations_test.go b/internal/integrations/integrations_test.go index cc963be2..eee8e17a 100644 --- a/internal/integrations/integrations_test.go +++ b/internal/integrations/integrations_test.go @@ -40,7 +40,15 @@ func TestSystemApplicationRootIsResolved(t *testing.T) { if err := os.Chdir(working); err != nil { t.Fatal(err) } - t.Cleanup(func() { _ = os.Chdir(previous) }) + t.Cleanup(func() { + if err := os.Chdir(previous); err != nil { + t.Errorf("restore working directory %q: %v", previous, err) + } + }) + marker := filepath.Join(working, "system", "Applications", "Claude.app") + if err := os.MkdirAll(marker, 0o700); err != nil { + t.Fatal(err) + } manager, err := New(Options{ HomeDir: t.TempDir(), SystemRoot: "system", @@ -49,12 +57,21 @@ func TestSystemApplicationRootIsResolved(t *testing.T) { GOOS: "darwin", }) if err != nil { - t.Fatalf("New() error = %v", err) + t.Fatalf("New(SystemRoot=%q) error = %v", "system", err) } - want := filepath.Join(working, "system") - if manager.systemRoot != want { - t.Fatalf("systemRoot for SystemRoot %q = %q, want %q", "system", manager.systemRoot, want) + detections, err := manager.DetectHookTargets(ScopeUser) + if err != nil { + t.Fatalf("DetectHookTargets(%q) error = %v", ScopeUser, err) + } + for _, detection := range detections { + if detection.Target == TargetClaudeDesktop { + if !detection.Detected { + t.Fatalf("relative SystemRoot %q did not find marker %q", "system", marker) + } + return + } } + t.Fatal("claude-desktop is not offered as a hook target") } func TestInstallJSONIsIdempotentAndBacksUpOnRemoval(t *testing.T) { diff --git a/ts-worker/src/facts-cli.ts b/ts-worker/src/facts-cli.ts index a4b37667..f9e8c7ca 100644 --- a/ts-worker/src/facts-cli.ts +++ b/ts-worker/src/facts-cli.ts @@ -405,8 +405,11 @@ export async function collectFacts( // Implementation targets come only from local declarations or imports // whose provider identity was proven, so an unresolved row contradicts // resolveImplementations rather than describing a partial result. - if (implementationNormalization.unresolved.length !== 0) - throw new Error("resolved implementations produced unresolved facts"); + const contradiction = implementationNormalization.unresolved[0]; + if (contradiction !== undefined) + throw new Error( + `resolved implementations produced unresolved facts: ${contradiction.file} @${contradiction.start} ${contradiction.reason}`, + ); const dependencyEvidenceFiles = dependencyResolution.dependencies .map((dependency) => dependency.imports[0]?.fileName) diff --git a/ts-worker/src/implements-resolver.test.ts b/ts-worker/src/implements-resolver.test.ts index bfa52b75..77fa23b7 100644 --- a/ts-worker/src/implements-resolver.test.ts +++ b/ts-worker/src/implements-resolver.test.ts @@ -84,8 +84,8 @@ export class Concrete extends Base { run(): string { return 'ok'; } } edge.relation, ]), ).toEqual([ - ["Concrete", "Base", "IMPLEMENTS"], ["Concrete.run", "Base.run", "OVERRIDES"], + ["Concrete", "Base", "IMPLEMENTS"], ]); expect(result.limitations).toContain( "Type declarations with compiler errors are excluded from implementation proofs.", @@ -113,12 +113,12 @@ export type TextBox = Box; edge.detection, ]), ).toEqual([ + ["Generic.get", "Box.get", "structural"], ["Generic", "Box", "structural"], ["Generic", "TextBox", "structural"], - ["Generic.get", "Box.get", "structural"], + ["StringBox.get", "Box.get", "declared"], ["StringBox", "Box", "declared"], ["StringBox", "TextBox", "structural"], - ["StringBox.get", "Box.get", "declared"], ]); }); @@ -244,10 +244,10 @@ it("retains canonical provider identities for imported interfaces and methods", edge.relation, ]), ).toEqual([ - ["Declared", "Reader", "declared", "IMPLEMENTS"], ["Declared.read", "Reader.read", "declared", "IMPLEMENTS"], - ["Structural", "Reader", "structural", "IMPLEMENTS"], + ["Declared", "Reader", "declared", "IMPLEMENTS"], ["Structural.read", "Reader.read", "structural", "IMPLEMENTS"], + ["Structural", "Reader", "structural", "IMPLEMENTS"], ]); expect( result.edges.map((edge) => [ @@ -256,10 +256,10 @@ it("retains canonical provider identities for imported interfaces and methods", edge.identity?.file, ]), ).toEqual([ - ["Declared", "provider-repo", "src/contracts.ts"], ["Declared.read", "provider-repo", "src/contracts.ts"], - ["Structural", "provider-repo", "src/contracts.ts"], + ["Declared", "provider-repo", "src/contracts.ts"], ["Structural.read", "provider-repo", "src/contracts.ts"], + ["Structural", "provider-repo", "src/contracts.ts"], ]); await writeFile( diff --git a/ts-worker/src/implements-resolver.ts b/ts-worker/src/implements-resolver.ts index 72602911..65803641 100644 --- a/ts-worker/src/implements-resolver.ts +++ b/ts-worker/src/implements-resolver.ts @@ -526,7 +526,7 @@ export async function resolveImplementations( return { generation: view.generation, edges: [...edges.entries()] - .sort(([left], [right]) => left.localeCompare(right)) + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) .map(([, edge]) => edge), limitations: [...limitations].sort(), }; From f4911cb33520dfaccf45f704867cb602fdff740d Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 19:08:30 +0200 Subject: [PATCH 15/18] fix: remove stale freshness Bazel inputs --- cmd/kivgraph/BUILD.bazel | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cmd/kivgraph/BUILD.bazel b/cmd/kivgraph/BUILD.bazel index eb57f606..f70f5b61 100644 --- a/cmd/kivgraph/BUILD.bazel +++ b/cmd/kivgraph/BUILD.bazel @@ -107,7 +107,6 @@ go_test( "logs_test.go", "main_test.go", "output_test.go", - "profile_freshness_test.go", "profile_indexer_test.go", "profile_registry_test.go", "profile_test.go", @@ -133,7 +132,6 @@ go_test( "//internal/eventlog", "//internal/facts", "//internal/filelock", - "//internal/freshness", "//internal/goworkspace", "//internal/hotsnapshot", "//internal/indexer", @@ -141,7 +139,6 @@ go_test( "//internal/integrations", "//internal/invalidation", "//internal/logging", - "//internal/mcp", "//internal/mcp/tools", "//internal/metrics", "//internal/procstat", @@ -159,6 +156,5 @@ go_test( "//internal/webassets", "//internal/workspace", "@com_github_charmbracelet_bubbletea//:bubbletea", - "@com_github_modelcontextprotocol_go_sdk//mcp", ], ) From 697f1fa724ab86255b8264be5dbf7fb08a6dcfbd Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 19:17:15 +0200 Subject: [PATCH 16/18] test: clarify reduced PR coverage --- README.md | 1 + internal/dartloader/worker_test.go | 4 ++-- internal/facts/typescript_test.go | 2 +- internal/integrations/hooks_test.go | 9 +++++---- internal/integrations/integrations_test.go | 2 +- ts-worker/src/implements-resolver.ts | 17 ++++++++++++----- 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index f8c80741..4915ae48 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ checkout, which is what a reader on a fork or without a network still has. | give me the code of these symbols | `get_source` | | everything about this one symbol | `get_symbol` | | what is indexed, and is the graph current | `list_repositories`, `graph_status` | +| how is an asynchronous index progressing | `get_index_status` | Thirteen read-only tools, plus two consent-gated mutations (`index_project` and `start_index_project`) that a client has to authorize before either can register diff --git a/internal/dartloader/worker_test.go b/internal/dartloader/worker_test.go index 619a4239..957b39b9 100644 --- a/internal/dartloader/worker_test.go +++ b/internal/dartloader/worker_test.go @@ -29,7 +29,7 @@ func TestDartWorkerFailurePreservesExitAndBoundedStderr(t *testing.T) { if protocol == "lsp" { c, e := start(t.Context(), command, "", testsupport.TempDir(t)) if e != nil { - t.Fatal(e) + t.Fatalf("%s worker startup: %v", protocol, e) } defer c.close() <-c.worker.done @@ -37,7 +37,7 @@ func TestDartWorkerFailurePreservesExitAndBoundedStderr(t *testing.T) { } else { c, e := startAnalyzer(t.Context(), command, "", testsupport.TempDir(t)) if e != nil { - t.Fatal(e) + t.Fatalf("%s worker startup: %v", protocol, e) } defer c.close() <-c.worker.done diff --git a/internal/facts/typescript_test.go b/internal/facts/typescript_test.go index 3cd256f0..bf82df52 100644 --- a/internal/facts/typescript_test.go +++ b/internal/facts/typescript_test.go @@ -1388,7 +1388,7 @@ func TestNormalizeTypeScriptImplementationEvidence(t *testing.T) { } } if _, exists := edges["Wrong->Reader"]; exists { - t.Fatal("incompatible types connected") + t.Fatal("implementation Wrong->Reader: incompatible types connected") } if len(payload.Implementations) == 0 { t.Fatal("worker emitted no implementations") diff --git a/internal/integrations/hooks_test.go b/internal/integrations/hooks_test.go index b05dd439..a5052190 100644 --- a/internal/integrations/hooks_test.go +++ b/internal/integrations/hooks_test.go @@ -652,23 +652,24 @@ func TestClaudeDesktopIsDetectedByItsOwnEntry(t *testing.T) { func TestClaudeDesktopDetectionUsesConfiguredSystemRoot(t *testing.T) { manager, _, _ := testManager(t) - entry := filepath.Join(manager.systemRoot, "Applications", "Claude.app") + systemRoot := manager.systemRoot + entry := filepath.Join(systemRoot, "Applications", "Claude.app") if err := os.MkdirAll(entry, 0o700); err != nil { t.Fatal(err) } detections, err := manager.DetectHookTargets(ScopeUser) if err != nil { - t.Fatalf("DetectHookTargets(%q, systemRoot=%q) error = %v", ScopeUser, manager.systemRoot, err) + t.Fatalf("DetectHookTargets(%q, systemRoot=%q) error = %v", ScopeUser, systemRoot, err) } for _, detection := range detections { if detection.Target == TargetClaudeDesktop { if !detection.Detected { - t.Fatalf("system application below configured root %q was not detected", manager.systemRoot) + t.Fatalf("system application below configured root %q was not detected", systemRoot) } return } } - t.Fatalf("claude-desktop is not offered as a hook target with system root %q", manager.systemRoot) + t.Fatalf("claude-desktop is not offered as a hook target with system root %q", systemRoot) } // TestOhMyPiProjectIsDetectedByItsAgentRoot keeps project selection from diff --git a/internal/integrations/integrations_test.go b/internal/integrations/integrations_test.go index eee8e17a..1af7e074 100644 --- a/internal/integrations/integrations_test.go +++ b/internal/integrations/integrations_test.go @@ -71,7 +71,7 @@ func TestSystemApplicationRootIsResolved(t *testing.T) { return } } - t.Fatal("claude-desktop is not offered as a hook target") + t.Fatalf("claude-desktop is not offered as a hook target for SystemRoot %q", "system") } func TestInstallJSONIsIdempotentAndBacksUpOnRemoval(t *testing.T) { diff --git a/ts-worker/src/implements-resolver.ts b/ts-worker/src/implements-resolver.ts index 65803641..155d7935 100644 --- a/ts-worker/src/implements-resolver.ts +++ b/ts-worker/src/implements-resolver.ts @@ -168,7 +168,12 @@ export async function resolveImplementations( const type = await checker.getDeclaredTypeOfSymbol(symbol); if (!valid(type) || !(type.isObjectType() || type.isIntersectionType())) continue; - if (!["interface", "type"].includes(entry.target.identity.kind)) continue; + if (!["interface", "type"].includes(entry.target.identity.kind)) { + limitations.add( + "Imported targets that are not an interface or a type alias are excluded.", + ); + continue; + } targets.set(symbol.id, { symbol, imported: entry, @@ -508,11 +513,13 @@ export async function resolveImplementations( ); continue; } - if ( - badDeclarations.has(sourceLocal.symbolId) || - sourceLocal.symbolId === targetLocal.symbolId - ) + if (badDeclarations.has(sourceLocal.symbolId)) { + limitations.add( + "Type declarations with compiler errors are excluded from implementation proofs.", + ); continue; + } + if (sourceLocal.symbolId === targetLocal.symbolId) continue; emit( sourceLocal, { symbol: targetMember, local: targetLocal, types: new Map() }, From acaefd6b170c0837f3dec5e659affe86c20a80aa Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 19:26:59 +0200 Subject: [PATCH 17/18] fix: align reduced PR with CI contracts --- benchmarks/mcp-token-cost/main.go | 8 +++++--- docs/protocol/mcp-surface-v3.md | 8 ++++---- internal/dartloader/worker.go | 8 ++++---- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/benchmarks/mcp-token-cost/main.go b/benchmarks/mcp-token-cost/main.go index 61f7043e..9fcffb35 100644 --- a/benchmarks/mcp-token-cost/main.go +++ b/benchmarks/mcp-token-cost/main.go @@ -87,9 +87,10 @@ func canonicalCommand(cfg config) string { // internal/mcp guards the same surface in bytes, because that package has no // tokenizer; this is the figure the reports and the protocol document quote, and // it is the half a byte guard cannot see -- a description rewritten into fewer, -// longer words moves one number and not the other. Measured at 716 over -// generation 000206, guarded with the headroom of one description. -const maximumResidentSurfaceTokens = 800 +// longer words moves one number and not the other. Measured at 812 after +// find_implementations joined the configured surface, guarded with the +// headroom of one description. +const maximumResidentSurfaceTokens = 850 func run(ctx context.Context, cfg config, command string) error { tokens, err := newCounter() @@ -253,6 +254,7 @@ func probeTools(ctx context.Context, session *sdkmcp.ClientSession, questions qu {"find_symbol", map[string]any{"name": root}}, {"find_by_intent", map[string]any{"intent": "publish a generation"}}, {"find_references", map[string]any{"name": root}}, + {"find_implementations", map[string]any{"name": root}}, {"trace_dependencies", map[string]any{"qualified_name": root, "depth": 2}}, {"get_blast_radius", map[string]any{"qualified_name": root, "depth": 2}}, } { diff --git a/docs/protocol/mcp-surface-v3.md b/docs/protocol/mcp-surface-v3.md index 0021e904..e0aed486 100644 --- a/docs/protocol/mcp-surface-v3.md +++ b/docs/protocol/mcp-surface-v3.md @@ -270,10 +270,10 @@ anuncia y no se rellena describe una respuesta que no se envía. **Lo que un anfitrión mantiene residente no es el esquema.** Oh My Pi monta cada tool como un dispositivo cuya documentación se lee bajo demanda; Claude Code difiere los esquemas detrás de su búsqueda de tools e inyecta `instructions` al -abrir la sesión. Lo residente es el nombre, dos veces, y la descripción: `716` -tokens -- `220` de enrutado y `496` de descripciones-- para las once de consulta -más `index_project`, medido por el arnés sobre la generación `000206`, frente a -`2.104` de esquema diferido. +abrir la sesión. Lo residente es el nombre, dos veces, y la descripción: `812` +tokens -- `279` de enrutado y `533` de descripciones-- para las doce de consulta +más los tres controles de indexado, medido por el smoke del arnés tras incorporar +`find_implementations`, frente a `5.049` de esquema diferido. Ahí es donde vive el enrutado, y por eso cada descripción dice contra qué alternativa nativa compite y **dónde pierde**. Nada de eso puede llevar un número diff --git a/internal/dartloader/worker.go b/internal/dartloader/worker.go index e098a928..79039a36 100644 --- a/internal/dartloader/worker.go +++ b/internal/dartloader/worker.go @@ -81,16 +81,16 @@ func launchWorker(ctx context.Context, executable string, args []string, root st func (w *workerProcess) failure(phase string, cause error) error { if w == nil { - return fmt.Errorf("Dart analyzer %s: %w", phase, cause) + return fmt.Errorf("dart analyzer %s: %w", phase, cause) } if err := w.ctx.Err(); err != nil { - return fmt.Errorf("Dart analyzer %s: %w", phase, err) + return fmt.Errorf("dart analyzer %s: %w", phase, err) } select { case <-w.done: - return fmt.Errorf("Dart analyzer %s: %w; process exit: %v; stderr: %s", phase, cause, w.exit, w.stderr.text()) + return fmt.Errorf("dart analyzer %s: %w; process exit: %v; stderr: %s", phase, cause, w.exit, w.stderr.text()) case <-time.After(100 * time.Millisecond): - return fmt.Errorf("Dart analyzer %s: %w; stderr: %s", phase, cause, w.stderr.text()) + return fmt.Errorf("dart analyzer %s: %w; stderr: %s", phase, cause, w.stderr.text()) } } From 92c24a1e6677c50af61181b66cc490b86da38a97 Mon Sep 17 00:00:00 2001 From: luqueee Date: Sun, 6 Sep 2026 19:39:20 +0200 Subject: [PATCH 18/18] fix: close reduced PR review gaps --- docs/adr/0116-typed-implementation-queries.md | 9 ++-- docs/protocol/mcp-surface-v3.md | 11 ++-- internal/config/profile_upgrade_test.go | 51 +++++++++++++++---- internal/config/profiles.go | 5 +- internal/facts/typescript_test.go | 3 +- internal/integrations/hooks_test.go | 4 +- internal/mcp/tools/find_implementations.go | 2 +- .../mcp/tools/find_implementations_test.go | 49 +++++++++++++++++- landing/src/content/docs/docs/mcp-tools.md | 6 +-- landing/src/pages/_seo.ts | 3 +- ts-worker/src/facts-cli.ts | 14 ++--- 11 files changed, 119 insertions(+), 38 deletions(-) diff --git a/docs/adr/0116-typed-implementation-queries.md b/docs/adr/0116-typed-implementation-queries.md index 53c63e73..be3bb80b 100644 --- a/docs/adr/0116-typed-implementation-queries.md +++ b/docs/adr/0116-typed-implementation-queries.md @@ -5,10 +5,11 @@ Status: accepted for local implementation, 2026-09-05. ## Decision Expose `find_implementations` as a read-only, single-text MCP query. It reads -`IMPLEMENTS` and `OVERRIDES` edges of one immutable published generation. It does -not reinterpret calls or name matches as implementations. Go retains its -existing `go/types` proofs. TypeScript emits declared and structural proofs -at index time through the installed native compiler's `isTypeAssignableTo`. +`IMPLEMENTS` and `OVERRIDES` edges from one immutable published generation per +selected profile. It does not reinterpret calls or name matches as +implementations. Go retains its existing `go/types` proofs. TypeScript emits +declared and structural proofs at index time through the installed native +compiler's `isTypeAssignableTo`. The worker examines concrete class instance types, interfaces, abstract types, object type aliases, and concrete type instances observed in type references, diff --git a/docs/protocol/mcp-surface-v3.md b/docs/protocol/mcp-surface-v3.md index e0aed486..fb8e1afa 100644 --- a/docs/protocol/mcp-surface-v3.md +++ b/docs/protocol/mcp-surface-v3.md @@ -281,10 +281,13 @@ derivado del grafo: reescribiría bytes del prompt de sistema de un cliente en c reindexado e invalidaría su caché. La superficie residente de las doce tools mide `1.874` bytes con la fórmula del -test, bajo un techo de `1.900` bytes. `TestServerSurfaceStaysCheapToLoad` fija el -techo del esquema en `18.000` bytes y falla si una tool vuelve a publicar -`outputSchema`; `TestServerSurfaceStaysCheapToKeepResident` guarda el techo -residente y falla si una descripción contiene un dígito. +test, bajo un techo de `1.900` bytes. Se reproduce con +`go test ./internal/mcp -run TestServerSurfaceStaysCheap`, sobre el catálogo +estático del ejecutable y sin depender de un corpus ni una generación. +`TestServerSurfaceStaysCheapToLoad` fija el techo del esquema en `18.000` bytes y +falla si una tool vuelve a publicar `outputSchema`; +`TestServerSurfaceStaysCheapToKeepResident` guarda el techo residente y falla si +una descripción contiene un dígito. ## 9. Códigos de error diff --git a/internal/config/profile_upgrade_test.go b/internal/config/profile_upgrade_test.go index 6f420606..cdde436b 100644 --- a/internal/config/profile_upgrade_test.go +++ b/internal/config/profile_upgrade_test.go @@ -8,6 +8,7 @@ import ( "net" "os" "path/filepath" + "strings" "testing" "github.com/Luqueee/kivgraph/internal/filelock" @@ -43,8 +44,8 @@ func TestProfileUpgradePreservesRuntimeAndFreshness(t *testing.T) { } t.Cleanup(func() { _ = listener.Close() }) listener.(*net.UnixListener).SetUnlinkOnClose(false) - if _, err := LoadProfile(path, ""); err == nil { - t.Fatal("migrated with live daemon") + if _, err := LoadProfile(path, ""); err == nil || !strings.Contains(err.Error(), "running daemon") { + t.Fatalf("live daemon refusal = %v", err) } if err := listener.Close(); err != nil { t.Fatal(err) @@ -105,8 +106,12 @@ func TestProfileUpgradeRefusesActiveWritersAndCanRetry(t *testing.T) { t.Fatalf("lock: %v %v", acquired, err) } t.Cleanup(func() { _ = lock.Release() }) - if _, err := LoadProfile(path, ""); err == nil { - t.Fatalf("migrated while %q was held", name) + expected := name + if name == "profile-migration.lock" { + expected = "profile migration" + } + if _, err := LoadProfile(path, ""); err == nil || !strings.Contains(err.Error(), expected) { + t.Fatalf("refusal while %q was held = %v", name, err) } if _, err := os.Stat(filepath.Join(state, "profiles", "default")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("published on refusal: %v", err) @@ -130,8 +135,8 @@ func TestProfileUpgradeRejectsPartialDestination(t *testing.T) { if err != nil { t.Fatal(err) } - if err := ensureDefaultProfile(configuration, configuration.Workspace.RepositoriesFile); err == nil { - t.Fatal("accepted incomplete destination") + if err := ensureDefaultProfile(configuration, configuration.Workspace.RepositoriesFile); err == nil || !strings.Contains(err.Error(), "repositories.yaml") { + t.Fatalf("incomplete destination refusal = %v", err) } } @@ -142,8 +147,8 @@ func TestProfileUpgradeRefusesSpecialGraphArtifact(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = listener.Close() }) - if _, err := LoadProfile(path, ""); err == nil { - t.Fatal("accepted special graph file") + if _, err := LoadProfile(path, ""); err == nil || !strings.Contains(err.Error(), "unexpected.sock") { + t.Fatalf("special graph refusal = %v", err) } if _, err := os.Stat(state + ".pre-profiles"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("published backup on failure: %v", err) @@ -187,6 +192,23 @@ func TestProfileUpgradeResumesOnlyAnIdenticalBackup(t *testing.T) { } } +func TestProfileUpgradeResumesAcrossPermissionNarrowing(t *testing.T) { + path, state := legacyProfileFixture(t) + if _, err := LoadProfile(path, ""); err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(filepath.Join(state, "profiles")); err != nil { + t.Fatal(err) + } + backupCurrent := filepath.Join(state+".pre-profiles", "CURRENT") + if err := os.Chmod(backupCurrent, 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadProfile(path, ""); err != nil { + t.Fatalf("resume after permission-only backup difference: %v", err) + } +} + func TestReadProfileCannotMigrateLegacyState(t *testing.T) { path, state := legacyProfileFixture(t) if _, err := ReadProfile(path, ""); err == nil { @@ -204,7 +226,14 @@ func TestReadProfileCannotMigrateLegacyState(t *testing.T) { } func TestProfileUpgradeRejectsUnsafeArtifactsWithoutPublication(t *testing.T) { - for _, kind := range []string{"current-parent", "current-file", "graph-symlink", "unexpected-socket", "unreadable-graph"} { + expectedErrors := map[string]string{ + "current-parent": "invalid generation", + "current-file": "000008", + "graph-symlink": "symbolic link", + "unexpected-socket": "unknown.sock", + "unreadable-graph": "unreadable", + } + for kind, expected := range expectedErrors { t.Run(kind, func(t *testing.T) { path, state := legacyProfileFixture(t) switch kind { @@ -233,8 +262,8 @@ func TestProfileUpgradeRejectsUnsafeArtifactsWithoutPublication(t *testing.T) { t.Fatal(err) } } - if _, err := LoadProfile(path, ""); err == nil { - t.Fatalf("accepted unsafe graph %q", kind) + if _, err := LoadProfile(path, ""); err == nil || !strings.Contains(err.Error(), expected) { + t.Fatalf("unsafe graph refusal for %q = %v", kind, err) } if _, err := os.Stat(filepath.Join(state, "profiles", "default")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("published invalid state: %v", err) diff --git a/internal/config/profiles.go b/internal/config/profiles.go index e57be778..a4d85326 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -346,7 +346,10 @@ func profileArtifactDigest(root string) ([32]byte, error) { if !info.IsDir() && !info.Mode().IsRegular() { return fmt.Errorf("unexpected recovery artifact %q", path) } - fmt.Fprintf(digest, "%q %d\n", relative, info.Mode()) + // Recovery compares identity and contents, not permissions. Copies use + // source permissions, but a different process umask may narrow them on a + // resumed migration without changing the artifact being recovered. + fmt.Fprintf(digest, "%q %d\n", relative, info.Mode().Type()) if info.IsDir() { return nil } diff --git a/internal/facts/typescript_test.go b/internal/facts/typescript_test.go index bf82df52..5e63aeab 100644 --- a/internal/facts/typescript_test.go +++ b/internal/facts/typescript_test.go @@ -1414,7 +1414,8 @@ func TestNormalizeTypeScriptImplementationEvidence(t *testing.T) { } payload.Implementations[0].Detection = "guessed" if _, _, err := NormalizeTypeScript(t.Context(), payload, workspace.Repository{RealPath: "/fixtures/implementations"}); err == nil { - t.Fatal("unknown implementation evidence accepted") + t.Fatalf("unknown implementation evidence accepted: qualified_name=%q relation=%q detection=%q", + payload.Implementations[0].QualifiedName, payload.Implementations[0].Relation, payload.Implementations[0].Detection) } } diff --git a/internal/integrations/hooks_test.go b/internal/integrations/hooks_test.go index a5052190..9d1eb933 100644 --- a/internal/integrations/hooks_test.go +++ b/internal/integrations/hooks_test.go @@ -651,8 +651,8 @@ func TestClaudeDesktopIsDetectedByItsOwnEntry(t *testing.T) { } func TestClaudeDesktopDetectionUsesConfiguredSystemRoot(t *testing.T) { - manager, _, _ := testManager(t) - systemRoot := manager.systemRoot + manager, home, _ := testManager(t) + systemRoot := filepath.Join(home, "system") entry := filepath.Join(systemRoot, "Applications", "Claude.app") if err := os.MkdirAll(entry, 0o700); err != nil { t.Fatal(err) diff --git a/internal/mcp/tools/find_implementations.go b/internal/mcp/tools/find_implementations.go index 8f975a88..31eb212d 100644 --- a/internal/mcp/tools/find_implementations.go +++ b/internal/mcp/tools/find_implementations.go @@ -60,7 +60,7 @@ func findImplementations(ctx context.Context, request *sdkmcp.CallToolRequest, a } paths := slices.Clone(arguments.Paths) for _, prefix := range paths { - if prefix == "" || path.IsAbs(prefix) || path.Clean(prefix) != strings.TrimSuffix(prefix, "/") || prefix == ".." || strings.HasPrefix(prefix, "../") { + if prefix == "" || prefix == "." || strings.ContainsRune(prefix, '\x00') || path.IsAbs(prefix) || path.Clean(prefix) != strings.TrimSuffix(prefix, "/") || prefix == ".." || strings.HasPrefix(prefix, "../") { return nil, Response[ImplementationResult]{}, NewToolError(CodeInvalidArgument, "paths must be clean repository-relative paths") } } diff --git a/internal/mcp/tools/find_implementations_test.go b/internal/mcp/tools/find_implementations_test.go index eedd99b5..3f77d62a 100644 --- a/internal/mcp/tools/find_implementations_test.go +++ b/internal/mcp/tools/find_implementations_test.go @@ -3,6 +3,10 @@ package tools import ( "context" "testing" + "time" + + "github.com/Luqueee/kivgraph/internal/facts" + "github.com/Luqueee/kivgraph/internal/hotsnapshot" ) func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { @@ -37,6 +41,17 @@ func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { if _, _, err := findImplementations(context.Background(), nil, args, dispatchSnapshot(t, 201, 2)); err == nil { t.Fatalf("cursor crossed generations: %#v", args) } + filterStore := implementationFilterSnapshot(t) + for _, detection := range []string{"declared", "structural"} { + filteredArgs := FindImplementationsInput{StableKey: "contract", Detection: detection} + _, filtered, err := findImplementations(context.Background(), nil, filteredArgs, filterStore) + if err != nil || filtered.Total != 1 || filtered.Returned != 1 { + t.Fatalf("%s filter for %#v: %#v %v", detection, filteredArgs, filtered, err) + } + if got := filtered.Results.Implementations[0].Detection; got != detection { + t.Fatalf("%s filter for %#v returned detection %q", detection, filteredArgs, got) + } + } concreteArgs := FindImplementationsInput{StableKey: "impl-sole"} _, concrete, err := findImplementations(context.Background(), nil, concreteArgs, store) if err != nil || concrete.Total != 0 { @@ -50,9 +65,41 @@ func TestImplementationsPageContainsTypedRelationsOnly(t *testing.T) { if err != nil || filtered.Total != 1 || filtered.Results.Implementations[0].FilePath != "disk.go" { t.Fatalf("paths filter for %#v: %#v %v", filteredArgs, filtered, err) } - for _, args := range []FindImplementationsInput{{StableKey: "iface-sole", Detection: "guess"}, {StableKey: "iface-sole", Paths: []string{"../outside"}}} { + for _, args := range []FindImplementationsInput{{StableKey: "iface-sole", Detection: "guess"}, {StableKey: "iface-sole", Paths: []string{"../outside"}}, {StableKey: "iface-sole", Paths: []string{"."}}, {StableKey: "iface-sole", Paths: []string{"a\x00b"}}} { if _, _, err := findImplementations(context.Background(), nil, args, store); err == nil { t.Fatalf("invalid arguments accepted: %#v", args) } } } + +func implementationFilterSnapshot(t *testing.T) *hotsnapshot.SnapshotStore { + t.Helper() + code := func(value uint8) uint8 { return value } + edge := func(source string, provenance facts.Provenance) hotsnapshot.EdgeRow { + return hotsnapshot.EdgeRow{ + SourceKey: hotsnapshot.StableKey(source), TargetKey: "contract", Kind: code(mustFactsEdgeCode(t, facts.Implements)), + Confidence: code(mustFactsConfidenceCode(t, facts.ExactTypechecked)), Provenance: code(mustFactsProvenanceCode(t, provenance)), + EvidenceKind: "checker", EvidenceSourceFileKey: "file-" + source, EvidenceTargetFileKey: "file-contract", + } + } + rows := hotsnapshot.LadybugSnapshotRows{ + Repositories: []hotsnapshot.RepositoryRow{{Key: "repository:repo-a", Name: "repo-a", Path: "/repo-a", Languages: "typescript"}}, + Packages: []hotsnapshot.PackageRow{{Key: "package-a", RepositoryKey: "repository:repo-a", Name: "pkg", ModulePath: "pkg"}}, + Files: []hotsnapshot.FileRow{ + {Key: "file-contract", RepositoryKey: "repository:repo-a", PackageKey: "package-a", Path: "contract.ts", Language: "typescript"}, + {Key: "file-declared", RepositoryKey: "repository:repo-a", PackageKey: "package-a", Path: "memory.ts", Language: "typescript"}, + {Key: "file-structural", RepositoryKey: "repository:repo-a", PackageKey: "package-a", Path: "disk.go", Language: "typescript"}, + }, + Symbols: []hotsnapshot.SymbolRow{ + {StableKey: "contract", CanonicalIdentity: "ts:Store", FileKey: "file-contract", Language: "typescript", Name: "Store", QualifiedName: "Store", Kind: "interface", StartLine: 1, EndLine: 3}, + {StableKey: "declared", CanonicalIdentity: "ts:Memory", FileKey: "file-declared", Language: "typescript", Name: "Memory", QualifiedName: "Memory", Kind: "class", StartLine: 1, EndLine: 3}, + {StableKey: "structural", CanonicalIdentity: "ts:Disk", FileKey: "file-structural", Language: "typescript", Name: "Disk", QualifiedName: "Disk", Kind: "class", StartLine: 1, EndLine: 3}, + }, + Edges: []hotsnapshot.EdgeRow{edge("declared", facts.TypeScriptImplementationDeclared), edge("structural", facts.TypeScriptImplementationStructural)}, + } + snapshot, err := hotsnapshot.BuildGraphSnapshot(rows, 200, time.Unix(1_700_000_200, 0).UTC(), 5) + if err != nil { + t.Fatalf("BuildGraphSnapshot() error = %v", err) + } + return hotsnapshot.NewSnapshotStore(snapshot) +} diff --git a/landing/src/content/docs/docs/mcp-tools.md b/landing/src/content/docs/docs/mcp-tools.md index 18880dc4..8bee343e 100644 --- a/landing/src/content/docs/docs/mcp-tools.md +++ b/landing/src/content/docs/docs/mcp-tools.md @@ -1,12 +1,12 @@ --- title: MCP tools description: >- - The fourteen tools Kivgraph registers once a generation is published, and + The fifteen tools Kivgraph registers once a generation is published, and which question each one answers. --- -`kivgraph serve` registers fourteen tools *once a generation is published*. -Twelve are read-only. `index_project` and `start_index_project` mutate only +`kivgraph serve` registers fifteen tools *once a generation is published*. +Thirteen are read-only. `index_project` and `start_index_project` mutate only after explicit consent. A server with no published generation registers those two tools plus `get_index_status`; see [Before a generation is published](#before-a-generation-is-published). diff --git a/landing/src/pages/_seo.ts b/landing/src/pages/_seo.ts index df36c11d..71be0518 100644 --- a/landing/src/pages/_seo.ts +++ b/landing/src/pages/_seo.ts @@ -84,7 +84,7 @@ export function umamiTracker(): UmamiTracker | null { } /** - * The fourteen tools the server registers, in the order the reference lists + * The fifteen tools the server registers, in the order the reference lists * them: retrieval first, then lookups, traversal, whole-graph tools and index * control. `get_unresolved_references` is not * among them. @@ -101,6 +101,7 @@ export const MCP_TOOLS = [ "get_source", "get_file_outline", "find_references", + "find_implementations", "find_cross_repo_consumers", "trace_dependencies", "get_blast_radius", diff --git a/ts-worker/src/facts-cli.ts b/ts-worker/src/facts-cli.ts index f9e8c7ca..7c738d84 100644 --- a/ts-worker/src/facts-cli.ts +++ b/ts-worker/src/facts-cli.ts @@ -68,16 +68,12 @@ * provider's configuration, and an inferred project has none. The Go side * only passes this when `typescript.include_unclaimed_sources` is on. * - * Regenerate the `ts-facts-v5` goldens, from `ts-worker/`: + * The cross-repository v4 goldens are frozen compatibility inputs: this v5 + * worker must not overwrite them. Regenerate the v5 implementation contract + * golden from `ts-worker/` with: * - * pnpm facts shared-library ../testdata/typescript/cross-repository/shared-library \ - * ../testdata/protocol/ts-facts-v5/shared-library.json - * pnpm facts consumer-a ../testdata/typescript/cross-repository/consumer-a \ - * ../testdata/protocol/ts-facts-v5/consumer-a.json \ - * --provider shared-library=../testdata/typescript/cross-repository/shared-library - * pnpm facts consumer-b ../testdata/typescript/cross-repository/consumer-b \ - * ../testdata/protocol/ts-facts-v5/consumer-b.json \ - * --provider shared-library=../testdata/typescript/cross-repository/shared-library + * pnpm facts implementations ../testdata/typescript/implementations \ + * ../testdata/protocol/ts-facts-v5/implementations.json */ import { mkdir, readFile, writeFile } from "node:fs/promises";