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..4915ae48 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` | @@ -43,10 +44,11 @@ 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` | -Eleven 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. +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. 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 @@ -81,8 +83,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/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/cmd/kivgraph/BUILD.bazel b/cmd/kivgraph/BUILD.bazel index 2c9d5781..f70f5b61 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 new file mode 100644 index 00000000..3de140e1 --- /dev/null +++ b/cmd/kivgraph/doctor_profile_test.go @@ -0,0 +1,80 @@ +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()) + } + 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 538f141d..3eb3d81d 100644 --- a/cmd/kivgraph/main.go +++ b/cmd/kivgraph/main.go @@ -2063,6 +2063,26 @@ 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) (loadedConfiguration config.Loaded, profileErr, configurationErr error) { + loadedConfiguration, configurationErr = config.Load(configPath) + if configurationErr != nil { + return config.Loaded{}, nil, configurationErr + } + profiles := filepath.Join(filepath.Dir(loadedConfiguration.Config.Storage.DatabasePath), "profiles") + if _, err := os.Stat(profiles); errors.Is(err, os.ErrNotExist) { + return loadedConfiguration, nil, nil + } else if err != nil { + return config.Loaded{}, nil, fmt.Errorf("inspect profiles: %w", err) + } + profile, profileErr := config.ReadProfile(configPath, "") + if profileErr != nil { + return loadedConfiguration, profileErr, nil + } + return profile, nil, nil +} + func runDoctor(args []string, stdout, stderr io.Writer) int { var options doctorOptions flags := doctorFlagSet(&options) @@ -2074,7 +2094,7 @@ func runDoctor(args []string, stdout, stderr io.Writer) int { return 2 } - loaded, err := config.Load(options.ConfigPath) + loaded, profileErr, err := readDoctorConfiguration(options.ConfigPath) if err != nil { writeResult(stdout, false, "config: FAIL (%v)", err) writeResult(stdout, false, "doctor: FAIL") @@ -2092,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/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/docs/adr/0116-typed-implementation-queries.md b/docs/adr/0116-typed-implementation-queries.md new file mode 100644 index 00000000..be3bb80b --- /dev/null +++ b/docs/adr/0116-typed-implementation-queries.md @@ -0,0 +1,59 @@ +# ADR 0116: 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 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, +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/protocol/mcp-surface-v3.md b/docs/protocol/mcp-surface-v3.md index b0c4753f..fb8e1afa 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 @@ -269,20 +270,24 @@ 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 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. 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 @@ -290,3 +295,14 @@ 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`, +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/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/config/BUILD.bazel b/internal/config/BUILD.bazel index b1adc836..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", @@ -25,6 +26,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 +34,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..cdde436b --- /dev/null +++ b/internal/config/profile_upgrade_test.go @@ -0,0 +1,278 @@ +//go:build unix + +package config + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + "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 || !strings.Contains(err.Error(), "running daemon") { + t.Fatalf("live daemon refusal = %v", err) + } + 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 into %q: %v", root, 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() }) + 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) + } + 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 || !strings.Contains(err.Error(), "repositories.yaml") { + t.Fatalf("incomplete destination refusal = %v", err) + } +} + +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 || !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) + } + 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.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("changed=%t: resume: %v", changed, err) + } + }) + } +} + +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 { + 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) { + 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 { + 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 || !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) + } + }) + } +} + +// 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..a4d85326 100644 --- a/internal/config/profiles.go +++ b/internal/config/profiles.go @@ -2,12 +2,21 @@ package config import ( "bytes" + "crypto/sha256" "errors" "fmt" + "io" + "io/fs" + "net" "os" "path/filepath" "sort" + "strings" + "syscall" + "time" + "github.com/Luqueee/kivgraph/internal/durable" + "github.com/Luqueee/kivgraph/internal/filelock" "gopkg.in/yaml.v3" ) @@ -44,14 +53,31 @@ 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() { + 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) + } backupRoot := stateRoot + ".pre-profiles" if _, err := os.Stat(stateRoot); errors.Is(err, os.ErrNotExist) { if _, backupErr := os.Stat(backupRoot); backupErr == nil { @@ -64,6 +90,23 @@ func ensureDefaultProfile(configuration Config, repositoriesPath string) error { } else if err != nil { return fmt.Errorf("inspect legacy profile state %q: %w", stateRoot, 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(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 + } root := profilesRoot(configuration) if entries, err := os.ReadDir(root); err == nil { for _, entry := range entries { @@ -82,11 +125,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,36 +133,66 @@ 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 := 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 { + 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 := 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.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 := 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 @@ -135,6 +204,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 +224,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 +238,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 { @@ -194,18 +298,74 @@ 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 } - 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 { + 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 errors.Join(fmt.Errorf("create copied artifact %q: %w", destination, err), input.Close()) + } + _, copyErr := io.Copy(output, input) + 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 } +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) + } + // 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 + } + 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) @@ -218,6 +378,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) @@ -249,6 +413,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 @@ -260,7 +434,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/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..7e5928d0 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,24 +538,39 @@ 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 + 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 { @@ -1096,8 +1105,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 +1122,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 +1135,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 +1177,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 +1193,9 @@ 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 + if !deliverBeforeShutdown(output, message, done) { + return + } } } } diff --git a/internal/dartloader/worker.go b/internal/dartloader/worker.go new file mode 100644 index 00000000..79039a36 --- /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: %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()) + } +} + +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..957b39b9 --- /dev/null +++ b/internal/dartloader/worker_test.go @@ -0,0 +1,102 @@ +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.Fatalf("%s worker startup: %v", protocol, 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.Fatalf("%s worker startup: %v", protocol, e) + } + defer c.close() + <-c.worker.done + _, err = c.call(t.Context(), "analysis.getNavigation", map[string]any{}) + } + 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) + } + }) + } +} + +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("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/codes.go b/internal/facts/codes.go index e5dcf281..0559dad1 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, + 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, } // 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..8fc0b1a1 100644 --- a/internal/facts/typescript.go +++ b/internal/facts/typescript.go @@ -11,8 +11,8 @@ 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. // @@ -20,17 +20,25 @@ const TypeScriptWireVersion = 4 // 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 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"` +} + +type TypeScriptImplementation struct { + TypeScriptExtends + Detection string `json:"detection"` + Relation EdgeKind `json:"relation"` } // TypeScriptRepository names the repository the payload belongs to. @@ -286,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 @@ -300,13 +311,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 +753,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: 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}) + } + for _, relation := range relations { + ext := relation.TypeScriptExtends if err := ctx.Err(); err != nil { return Set{}, TypeScriptReport{}, err } @@ -814,12 +841,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 +861,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: UnresolvedImplementationCoverage, 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..5e63aeab 100644 --- a/internal/facts/typescript_test.go +++ b/internal/facts/typescript_test.go @@ -1351,3 +1351,80 @@ 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.Fatalf("read implementations.json: %v", err) + } + payload, err := DecodeTypeScriptPayload(data) + if err != nil { + t.Fatalf("decode implementations.json: %v", err) + } + set, _, err := NormalizeTypeScript(t.Context(), payload, workspace.Repository{RealPath: "/fixtures/implementations"}) + if err != nil { + t.Fatalf("normalize implementations.json: %v", err) + } + if err := set.Validate(); err != nil { + t.Fatalf("validate implementations.json: %v", 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 { + pair := names[edge.SourceKey] + "->" + names[edge.TargetKey] + if !edge.Confidence.Exact() || edge.EvidenceKey == "" { + t.Fatalf("%s: unproven relationship: %#v", pair, edge) + } + 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} { + if edges[pair] != provenance { + t.Errorf("%s provenance=%s want=%s", pair, edges[pair], provenance) + } + } + if _, exists := edges["Wrong->Reader"]; exists { + t.Fatal("implementation Wrong->Reader: incompatible types connected") + } + 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.Fatalf("unknown implementation evidence accepted: qualified_name=%q relation=%q detection=%q", + payload.Implementations[0].QualifiedName, payload.Implementations[0].Relation, payload.Implementations[0].Detection) + } +} + +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/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/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/hooks_test.go b/internal/integrations/hooks_test.go index af2b4e2a..9d1eb933 100644 --- a/internal/integrations/hooks_test.go +++ b/internal/integrations/hooks_test.go @@ -650,6 +650,28 @@ func TestClaudeDesktopIsDetectedByItsOwnEntry(t *testing.T) { } } +func TestClaudeDesktopDetectionUsesConfiguredSystemRoot(t *testing.T) { + 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) + } + detections, err := manager.DetectHookTargets(ScopeUser) + if err != nil { + 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", systemRoot) + } + return + } + } + t.Fatalf("claude-desktop is not offered as a hook target with system root %q", systemRoot) +} + // 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/integrations/integrations.go b/internal/integrations/integrations.go index 69a5a92c..a52d5794 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,17 @@ func New(options Options) (Manager, error) { if err != nil { return Manager{}, err } + 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, homeDir: homeDir, projectDir: projectDir, executable: executable, diff --git a/internal/integrations/integrations_test.go b/internal/integrations/integrations_test.go index f8e8f9ce..1af7e074 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", @@ -28,6 +29,51 @@ 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() { + 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", + ProjectDir: t.TempDir(), + Executable: testsupport.InstalledExecutable(), + GOOS: "darwin", + }) + if err != nil { + t.Fatalf("New(SystemRoot=%q) error = %v", "system", err) + } + 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.Fatalf("claude-desktop is not offered as a hook target for SystemRoot %q", "system") +} + 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/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..31eb212d --- /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 == "" || 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") + } + } + 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, 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) +} diff --git a/internal/mcp/tools/find_implementations_test.go b/internal/mcp/tools/find_implementations_test.go new file mode 100644 index 00000000..3f77d62a --- /dev/null +++ b/internal/mcp/tools/find_implementations_test.go @@ -0,0 +1,105 @@ +package tools + +import ( + "context" + "testing" + "time" + + "github.com/Luqueee/kivgraph/internal/facts" + "github.com/Luqueee/kivgraph/internal/hotsnapshot" +) + +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.Fatalf("find implementations with %#v: %v", args, err) + } + if first.Total != 2 || first.Returned != 1 || first.NextCursor == nil { + 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 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) + } + args.Detection = "declared" + if _, _, err := findImplementations(context.Background(), nil, args, store); err == nil { + t.Fatalf("cursor accepted changed filters: %#v", args) + } + args.Detection = "" + 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 { + 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 %#v", concreteArgs) + } + 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 for %#v: %#v %v", filteredArgs, filtered, err) + } + 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/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/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..3270220b 100644 --- a/internal/storage/ladybug/canonical_schema_test.go +++ b/internal/storage/ladybug/canonical_schema_test.go @@ -12,10 +12,10 @@ 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) + 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/landing/src/content/docs/docs/mcp-tools.md b/landing/src/content/docs/docs/mcp-tools.md index 5a9fe1ef..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). @@ -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. 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. 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/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": [] +} 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..522b4904 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,9 +196,11 @@ export function scopeName(node: Node): string | undefined { } if ( isMethodDeclaration(node) || + 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 2b5f7849..7c738d84 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,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-v4` 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-v4/shared-library.json - * pnpm facts consumer-a ../testdata/typescript/cross-repository/consumer-a \ - * ../testdata/protocol/ts-facts-v4/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 \ - * --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"; @@ -85,6 +81,10 @@ import path from "node:path"; import { isEntryPoint } from "./entry-point.js"; import { type ExtendsEdge, resolveExtends } from "./extends-resolver.js"; +import { + type ImplementationEdge, + resolveImplementations, +} from "./implements-resolver.js"; import type { ImportedSymbol, ReexportedSymbol, @@ -108,7 +108,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 +122,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 +345,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 @@ -382,6 +393,19 @@ export async function collectFacts( extendsResolution.extends, manifest?.name ?? repositoryName, ); + const implementationNormalization = implementationFactSymbols( + root, + 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. + 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) @@ -440,7 +464,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 +534,15 @@ export async function collectFacts( }), exports: exportSymbols.exports, extends: extendsFacts.extends, + implementations: implementationNormalization.implementations, + implementationLimitations: [ + ...implementationResolution.limitations, + ...(unclaimed.length > 0 + ? [ + "Inferred files contribute symbols and references, but do not attest implementation coverage.", + ] + : []), + ], dependencies: dependencyFacts, unresolved, }; @@ -922,50 +955,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, @@ -1218,7 +1288,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 +1303,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..77fa23b7 --- /dev/null +++ b/ts-worker/src/implements-resolver.test.ts @@ -0,0 +1,279 @@ +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 {} +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, [], { + 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); + expect( + result.edges + .filter((edge) => edge.base.sourceQualifiedName.startsWith("Concrete")) + .map((edge) => [ + edge.base.sourceQualifiedName, + edge.targetQualifiedName, + edge.relation, + ]), + ).toEqual([ + ["Concrete.run", "Base.run", "OVERRIDES"], + ["Concrete", "Base", "IMPLEMENTS"], + ]); + 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 () => { + 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, []); + 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, + edge.detection, + ]), + ).toEqual([ + ["Generic.get", "Box.get", "structural"], + ["Generic", "Box", "structural"], + ["Generic", "TextBox", "structural"], + ["StringBox.get", "Box.get", "declared"], + ["StringBox", "Box", "declared"], + ["StringBox", "TextBox", "structural"], + ]); +}); + +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); + 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, + edge.relation, + ]), + ).toEqual([ + ["Declared.read", "Reader.read", "declared", "IMPLEMENTS"], + ["Declared", "Reader", "declared", "IMPLEMENTS"], + ["Structural.read", "Reader.read", "structural", "IMPLEMENTS"], + ["Structural", "Reader", "structural", "IMPLEMENTS"], + ]); + expect( + result.edges.map((edge) => [ + edge.base.sourceQualifiedName, + edge.identity?.repository, + edge.identity?.file, + ]), + ).toEqual([ + ["Declared.read", "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( + 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 new file mode 100644 index 00000000..155d7935 --- /dev/null +++ b/ts-worker/src/implements-resolver.ts @@ -0,0 +1,540 @@ +/** 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(); + 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 symbolsByFile.get(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)) { + limitations.add( + "Imported targets that are not an interface or a type alias are excluded.", + ); + 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 => { + if ( + isClassDeclaration(node) || + isTypeReferenceNode(node) || + isNewExpression(node) + ) + 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)) { + 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, + )) { + 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); + } + } 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(); + } + } + 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 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>(); + 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, + ...targetsWithoutRequiredMembers, + ]); + 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"; + 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, + exportedName: targetMember.name, + 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)) { + 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() }, + detection, + target.local?.kind === "class", + ); + } + } + } + service.assertFresh(view); + return { + generation: view.generation, + edges: [...edges.entries()] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .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",