diff --git a/Cargo.lock b/Cargo.lock index ee81d1a9..61006fe6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -483,12 +483,15 @@ dependencies = [ "clap", "codegraph-core", "codegraph-extract", + "codegraph-resolve", "criterion", "libc", + "regex", "rusqlite", "serde", "serde_json", "sha2 0.11.1", + "tree-sitter", ] [[package]] @@ -592,6 +595,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "tree-sitter", "unicode-normalization", ] @@ -1143,7 +1147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1820,7 +1824,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" dependencies = [ "io-lifetimes 3.0.1", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -2384,7 +2388,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2702,7 +2706,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2771,7 +2775,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3217,7 +3221,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3572,9 +3576,9 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.27.0" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2038684e0058edba0d17302619f62eabce4a8e11c6ac59506996a8d79848851d" +checksum = "a5129de2ccc97808c4583d48f1fb44576d015cf241e432800572bda11ef30c3b" dependencies = [ "cc", "regex", @@ -3615,9 +3619,9 @@ dependencies = [ [[package]] name = "tree-sitter-cfml" -version = "0.26.42" +version = "0.26.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf1c57e5c21ce03a08338c67441b40125227066634297ea0c2c8621372541e95" +checksum = "68ebd7091acf7ec18caf8e99c6b95116bc03b04173244d8ef26c0c185631bb9a" dependencies = [ "cc", "tree-sitter-language", @@ -3655,9 +3659,9 @@ dependencies = [ [[package]] name = "tree-sitter-erlang" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3107efc581d335488dc38d2f51ca8b7a6a98e36f07f95a8e963f031edda6f568" +checksum = "8079a9cfe733c3b6749bb3c683c0e997ee68a2305f7a210800bbbc53e82d3f64" dependencies = [ "cc", "tree-sitter-language", @@ -3745,9 +3749,9 @@ dependencies = [ [[package]] name = "tree-sitter-language" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca0d1bf6fdd806e43ae5198f82f527056d359def39e54e67a0f478ac09dac081" +checksum = "d0af592be68c579aa78a16846bd19422978c3c52e438523d45ff5d1bff1f9d4a" [[package]] name = "tree-sitter-lua" @@ -3801,9 +3805,9 @@ dependencies = [ [[package]] name = "tree-sitter-php" -version = "0.24.2" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c17c3ab69052c5eeaa7ff5cd972dd1bc25d1b97ee779fec391ad3b5df5592" +checksum = "d9779e392d38af4254f48be0e57b7eed4d50ec7a6e55a7e5e2e74df8f8f032d3" dependencies = [ "cc", "tree-sitter-language", @@ -3861,9 +3865,9 @@ dependencies = [ [[package]] name = "tree-sitter-scala" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24e0ab4505990bfe30051761d40a7bf4033ce5a81c9eda9e20e987a5cdc84826" +checksum = "c554730d29306d9c7cdd26912638d2401378c26961e12d161a071df43f49b81b" dependencies = [ "cc", "tree-sitter-language", @@ -4233,7 +4237,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/crates/codegraph-bench/Cargo.toml b/crates/codegraph-bench/Cargo.toml index 2c3a2311..cca87923 100644 --- a/crates/codegraph-bench/Cargo.toml +++ b/crates/codegraph-bench/Cargo.toml @@ -26,6 +26,9 @@ rusqlite = { workspace = true } sha2 = { workspace = true } codegraph-core = { path = "../codegraph-core" } codegraph-extract = { path = "../codegraph-extract" } +codegraph-resolve = { path = "../codegraph-resolve" } +regex = "1.10" +tree-sitter = { workspace = true } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/codegraph-bench/fixtures/go/config.go b/crates/codegraph-bench/fixtures/go/config.go new file mode 100644 index 00000000..778b69c5 --- /dev/null +++ b/crates/codegraph-bench/fixtures/go/config.go @@ -0,0 +1,41 @@ +package payroll + +import "errors" + +// Errors a payroll run returns. +var ( + // ErrNoRun is returned when no run is scheduled. + ErrNoRun = errors.New("no run") + ErrTooLate = newRunError("too late") +) + +// Phases of a payroll run. +const ( + PhaseDraft = iota + PhaseFinal +) + +// Schedule runs a hook at the end of a run. +type Schedule struct { + Hook func() error +} + +func newRunError(msg string) error { return errors.New(msg) } + +func closeBooks() error { return nil } + +var nightly = Schedule{Hook: closeBooks} + +var defaultEmitter = &Emitter{Table: "payroll"} + +var runAll = func() error { + _ = RunNightly("all") + return closeBooks() +} + +func phaseOf(final bool) int { + if final { + return PhaseFinal + } + return PhaseDraft +} diff --git a/crates/codegraph-bench/fixtures/synthesis/c/src/cmds.c b/crates/codegraph-bench/fixtures/synthesis/c/src/cmds.c new file mode 100644 index 00000000..38c84c59 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/c/src/cmds.c @@ -0,0 +1,13 @@ +#include "cmds.h" + +static void getCommand(int argc) {} +static void setCommand(int argc) {} + +static struct COMMAND_T commandTable[] = { + MAKE_CMD("get", getCommand), + MAKE_CMD("set", setCommand), +}; + +void call(struct client *c, int argc) { + c->cmd->proc(argc); +} diff --git a/crates/codegraph-bench/fixtures/synthesis/c/src/cmds.h b/crates/codegraph-bench/fixtures/synthesis/c/src/cmds.h new file mode 100644 index 00000000..cbec9bcd --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/c/src/cmds.h @@ -0,0 +1,14 @@ +typedef void cmdProc(int argc); + +struct command { + const char *name; + cmdProc *proc; +}; + +struct client { + int id; + struct command *cmd, *lastcmd; +}; + +#define MAKE_CMD(name, fn) { name, fn } +#define COMMAND_T command diff --git a/crates/codegraph-bench/fixtures/synthesis/c/src/opcodes.c b/crates/codegraph-bench/fixtures/synthesis/c/src/opcodes.c new file mode 100644 index 00000000..a7b9db55 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/c/src/opcodes.c @@ -0,0 +1,10 @@ +typedef void opcode_t(unsigned char op); + +static void op_nop(unsigned char op) {} +static void op_inc(unsigned char op) {} + +static opcode_t *opcodes[2] = { op_nop, op_inc }; + +void step(unsigned char op) { + opcodes[op](op); +} diff --git a/crates/codegraph-bench/fixtures/synthesis/c/src/optab.c b/crates/codegraph-bench/fixtures/synthesis/c/src/optab.c new file mode 100644 index 00000000..09616f8e --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/c/src/optab.c @@ -0,0 +1,18 @@ +typedef int opRun(void); + +struct op { + const char *name; + opRun *run; +}; + +#define MK(nm, fn) nm, fn +#define CMD_T op + +static int optab_a(void) { return 0; } +static int optab_b(void) { return 0; } + +#include "optab.def" + +int dispatch_op(struct op *o) { + return o->run(); +} diff --git a/crates/codegraph-bench/fixtures/synthesis/c/src/optab.def b/crates/codegraph-bench/fixtures/synthesis/c/src/optab.def new file mode 100644 index 00000000..204e392e --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/c/src/optab.def @@ -0,0 +1,4 @@ +static struct CMD_T optable[] = { + { MK("a", optab_a) }, + { MK("b", optab_b) }, +}; diff --git a/crates/codegraph-bench/fixtures/synthesis/cpp/src/launch.cpp b/crates/codegraph-bench/fixtures/synthesis/cpp/src/launch.cpp new file mode 100644 index 00000000..9b527e07 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/cpp/src/launch.cpp @@ -0,0 +1,12 @@ +void kernel_a(int n) {} + +template +void kernel_b(T n) {} + +void launch(int n) { + auto kernel = &kernel_a; + if (n > 1) { + kernel = &kernel_b; + } + kernel(n); +} diff --git a/crates/codegraph-bench/fixtures/synthesis/dispatch/app/Http/Registration.php b/crates/codegraph-bench/fixtures/synthesis/dispatch/app/Http/Registration.php new file mode 100644 index 00000000..37ce20ff --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/dispatch/app/Http/Registration.php @@ -0,0 +1,12 @@ + { } + +public class PlaceOrderHandler : IRequestHandler +{ + public Task Handle(PlaceOrder request, CancellationToken ct) => Task.FromResult(1); +} + +public class OrdersController +{ + private readonly ISender _mediator; + + public async Task Place() + { + await _mediator.Send(new PlaceOrder()); + } +} diff --git a/crates/codegraph-bench/fixtures/synthesis/go.mod b/crates/codegraph-bench/fixtures/synthesis/go.mod new file mode 100644 index 00000000..31809419 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/go.mod @@ -0,0 +1,3 @@ +module example.com/synthesis + +require github.com/gogf/gf/v2 v2.7.0 diff --git a/crates/codegraph-bench/fixtures/synthesis/go/ec2/ec2.go b/crates/codegraph-bench/fixtures/synthesis/go/ec2/ec2.go new file mode 100644 index 00000000..70e36ab1 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/go/ec2/ec2.go @@ -0,0 +1,30 @@ +package ec2 + +import ( + "context" + + "example.com/synth/refresh" +) + +type Discovery struct { + *refresh.Discovery + region string +} + +func New(region string) *Discovery { + d := &Discovery{region: region} + d.Discovery = refresh.NewDiscovery(refresh.Options{ + Mech: "ec2", + RefreshF: d.refresh, + }) + return d +} + +func (d *Discovery) refresh(ctx context.Context) ([]*refresh.Group, error) { + return nil, nil +} + +func listStatic(ctx context.Context) ([]*refresh.Group, error) { return nil, nil } + +// The static provider's options, wired at package level. +var staticOptions = refresh.Options{Mech: "static", RefreshF: listStatic} diff --git a/crates/codegraph-bench/fixtures/synthesis/go/gin/app.go b/crates/codegraph-bench/fixtures/synthesis/go/gin/app.go new file mode 100644 index 00000000..b2d35574 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/go/gin/app.go @@ -0,0 +1,11 @@ +package gin + +func Logger(c *Context) {} + +func getUser(c *Context) {} + +func setup(e *Engine) { + e.Use(Logger) + e.GET("/users", getUser) + e.GET("/inline", func(c *Context) {}) +} diff --git a/crates/codegraph-bench/fixtures/synthesis/go/gin/gin.go b/crates/codegraph-bench/fixtures/synthesis/go/gin/gin.go new file mode 100644 index 00000000..39340976 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/go/gin/gin.go @@ -0,0 +1,28 @@ +package gin + +type HandlerFunc func(*Context) + +type HandlersChain []HandlerFunc + +type Context struct { + handlers HandlersChain + index int +} + +// Next runs the rest of the handler chain. +func (c *Context) Next() { + for c.index < len(c.handlers) { + c.handlers[c.index](c) + c.index++ + } +} + +type Engine struct { + Handlers HandlersChain +} + +func (e *Engine) Use(middleware ...HandlerFunc) { + e.Handlers = append(e.Handlers, middleware...) +} + +func (e *Engine) GET(path string, handlers ...HandlerFunc) {} diff --git a/crates/codegraph-bench/fixtures/synthesis/go/refresh/refresh.go b/crates/codegraph-bench/fixtures/synthesis/go/refresh/refresh.go new file mode 100644 index 00000000..6f111b71 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/go/refresh/refresh.go @@ -0,0 +1,24 @@ +package refresh + +import "context" + +type Group struct{ Source string } + +// Options are the options for a refreshing discovery. +type Options struct { + Mech string + RefreshF func(ctx context.Context) ([]*Group, error) +} + +// Discovery runs a refresh function on an interval. +type Discovery struct { + refreshf func(ctx context.Context) ([]*Group, error) +} + +func NewDiscovery(opts Options) *Discovery { + return &Discovery{refreshf: opts.RefreshF} +} + +func (d *Discovery) Refresh(ctx context.Context) ([]*Group, error) { + return d.refreshf(ctx) +} diff --git a/crates/codegraph-bench/fixtures/synthesis/go/rpc/greeter_grpc.pb.go b/crates/codegraph-bench/fixtures/synthesis/go/rpc/greeter_grpc.pb.go new file mode 100644 index 00000000..77235adb --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/go/rpc/greeter_grpc.pb.go @@ -0,0 +1,11 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. + +package rpc + +type UnimplementedGreeterServer struct{} + +func (UnimplementedGreeterServer) SayHello(name string) (string, error) { + return "", nil +} +func (UnimplementedGreeterServer) mustEmbedUnimplementedGreeterServer() {} +func (UnimplementedGreeterServer) testEmbeddedByValue() {} diff --git a/crates/codegraph-bench/fixtures/synthesis/go/rpc/server.go b/crates/codegraph-bench/fixtures/synthesis/go/rpc/server.go new file mode 100644 index 00000000..b280b493 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/go/rpc/server.go @@ -0,0 +1,9 @@ +package rpc + +type server struct { + UnimplementedGreeterServer +} + +func (s *server) SayHello(name string) (string, error) { + return "hello " + name, nil +} diff --git a/crates/codegraph-bench/fixtures/synthesis/goframe/api/system/dept.go b/crates/codegraph-bench/fixtures/synthesis/goframe/api/system/dept.go new file mode 100644 index 00000000..73c4b69b --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/goframe/api/system/dept.go @@ -0,0 +1,24 @@ +package system + +import "github.com/gogf/gf/v2/frame/g" + +type DeptSearchReq struct { + g.Meta `path:"/dept/list" tags:"Dept" method:"get" summary:"list"` + DeptName string +} +type DeptSearchRes struct { + g.Meta `mime:"application/json"` + List []string +} + +type DeptAddReq struct { + g.Meta `path:"/dept/add" method:"post"` + Name string +} +type DeptAddRes struct{} + +// A declared route whose handler does not exist in this codebase. +type OrphanReq struct { + g.Meta `path:"/orphan" method:"get"` +} +type OrphanRes struct{} diff --git a/crates/codegraph-bench/fixtures/synthesis/goframe/internal/controller/dept.go b/crates/codegraph-bench/fixtures/synthesis/goframe/internal/controller/dept.go new file mode 100644 index 00000000..4def8b32 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/goframe/internal/controller/dept.go @@ -0,0 +1,24 @@ +package controller + +import ( + "context" + + "example.com/synthesis/goframe/api/system" +) + +type sysDeptController struct{} + +// NB: method name (List) differs from the request type (DeptSearchReq) — the join +// must be by signature, not name. +func (c *sysDeptController) List(ctx context.Context, req *system.DeptSearchReq) (res *system.DeptSearchRes, err error) { + return helper(ctx) +} + +func (c *sysDeptController) Add(ctx context.Context, req *system.DeptAddReq) (res *system.DeptAddRes, err error) { + return +} + +// Returns the response type but takes no request type — must NOT be linked. +func helper(ctx context.Context) (res *system.DeptSearchRes, err error) { + return +} diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/Posts.tsx b/crates/codegraph-bench/fixtures/synthesis/js/src/Posts.tsx new file mode 100644 index 00000000..d0d2e6cb --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/Posts.tsx @@ -0,0 +1,7 @@ +import { useGetPostsQuery, useAddPostMutation } from './api'; + +export function Posts() { + const { data } = useGetPostsQuery(); + const [addPost] = useAddPostMutation(); + return data ? addPost : null; +} diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/api.ts b/crates/codegraph-bench/fixtures/synthesis/js/src/api.ts new file mode 100644 index 00000000..b485c8a0 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/api.ts @@ -0,0 +1,20 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; + +async function loadPosts() { + return []; +} + +export const postsApi = createApi({ + reducerPath: 'posts', + baseQuery: fetchBaseQuery({ baseUrl: '/' }), + endpoints: (build) => ({ + getPosts: build.query({ + queryFn: async () => ({ data: await loadPosts() }), + }), + addPost: build.mutation({ + query: (body) => ({ url: 'posts', method: 'POST', body }), + }), + }), +}); + +export const { useGetPostsQuery, useLazyGetPostsQuery, useAddPostMutation } = postsApi; diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/app.js b/crates/codegraph-bench/fixtures/synthesis/js/src/app.js new file mode 100644 index 00000000..00fb1e9e --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/app.js @@ -0,0 +1,3 @@ +export function signIn(store, token) { + store.dispatch('user/login', token); +} diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/cartConsumer.ts b/crates/codegraph-bench/fixtures/synthesis/js/src/cartConsumer.ts new file mode 100644 index 00000000..dc50db89 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/cartConsumer.ts @@ -0,0 +1,6 @@ +import { useCartStore } from './store'; + +export function addToCart(item: string) { + const cart = useCartStore(); + cart.add(item); +} diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/store/auth.js b/crates/codegraph-bench/fixtures/synthesis/js/src/store/auth.js new file mode 100644 index 00000000..feae7043 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/store/auth.js @@ -0,0 +1,3 @@ +export function persistToken(token) { + return token; +} diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/store/modules/user.js b/crates/codegraph-bench/fixtures/synthesis/js/src/store/modules/user.js new file mode 100644 index 00000000..12cd10d0 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/store/modules/user.js @@ -0,0 +1,17 @@ +import { persistToken } from '../auth'; + +export default { + namespaced: true, + state: () => ({ token: '' }), + mutations: { + SET_TOKEN(state, token) { + state.token = token; + }, + }, + actions: { + login({ commit }, token) { + persistToken(token); + commit('SET_TOKEN', token); + }, + }, +}; diff --git a/crates/codegraph-bench/fixtures/synthesis/js/src/thunks.ts b/crates/codegraph-bench/fixtures/synthesis/js/src/thunks.ts new file mode 100644 index 00000000..cfda3491 --- /dev/null +++ b/crates/codegraph-bench/fixtures/synthesis/js/src/thunks.ts @@ -0,0 +1,9 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; + +export const refreshCart = createAsyncThunk('cart/refresh', async (id: string) => { + return { id }; +}); + +export const checkout = createAsyncThunk('cart/checkout', async (id: string, { dispatch }) => { + await dispatch(refreshCart(id)); +}); diff --git a/crates/codegraph-bench/src/go_std_gen.rs b/crates/codegraph-bench/src/go_std_gen.rs new file mode 100644 index 00000000..c3671a0b --- /dev/null +++ b/crates/codegraph-bench/src/go_std_gen.rs @@ -0,0 +1,657 @@ +//! `--gen-go-std-methods`: regenerate the rows of +//! `codegraph-resolve/src/go_std_methods.rs` from a Go source tree (upstream +//! `scripts/gen-go-stdlib-methods.mjs` at `e8ddc4e2`). For every type the +//! rows list, the generator reads `/src//*.go`, tests +//! skipped, with this repository's Go grammar, and lists each method as +//! `name:params/results`: an interface's own and those of the interfaces it +//! embeds, a named type's (receiver `T` or `*T`) and those its embedded fields +//! promote, an alias's target's. Exported names are kept, and an unexported +//! one only where a listed interface requires it. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use anyhow::{Context, Result, bail}; +use codegraph_core::types::Language; +use codegraph_extract::lang::spec_for_language; +use codegraph_resolve::go_std_methods::go_signature_arity; +use regex::Regex; +use tree_sitter::{Node, Parser}; + +/// The line before the generated rows. +pub const BEGIN: &str = " // BEGIN GENERATED (bench --gen-go-std-methods)\n"; +/// The line after them. +pub const END: &str = " // END GENERATED\n"; + +/// A method receiver's type name (upstream's receiver pattern). +static RECEIVER: LazyLock = LazyLock::new(|| { + Regex::new(r"\(\s*(?:[A-Za-z0-9_]+\s+)?\*?\s*([A-Za-z0-9_]+)").expect("receiver") +}); +/// An exported Go name. +static EXPORTED: LazyLock = LazyLock::new(|| Regex::new(r"^\p{Lu}").expect("exported")); + +/// The committed table module. +pub fn table_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../codegraph-resolve/src/go_std_methods.rs") +} + +/// The byte range of the generated rows in `module`. +fn section(module: &str) -> Result<(usize, usize)> { + let start = module.find(BEGIN).context("no BEGIN GENERATED line")? + BEGIN.len(); + let end = start + module[start..].find(END).context("no END GENERATED line")?; + Ok((start, end)) +} + +/// The import path and type each row of `module` lists, in order. +pub fn listed(module: &str) -> Result> { + let (start, end) = section(module)?; + module[start..end] + .lines() + .map(|line| { + let inner = line + .trim() + .strip_prefix("(\"") + .and_then(|rest| rest.strip_suffix("\"),")) + .with_context(|| format!("not a row: {line}"))?; + let parts: Vec<&str> = inner.split("\", \"").collect(); + if parts.len() != 3 { + bail!("not a row: {line}"); + } + Ok((parts[0].to_string(), parts[1].to_string())) + }) + .collect() +} + +/// One row as the module writes it. +fn row_line(path: &str, name: &str, methods: &str) -> String { + format!(" (\"{path}\", \"{name}\", \"{methods}\"),\n") +} + +/// A type an interface, struct or alias embeds. +#[derive(Clone)] +enum Embed { + /// `pkg.Name`, with the import path the declaring file gives `pkg`. + Qualified(Option, String), + /// A type of the same package. + Local(String), + /// The predeclared `error`. + Error, +} + +/// One element of an interface, in source order. +#[derive(Clone)] +enum Element { + Method(String, Option), + Embed(Embed), +} + +/// What a top-level type declaration is. +#[derive(Clone)] +enum Declared { + Interface(Vec), + Struct(Vec), + Alias(Option), + Other, +} + +/// A package's top-level type declarations and the methods declared on each +/// receiver type, first declaration first. +#[derive(Default)] +struct Package { + types: HashMap, + methods: HashMap)>>, +} + +/// The method lists of the listed types, each `(name, params/results)`. +type Methods = Vec<(String, Option)>; + +struct Generator { + root: PathBuf, + parser: Parser, + packages: HashMap, +} + +/// A node's text. +fn text<'s>(node: Node<'_>, source: &'s str) -> &'s str { + &source[node.byte_range()] +} + +/// A function or method node's signature as extraction stores it: its +/// parameter list's text, then its result's. +fn signature(node: Node<'_>, source: &str) -> Option { + let params = text(node.child_by_field_name("parameters")?, source); + Some(match node.child_by_field_name("result") { + Some(result) => format!("{params} {}", text(result, source)), + None => params.to_string(), + }) +} + +/// The type `node` embeds (upstream `embedded`): a pointer's and a generic +/// instantiation's base type, a qualified or a local type name. +fn embed(node: Option>, source: &str, imports: &HashMap) -> Option { + let mut node = node?; + if node.kind() == "pointer_type" { + node = node.named_child(0)?; + } + if node.kind() == "generic_type" { + node = node.child_by_field_name("type")?; + } + match node.kind() { + "qualified_type" => { + let package = text(node.child_by_field_name("package")?, source); + let name = text(node.child_by_field_name("name")?, source); + Some(Embed::Qualified( + imports.get(package).cloned(), + name.to_string(), + )) + } + "type_identifier" => Some(match text(node, source) { + "error" => Embed::Error, + name => Embed::Local(name.to_string()), + }), + _ => None, + } +} + +/// Every descendant of `node` of one of `kinds`, in document order. +fn descendants<'t>(node: Node<'t>, kinds: &[&str], found: &mut Vec>) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if kinds.contains(&child.kind()) { + found.push(child); + } + descendants(child, kinds, found); + } +} + +impl Generator { + fn new(root: &Path) -> Result { + let spec = spec_for_language(Language::Go).context("no Go grammar")?; + let mut parser = Parser::new(); + parser + .set_language(&spec.tree_sitter_language()) + .context("Go grammar")?; + Ok(Self { + root: root.to_path_buf(), + parser, + packages: HashMap::new(), + }) + } + + /// Parse the package at `import_path` once. + fn load(&mut self, import_path: &str) -> Result<()> { + if self.packages.contains_key(import_path) { + return Ok(()); + } + let mut package = Package::default(); + let dir = self.root.join("src").join(import_path); + let mut files: Vec = match fs::read_dir(&dir) { + Ok(entries) => entries + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter(|name| name.ends_with(".go") && !name.ends_with("_test.go")) + .collect(), + Err(_) => Vec::new(), + }; + files.sort(); + for file in files { + let source = fs::read_to_string(dir.join(&file)) + .with_context(|| format!("{import_path}/{file}"))?; + let tree = self.parser.parse(&source, None).context("parse")?; + let root = tree.root_node(); + let mut imports = HashMap::new(); + let mut specs = Vec::new(); + descendants(root, &["import_spec"], &mut specs); + for spec in specs { + let Some(path) = spec.child_by_field_name("path") else { + continue; + }; + let path = text(path, &source).trim_matches('"').to_string(); + let name = spec + .child_by_field_name("name") + .map(|name| text(name, &source).to_string()) + .unwrap_or_else(|| path.rsplit('/').next().unwrap_or(&path).to_string()); + imports.insert(name, path); + } + let mut types = Vec::new(); + descendants(root, &["type_spec", "type_alias"], &mut types); + for spec in types { + let top = spec.parent().is_some_and(|parent| { + parent.kind() == "type_declaration" + && parent + .parent() + .is_some_and(|file| file.kind() == "source_file") + }); + let Some(name) = spec.child_by_field_name("name") else { + continue; + }; + let name = text(name, &source).to_string(); + if !top || package.types.contains_key(&name) { + continue; + } + let ty = spec.child_by_field_name("type"); + let declared = if spec.kind() == "type_alias" { + Declared::Alias(embed(ty, &source, &imports)) + } else { + match ty { + Some(ty) if ty.kind() == "interface_type" => { + let mut elements = Vec::new(); + let mut cursor = ty.walk(); + for element in ty.named_children(&mut cursor) { + match element.kind() { + "method_elem" => { + let Some(method) = element.child_by_field_name("name") + else { + continue; + }; + elements.push(Element::Method( + text(method, &source).to_string(), + go_signature_arity( + signature(element, &source).as_deref(), + ), + )); + } + "type_elem" => { + let mut inner = element.walk(); + let terms: Vec> = element + .named_children(&mut inner) + .filter(|term| term.kind() != "comment") + .collect(); + if let [term] = terms[..] + && let Some(embedded) = + embed(Some(term), &source, &imports) + { + elements.push(Element::Embed(embedded)); + } + } + _ => {} + } + } + Declared::Interface(elements) + } + Some(ty) if ty.kind() == "struct_type" => { + let mut embeds = Vec::new(); + let mut cursor = ty.walk(); + if let Some(fields) = ty + .named_children(&mut cursor) + .find(|child| child.kind() == "field_declaration_list") + { + let mut inner = fields.walk(); + for field in fields.named_children(&mut inner) { + if field.kind() != "field_declaration" { + continue; + } + let mut names = field.walk(); + if field + .named_children(&mut names) + .any(|c| c.kind() == "field_identifier") + { + continue; + } + if let Some(embedded) = + embed(field.child_by_field_name("type"), &source, &imports) + { + embeds.push(embedded); + } + } + } + Declared::Struct(embeds) + } + _ => Declared::Other, + } + }; + package.types.insert(name, declared); + } + let mut cursor = root.walk(); + for method in root.named_children(&mut cursor) { + if method.kind() != "method_declaration" { + continue; + } + let receiver = method + .child_by_field_name("receiver") + .and_then(|receiver| RECEIVER.captures(text(receiver, &source))) + .map(|captures| captures[1].to_string()); + let name = method + .child_by_field_name("name") + .map(|name| text(name, &source).to_string()); + let (Some(receiver), Some(name)) = (receiver, name) else { + continue; + }; + let methods = package.methods.entry(receiver).or_default(); + if !methods.iter().any(|(known, _)| *known == name) { + methods.push(( + name, + go_signature_arity(signature(method, &source).as_deref()), + )); + } + } + } + self.packages.insert(import_path.to_string(), package); + Ok(()) + } + + /// Whether the package at `import_path` declares the type `name`. + fn declared(&mut self, import_path: &str, name: &str) -> Result> { + self.load(import_path)?; + Ok(self.packages[import_path].types.get(name).cloned()) + } + + /// The methods of the type `name` the package at `import_path` declares + /// (upstream `methodsOf`), its own first, then what it embeds. + fn methods_of( + &mut self, + import_path: &str, + name: &str, + seen: &mut HashSet, + ) -> Result { + if !seen.insert(format!("{import_path}.{name}")) { + return Ok(Vec::new()); + } + self.load(import_path)?; + let package = &self.packages[import_path]; + let mut methods: Methods = package.methods.get(name).cloned().unwrap_or_default(); + let Some(declared) = package.types.get(name).cloned() else { + return Ok(methods); + }; + let take = |methods: &mut Methods, more: Methods| { + for (method, arity) in more { + if !methods.iter().any(|(known, _)| *known == method) { + methods.push((method, arity)); + } + } + }; + match declared { + Declared::Interface(elements) => { + for element in elements { + match element { + Element::Method(method, arity) => { + match methods.iter_mut().find(|(known, _)| *known == method) { + Some(entry) => entry.1 = arity, + None => methods.push((method, arity)), + } + } + Element::Embed(embedded) => { + let more = self.embedded(import_path, &embedded, seen)?; + take(&mut methods, more); + } + } + } + } + Declared::Struct(embeds) => { + for embedded in embeds { + let more = self.embedded(import_path, &embedded, seen)?; + take(&mut methods, more); + } + } + Declared::Alias(Some(embedded)) => { + let more = self.embedded(import_path, &embedded, seen)?; + take(&mut methods, more); + } + Declared::Alias(None) | Declared::Other => {} + } + Ok(methods) + } + + fn embedded( + &mut self, + import_path: &str, + embedded: &Embed, + seen: &mut HashSet, + ) -> Result { + match embedded { + Embed::Error => Ok(vec![("Error".to_string(), Some("0/1".to_string()))]), + Embed::Local(name) => self.methods_of(import_path, name, seen), + Embed::Qualified(Some(path), name) => self.methods_of(path, name, seen), + Embed::Qualified(None, name) => { + bail!("{import_path}: `{name}` names a package the file does not import") + } + } + } +} + +/// The rows for the types `listed` names, generated from the Go source tree +/// at `go_src`. Fails when a listed type is missing or a kept method's +/// signature does not read. +pub fn generate(go_src: &Path, listed: &[(String, String)]) -> Result { + let mut generator = Generator::new(go_src)?; + let mut generated = Vec::new(); + let mut required: HashSet = HashSet::new(); + let mut failures = Vec::new(); + for (import_path, name) in listed { + let Some(declared) = generator.declared(import_path, name)? else { + failures.push(format!("not found: {import_path}.{name}")); + continue; + }; + let methods = generator.methods_of(import_path, name, &mut HashSet::new())?; + if matches!(declared, Declared::Interface(_)) { + required.extend( + methods + .iter() + .filter(|(method, _)| !EXPORTED.is_match(method)) + .map(|(method, _)| method.clone()), + ); + } + generated.push((import_path, name, methods)); + } + let mut rows = String::new(); + for (import_path, name, methods) in generated { + let mut kept: Vec<&(String, Option)> = methods + .iter() + .filter(|(method, _)| EXPORTED.is_match(method) || required.contains(method)) + .collect(); + kept.sort_by(|a, b| a.0.cmp(&b.0)); + let mut listed = Vec::new(); + for (method, arity) in kept { + match arity { + Some(arity) => listed.push(format!("{method}:{arity}")), + None => failures.push(format!( + "signature doesn't read: {import_path}.{name}.{method}" + )), + } + } + rows.push_str(&row_line(import_path, name, &listed.join(" "))); + } + if !failures.is_empty() { + bail!("{}", failures.join("\n")); + } + Ok(rows) +} + +/// What `--gen-go-std-methods` does with the generated rows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Print, + Check, + Write, +} + +/// Run `--gen-go-std-methods` over the module at `module_path`: print the +/// rows, check them against the committed ones (`Ok(false)` when they +/// differ) or write them in. +pub fn run( + go_src: &Path, + module_path: &Path, + mode: Mode, + out: &mut dyn std::io::Write, +) -> Result { + if !go_src.join("src").join("io").is_dir() { + bail!("{} is not a Go source tree (no src/io)", go_src.display()); + } + let module = + fs::read_to_string(module_path).with_context(|| module_path.display().to_string())?; + let rows = generate(go_src, &listed(&module)?)?; + let (start, end) = section(&module)?; + match mode { + Mode::Print => { + write!(out, "{rows}")?; + Ok(true) + } + Mode::Check => { + let same = module[start..end] == rows; + if !same { + let committed: Vec<&str> = module[start..end].lines().collect(); + for (at, line) in rows.lines().enumerate() { + if committed.get(at) != Some(&line) { + writeln!( + out, + "row {}: committed {:?}, generated {line:?}", + at + 1, + committed.get(at) + )?; + } + } + if committed.len() != rows.lines().count() { + writeln!( + out, + "committed {} rows, generated {}", + committed.len(), + rows.lines().count() + )?; + } + } + Ok(same) + } + Mode::Write => { + fs::write( + module_path, + format!("{}{rows}{}", &module[..start], &module[end..]), + )?; + Ok(true) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A scratch Go source tree, removed on drop. + struct Tree(PathBuf); + + impl Tree { + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for Tree { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn tree(files: &[(&str, &str)]) -> Tree { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + let dir = std::env::temp_dir().join(format!( + "cg-go-std-gen-{}-{nanos}-{}", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + fs::create_dir(&dir).expect("a fresh scratch directory"); + let tree = Tree(dir); + for (path, content) in files { + let full = tree.path().join("src").join(path); + fs::create_dir_all(full.parent().expect("parent")).expect("mkdir"); + fs::write(full, content).expect("write"); + } + tree + } + + const IO: &str = "package io\n\ntype Reader interface {\n\tRead(p []byte) (n int, err error)\n}\n\ntype Closer interface {\n\tClose() error\n}\n\ntype ReadCloser interface {\n\tReader\n\tCloser\n}\n"; + + fn listed_of(pairs: &[(&str, &str)]) -> Vec<(String, String)> { + pairs + .iter() + .map(|&(p, t)| (p.to_string(), t.to_string())) + .collect() + } + + #[test] + fn interfaces_structs_and_aliases_list_what_they_embed() { + let dir = tree(&[ + ("io/io.go", IO), + ( + "os/file.go", + "package os\n\nimport \"io\"\n\ntype File struct {\n\tname string\n\t*fileInner\n}\n\ntype fileInner struct{}\n\nfunc (f *fileInner) Fd() uintptr { return 0 }\nfunc (f *File) Read(b []byte) (n int, err error) { return }\nfunc (f *File) close() error { return nil }\nfunc (f File) Name() string { return f.name }\n\ntype Wrapped = io.ReadCloser\n\ntype Errs interface {\n\terror\n\tCode() int\n}\n", + ), + ( + "os/file_test.go", + "package os\n\nfunc (f *File) Hidden() {}\n", + ), + ("os/zz.go", "package os\n\nfunc (f *File) Read() {}\n"), + ]); + let rows = generate( + dir.path(), + &listed_of(&[ + ("io", "ReadCloser"), + ("os", "File"), + ("os", "Wrapped"), + ("os", "Errs"), + ]), + ) + .expect("generate"); + assert_eq!( + rows, + " (\"io\", \"ReadCloser\", \"Close:0/1 Read:1/2\"),\n (\"os\", \"File\", \"Fd:0/1 Name:0/1 Read:1/2\"),\n (\"os\", \"Wrapped\", \"Close:0/1 Read:1/2\"),\n (\"os\", \"Errs\", \"Code:0/1 Error:0/1\"),\n" + ); + } + + #[test] + fn an_unexported_method_stays_only_where_an_interface_requires_it() { + let dir = tree(&[( + "testing/testing.go", + "package testing\n\ntype TB interface {\n\tHelper()\n\tprivate()\n}\n\ntype T struct{}\n\nfunc (t *T) Helper() {}\nfunc (t *T) private() {}\nfunc (t *T) other() {}\n", + )]); + let rows = generate( + dir.path(), + &listed_of(&[("testing", "TB"), ("testing", "T")]), + ) + .expect("generate"); + assert_eq!( + rows, + " (\"testing\", \"TB\", \"Helper:0/0 private:0/0\"),\n (\"testing\", \"T\", \"Helper:0/0 private:0/0\"),\n" + ); + } + + #[test] + fn the_first_declaration_of_a_type_wins() { + let dir = tree(&[ + ("pkg/a.go", "package pkg\n\ntype X interface {\n\tA()\n}\n"), + ("pkg/b.go", "package pkg\n\ntype X interface {\n\tB()\n}\n"), + ]); + let rows = generate(dir.path(), &listed_of(&[("pkg", "X")])).expect("generate"); + assert_eq!(rows, " (\"pkg\", \"X\", \"A:0/0\"),\n"); + } + + #[test] + fn a_missing_type_fails() { + let dir = tree(&[("io/io.go", IO)]); + let error = generate(dir.path(), &listed_of(&[("io", "Writer")])).expect_err("missing"); + assert!( + error.to_string().contains("not found: io.Writer"), + "{error}" + ); + } + + #[test] + fn check_reports_a_changed_row_and_write_fixes_it() { + let dir = tree(&[("io/io.go", IO)]); + let module = dir.path().join("table.rs"); + let committed = + format!("const ROWS = &[\n{BEGIN} (\"io\", \"Reader\", \"Read:2/2\"),\n{END}];\n"); + fs::write(&module, &committed).expect("write"); + let mut out = Vec::new(); + assert!(!run(dir.path(), &module, Mode::Check, &mut out).expect("check")); + assert!(String::from_utf8_lossy(&out).contains("Read:1/2")); + assert!(run(dir.path(), &module, Mode::Write, &mut Vec::new()).expect("write")); + assert!(run(dir.path(), &module, Mode::Check, &mut Vec::new()).expect("check")); + assert_eq!( + fs::read_to_string(&module).expect("read"), + committed.replace("Read:2/2", "Read:1/2") + ); + } +} diff --git a/crates/codegraph-bench/src/lib.rs b/crates/codegraph-bench/src/lib.rs index 661b373a..3da5fcf6 100644 --- a/crates/codegraph-bench/src/lib.rs +++ b/crates/codegraph-bench/src/lib.rs @@ -1,5 +1,6 @@ pub mod ab; pub mod corpus; +pub mod go_std_gen; pub mod graph_diff; pub mod markdown; pub mod metrics; diff --git a/crates/codegraph-bench/src/main.rs b/crates/codegraph-bench/src/main.rs index b4cc3be5..9a8202c3 100644 --- a/crates/codegraph-bench/src/main.rs +++ b/crates/codegraph-bench/src/main.rs @@ -7,6 +7,7 @@ use codegraph_bench::ab::{ AbConfig, AbCorpus, DEFAULT_RUN_TIMEOUT, run_ab, write_ab_markdown, write_ab_report, }; use codegraph_bench::corpus::{corpora_root, fetch_all_in, list_statuses, list_statuses_in}; +use codegraph_bench::go_std_gen; use codegraph_bench::graph_diff::run_graph_diff; use codegraph_bench::markdown::{render_from_json, write_markdown}; use codegraph_bench::oracle::golden::write_golden; @@ -129,6 +130,37 @@ struct Args { exit 0 when identical, 1 when different, 2 when an input cannot be loaded" )] graph_diff: Option>, + + #[arg( + long, + requires = "go_src", + help = "Regenerate the Go standard-library method table from a Go source tree \ + (codegraph-resolve/src/go_std_methods.rs): print it, or with --check \ + exit 1 when the committed rows differ, or with --write rewrite them" + )] + gen_go_std_methods: bool, + + #[arg( + long, + value_name = "DIR", + help = "Go source tree (with src/io) for --gen-go-std-methods" + )] + go_src: Option, + + #[arg( + long, + requires = "gen_go_std_methods", + conflicts_with = "write", + help = "Compare the generated rows with the committed ones" + )] + check: bool, + + #[arg( + long, + requires = "gen_go_std_methods", + help = "Write the generated rows into the table module" + )] + write: bool, } #[derive(Clone, Copy, Debug, ValueEnum)] @@ -142,6 +174,9 @@ fn main() -> ExitCode { if let Some(paths) = &args.graph_diff { return graph_diff_exit(&paths[0], &paths[1]); } + if args.gen_go_std_methods { + return go_std_methods_exit(&args); + } match run(&args) { Ok(()) => ExitCode::SUCCESS, Err(error) => { @@ -165,6 +200,37 @@ fn graph_diff_exit(left: &Path, right: &Path) -> ExitCode { } } +/// `--gen-go-std-methods`: 0 when printed, written or matching; 1 when +/// `--check` finds a difference or the table cannot be generated (a listed +/// type missing, a signature that does not read), as upstream's generator +/// fails; 2 when `--go-src` is not a Go source tree. +fn go_std_methods_exit(args: &Args) -> ExitCode { + let mode = if args.check { + go_std_gen::Mode::Check + } else if args.write { + go_std_gen::Mode::Write + } else { + go_std_gen::Mode::Print + }; + let go_src = args.go_src.as_deref().expect("clap requires --go-src"); + if !go_src.join("src").join("io").is_dir() { + eprintln!( + "Error: {} is not a Go source tree (no src/io)", + go_src.display() + ); + return ExitCode::from(2); + } + let mut stdout = std::io::stdout().lock(); + match go_std_gen::run(go_src, &go_std_gen::table_path(), mode, &mut stdout) { + Ok(true) => ExitCode::SUCCESS, + Ok(false) => ExitCode::from(1), + Err(error) => { + eprintln!("Error: {error:?}"); + ExitCode::from(1) + } + } +} + fn run(args: &Args) -> Result<()> { if args.fetch_corpora { let statuses = fetch_all_in(&selected_corpora_root(args)?)?; diff --git a/crates/codegraph-cli/src/main.rs b/crates/codegraph-cli/src/main.rs index e07c5635..40b77962 100644 --- a/crates/codegraph-cli/src/main.rs +++ b/crates/codegraph-cli/src/main.rs @@ -2152,6 +2152,69 @@ fn cmd_status(path: Option, json_output: bool) -> Result<()> { } return Ok(()); } + // A database a newer codegraph wrote (OD-19) is unreadable here, and + // nothing may write it; report which versions disagree instead of + // failing like a read. + Err( + error @ codegraph_store::StoreError::FutureSchema { + found, + supported, + discard_with_uninit, + .. + }, + ) => { + if json_output { + let mut status = json!({ + "initialized": false, + "version": VERSION, + "projectPath": project, + "indexPath": index_root, + "lastIndexed": null, + "dbPath": db, + "dbExists": db_exists, + "dbSizeBytes": db_size, + "extractionStatus": "current", + "extractionStatusDetail": error.to_string(), + "recoveryCommand": null, + "legacyIndexPresent": legacy_index_present, + "legacyIndexPaths": legacy_index_paths, + "daemonRunning": daemon_running, + "daemonPidPath": daemon_pid_path, + "daemonSocketPath": daemon_socket_path, + "daemonLogPath": daemon_log_path, + }); + if wal_size > 0 { + status["walSizeBytes"] = json!(wal_size); + } + print_json(&status)?; + } else { + println!("\nCodeGraph Status\n"); + println!("Project: {}\n", project.display()); + println!( + "State: current (schema {found} is newer than this binary's {supported})" + ); + println!("Index Statistics:"); + println!(" DB Size: {:.2} MB", db_size as f64 / 1024.0 / 1024.0); + print_wal_status(wal_size, db_size); + println!("\n DB Path: {}", db.display()); + println!( + " Daemon: {}", + if daemon_running { "running" } else { "stopped" } + ); + if discard_with_uninit { + println!( + "\nRecovery: a newer CodeGraph wrote this index. Upgrade CodeGraph, or run `codegraph uninit --force {}` and then `codegraph init {}` to discard and rebuild it.", + project.display(), + project.display() + ); + } else { + println!( + "\nRecovery: a newer CodeGraph wrote this index. Upgrade CodeGraph to use it." + ); + } + } + return Ok(()); + } Err(error) => return Err(error.into()), }; if status_open.rebuilding { @@ -6506,7 +6569,7 @@ fn index_project_inner( codegraph_watch::control_inputs::write_index_rows( &store, project, - &resolver.framework_names(), + &resolver.framework_signature(), )?; if resolver.has_framework_resolvers() { let relative_files = store @@ -6527,6 +6590,22 @@ fn index_project_inner( framework_started.elapsed(), json!({ "enabled": resolver.has_framework_resolvers() }), ); + diagnostic_run.phase_start("framework_finalize"); + let framework_finalize_started = std::time::Instant::now(); + let pb = phase_spinner("Finalizing frameworks", quiet); + // Cross-file framework finalization (NestJS RouterModule prefixes, React + // Router routes named after a constant, then the routes of tables + // another file hands React Router) BEFORE resolution, as upstream's + // index runs runPostExtract: a navigation call resolves against the + // route's final name. + resolver.run_post_extract(&mut store)?; + resolver.run_cross_file_nodes(&mut store)?; + finish_phase(&pb, "Finalized frameworks"); + diagnostic_run.phase_end( + "framework_finalize", + framework_finalize_started.elapsed(), + json!({}), + ); // Finished from INSIDE the callback on the final chunk so the retained line // lands before the resolver's deferred passes (which resolve refs this bar // does not count). The trailing finish covers the no-chunk case where the @@ -6597,17 +6676,16 @@ fn index_project_inner( resolution_started.elapsed(), json!({ "processed": resolve_bar.position(), "total": resolve_bar.length() }), ); - diagnostic_run.phase_start("framework_finalize"); - let framework_finalize_started = std::time::Instant::now(); - let pb = phase_spinner("Finalizing frameworks", quiet); - // Cross-file framework finalization (NestJS RouterModule prefixing) after - // resolution, mirroring the upstream index.ts:358 runPostExtract. - resolver.run_post_extract(&mut store)?; - finish_phase(&pb, "Finalized frameworks"); + // The dispatch edges no reference names, over the finished base graph + // (W7-14). + diagnostic_run.phase_start("synthesis"); + let synthesis_started = std::time::Instant::now(); + let synthesis = codegraph_resolve::synthesis::run_stage(&mut store, project, &options)?; + codegraph_resolve::synthesis::write_marker(&store)?; diagnostic_run.phase_end( - "framework_finalize", - framework_finalize_started.elapsed(), - json!({}), + "synthesis", + synthesis_started.elapsed(), + json!({ "edges": synthesis.inserted }), ); // Every reference is now in `unresolved_refs` or `resolved_refs` // (W5-09e). diff --git a/crates/codegraph-cli/tests/batch_m_outdated_migration.rs b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs index 99f9a354..e5812597 100644 --- a/crates/codegraph-cli/tests/batch_m_outdated_migration.rs +++ b/crates/codegraph-cli/tests/batch_m_outdated_migration.rs @@ -14,6 +14,8 @@ //! the index namespace, and an `uninitialized` namespace stays reserved for an //! explicit `init`. +mod upgrade_support; + use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; @@ -1867,9 +1869,9 @@ fn outdated_migration_writes_the_class_head_rows_of_index_force() { } /// W5-06: a migration writes the control-input rows (what resolution read -/// of the JS/TS configs and manifests, the C# project files and the Cargo -/// manifests), the detected frameworks and both markers as `index --force` -/// does. +/// of the JS/TS configs and manifests, the C# project files, the Cargo +/// manifests and, W7-15d1, the Go module files), the detected frameworks and +/// both markers as `index --force` does. #[test] fn outdated_migration_writes_the_control_rows_of_index_force() { let dir = TestDir::new("outdated-control-inputs"); @@ -1901,6 +1903,8 @@ fn outdated_migration_writes_the_control_rows_of_index_force() { "[package]\nname = \"app\"\nversion = \"0.1.0\"\n", ), ("src/lib.rs", "pub fn one() -> u32 {\n 1\n}\n"), + ("svc/go.mod", "module example.com/svc\n\ngo 1.22\n"), + ("svc/main.go", "package main\n\nfunc main() {}\n"), ] { let full = project.join(path); fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); @@ -1947,6 +1951,7 @@ fn outdated_migration_writes_the_control_rows_of_index_force() { .collect::>(), [ "control_inputs:csharp", + "control_inputs:go", "control_inputs:js", "control_inputs:rust", "control_inputs_version", @@ -1957,10 +1962,11 @@ fn outdated_migration_writes_the_control_rows_of_index_force() { ); for (row, input) in [ (0, "\"App/App.csproj\""), - (1, "\"tsconfig.json\""), - (1, "\"package.json\""), - (2, "\"Cargo.toml\""), - (4, "\"react\""), + (1, "\"svc/go.mod\""), + (2, "\"tsconfig.json\""), + (2, "\"package.json\""), + (3, "\"Cargo.toml\""), + (5, "\"react\""), ] { assert!( migrated[row].1.contains(input), @@ -2473,3 +2479,148 @@ fn status_reports_a_current_index_under_the_tombstone() { let json: serde_json::Value = serde_json::from_str(&status.stdout).expect("status JSON"); assert_eq!(json["initialized"], true, "{}", status.stdout); } + +/// W7-09b: a migration names a route a constant spells before it resolves +/// the navigation calls, as `index --force` does (upstream `postExtract` +/// before resolution), so a call to the route's path navigates to it after +/// the upgrade. +#[test] +fn outdated_migration_names_constant_routes_before_resolving_navigation() { + let dir = TestDir::new("outdated-react-router-constant-route"); + let project = dir.path().join("web"); + for (path, contents) in [ + ( + "package.json", + "{\"name\":\"web\",\"dependencies\":{\"react\":\"18\",\"react-router\":\"7\"}}\n", + ), + ( + "src/config/paths.ts", + "export const paths = {\n app: { root: { path: '/app' } },\n} as const\n", + ), + ( + "src/router.tsx", + "import { createBrowserRouter, redirect } from 'react-router'\nimport { paths } from './config/paths'\nimport { AppRoot } from './AppRoot'\nexport const router = createBrowserRouter([\n { path: paths.app.root.path, element: },\n])\nexport function goApp() {\n return redirect('/app')\n}\n", + ), + ( + "src/AppRoot.tsx", + "export function AppRoot() {\n return null\n}\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let migrated = canonicalize_db(&paths.current_db()).expect("canonicalize migrated db"); + assert!( + migrated + .edges + .iter() + .any(|edge| edge.get("kind").and_then(|kind| kind.as_str()) == Some("navigates")), + "the migrated index must hold the call's navigates edge: {:?}", + migrated.edges + ); + let (_scratch, peer) = fresh_force_peer("outdated-react-router-constant-route", &project); + let rebuilt = canonicalize_db( + &IndexPaths::resolve(&peer, None) + .expect("resolve peer v2 paths") + .current_db(), + ) + .expect("canonicalize peer db"); + diff_canonical(&rebuilt, &migrated, None) + .expect("a forced migration must equal a fresh v2 `index --force`"); +} + +/// W7-14: a migration synthesizes the dispatch edges no reference names, as +/// `index --force` does, and leaves the synthesis marker a sync reads. +#[test] +fn outdated_migration_synthesizes_dispatch_edges() { + let dir = TestDir::new("outdated-synthesis"); + let project = dir.path().join("app"); + for (path, contents) in [ + ( + "src/scene.ts", + "type Callback = () => void;\nexport class Scene {\n private callbacks = new Set();\n onUpdate(cb: Callback) {\n this.callbacks.add(cb);\n }\n triggerUpdate() {\n for (const cb of this.callbacks) cb();\n }\n}\n", + ), + ( + "src/app.ts", + "import { Scene } from './scene';\nexport class App {\n scene = new Scene();\n constructor() {\n this.scene.onUpdate(this.triggerRender);\n }\n triggerRender() {\n return 1;\n }\n}\n", + ), + ] { + let full = project.join(path); + fs::create_dir_all(full.parent().unwrap()).expect("create the source directory"); + fs::write(full, contents).expect("write a source file"); + } + let run = cli(&["init", project.to_str().unwrap()]); + assert!( + run.ok, + "setup: `codegraph init` must succeed (stdout={}, stderr={})", + run.stdout, run.stderr + ); + let paths = IndexPaths::resolve(&project, None).expect("resolve v2 paths"); + stage_state_slot( + &paths, + 100, + CURRENT_STORAGE_PROTOCOL, + CURRENT_EXTRACTION_VERSION - 1, + "current", + ); + let run = cli(&["sync", project.to_str().unwrap()]); + assert!( + run.ok, + "sync must migrate an Outdated namespace: stdout={}, stderr={}", + run.stdout, run.stderr + ); + let migrated = canonicalize_db(&paths.current_db()).expect("canonicalize migrated db"); + assert!( + migrated.edges.iter().any(|edge| { + let edge = serde_json::to_string(edge).expect("serialize a canonical edge"); + edge.contains("synthesizedBy") && edge.contains("callback") + }), + "the migrated index must hold the synthesized dispatch edge: {:?}", + migrated.edges + ); + let marker = rusqlite::Connection::open_with_flags( + format!( + "{}?immutable=1", + upgrade_support::sqlite_file_uri(&paths.current_db()) + ), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("open the migrated db") + .query_row( + "SELECT value FROM project_metadata WHERE key = 'synthesis_version'", + [], + |row| row.get::<_, String>(0), + ) + .expect("the migration writes the synthesis marker"); + assert_eq!(marker, "17"); + let (_scratch, peer) = fresh_force_peer("outdated-synthesis", &project); + let rebuilt = canonicalize_db( + &IndexPaths::resolve(&peer, None) + .expect("resolve peer v2 paths") + .current_db(), + ) + .expect("canonicalize peer db"); + diff_canonical(&rebuilt, &migrated, None) + .expect("a forced migration must equal a fresh v2 `index --force`"); +} diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md new file mode 100644 index 00000000..730ae02c --- /dev/null +++ b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/README.md @@ -0,0 +1,20 @@ +# Index written by codegraph v0.55.2 + +`codegraph.db` is the database the released v0.55.2 binary (schema 10, extraction +version 22) wrote for `crates/codegraph-bench/fixtures/mini/`. The upgrade tests +open it with the current binary to prove that an index from the previous release +migrates: the outdated extraction version triggers one full rebuild. + +Provenance: + +- Binary: `codegraph 0.55.2` from `codegraph-0.55.2-x86_64-unknown-linux-musl.tar.gz` + (checked against the release's `SHA256SUMS` and `gh attestation verify`), sha256 + `6c8f0093905b08d17848dfcc05b6d04f88256bca88a89186a71c052b6165d57c`. +- Command: `CODEGRAPH_NO_DAEMON=1 CODEGRAPH_NO_WATCH=1 codegraph init `, then `.codegraph/codegraph.db` copied + here. +- `codegraph.db` sha256: + `12cfc45a0c157873f75567fcd85d8ef0522eeead31bdfe2043513d9cecf16a0b`. + +Do not regenerate it with a newer binary: its value is that an old release +wrote it. diff --git a/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/codegraph.db b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/codegraph.db new file mode 100644 index 00000000..5131f3ea Binary files /dev/null and b/crates/codegraph-cli/tests/fixtures/upgrade_v0_55/codegraph.db differ diff --git a/crates/codegraph-cli/tests/future_schema_cli.rs b/crates/codegraph-cli/tests/future_schema_cli.rs new file mode 100644 index 00000000..d040caaf --- /dev/null +++ b/crates/codegraph-cli/tests/future_schema_cli.rs @@ -0,0 +1,419 @@ +//! An index a newer codegraph wrote (OD-19), through the commands a user runs: +//! `status` reports it, reads, `sync` and `init` refuse it without changing a +//! byte, MCP answers with the refusal, a daemon does not start on it, and +//! `uninit --force` followed by `init` discards and rebuilds it. + +mod upgrade_support; + +use std::collections::BTreeMap; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use codegraph_core::IndexPaths; +use codegraph_store::migrations::CURRENT_SCHEMA_VERSION; +use codegraph_store::{ExtractionStatus, Store}; +use serde_json::{Value, json}; +use upgrade_support::*; + +const NEWER: i64 = CURRENT_SCHEMA_VERSION + 1; + +fn sidecar(db: &Path, suffix: &str) -> PathBuf { + PathBuf::from(format!("{}{suffix}", db.display())) +} + +/// The mini corpus indexed by this binary. +fn indexed_project(dir: &TestDir) -> PathBuf { + let project = dir.path().join("project"); + copy_tree(&mini_fixture(), &project); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init failed: {}", init.stderr); + project +} + +/// Record schema `NEWER` in the project's database, checkpointed. +fn record_newer_schema(paths: &IndexPaths) { + let db = paths.current_db(); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute( + "INSERT INTO schema_versions (version, applied_at, description) VALUES (?1, 0, 'newer')", + [NEWER], + ) + .unwrap(); + conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);") + .unwrap(); + drop(conn); + for suffix in ["-wal", "-shm"] { + let _ = fs::remove_file(sidecar(&db, suffix)); + } +} + +/// Record schema `NEWER` in the database's log only, as a newer binary killed +/// before its checkpoint leaves it. +fn record_newer_schema_in_the_log_only(paths: &IndexPaths) { + let db = paths.current_db(); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute_batch("PRAGMA wal_autocheckpoint = 0;") + .unwrap(); + conn.execute( + "INSERT INTO schema_versions (version, applied_at, description) VALUES (?1, 0, 'newer')", + [NEWER], + ) + .unwrap(); + let main = fs::read(&db).unwrap(); + let wal = fs::read(sidecar(&db, "-wal")).unwrap(); + drop(conn); + fs::write(&db, main).unwrap(); + fs::write(sidecar(&db, "-wal"), wal).unwrap(); + let _ = fs::remove_file(sidecar(&db, "-shm")); +} + +/// The database, its sidecars and the state slots, byte for byte (`None` when +/// absent). The daemon's own log, pid and socket files are not part of it. +fn index_bytes(paths: &IndexPaths) -> BTreeMap>> { + let db = paths.current_db(); + let mut files = BTreeMap::new(); + for (label, path) in [ + ("db", db.clone()), + ("wal", sidecar(&db, "-wal")), + ("shm", sidecar(&db, "-shm")), + ("slot0", paths.state_slots()[0].clone()), + ("slot1", paths.state_slots()[1].clone()), + ("tombstone", paths.tombstone()), + ] { + files.insert(label.to_string(), fs::read(path).ok()); + } + files +} + +fn assert_unchanged(paths: &IndexPaths, before: &BTreeMap>>, what: &str) { + let after = index_bytes(paths); + for (label, bytes) in before { + assert!( + after.get(label) == Some(bytes), + "{what} changed the index's {label}" + ); + } +} + +#[test] +fn status_reports_an_index_a_newer_codegraph_wrote() { + let dir = TestDir::new("future-schema-status"); + let project = indexed_project(&dir); + record_newer_schema(&index_paths(&project)); + + let status = cli(&["status", path_str(&project), "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["initialized"], false, "{}", status.stdout); + assert_eq!(json["extractionStatus"], "current", "{}", status.stdout); + let detail = json["extractionStatusDetail"].as_str().unwrap_or(""); + assert!( + detail.contains(&format!("schema {NEWER}")) && detail.contains("uninit --force"), + "{detail}" + ); + assert_eq!(json["recoveryCommand"], Value::Null); + + let human = cli(&["status", path_str(&project)]); + assert!(human.ok, "status failed: {}", human.stderr); + assert!( + human.stdout.contains(&format!( + "State: current (schema {NEWER} is newer than this binary's {CURRENT_SCHEMA_VERSION})" + )), + "{}", + human.stdout + ); + assert!( + human.stdout.contains("codegraph uninit --force"), + "{}", + human.stdout + ); +} + +#[test] +fn reads_sync_and_init_refuse_a_newer_schema_and_uninit_discards_it() { + let dir = TestDir::new("future-schema-commands"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + let before = index_bytes(&paths); + + let search = cli(&["search", "add", "-p", path_str(&project)]); + assert!(!search.ok, "search read a newer codegraph's index"); + assert!( + search.stderr.contains("newer CodeGraph") && search.stderr.contains("uninit --force"), + "{}", + search.stderr + ); + assert_unchanged(&paths, &before, "search"); + + let sync = cli(&["sync", path_str(&project)]); + assert!(!sync.ok, "sync wrote a newer codegraph's index"); + assert!(sync.stderr.contains("newer CodeGraph"), "{}", sync.stderr); + assert_unchanged(&paths, &before, "sync"); + + let init = cli(&["init", path_str(&project)]); + assert!(!init.ok, "init replaced a newer codegraph's index"); + assert!(init.stderr.contains("newer CodeGraph"), "{}", init.stderr); + assert_unchanged(&paths, &before, "init"); + + let uninit = cli(&["uninit", "--force", path_str(&project)]); + assert!(uninit.ok, "uninit failed: {}", uninit.stderr); + assert!(!paths.current_db().exists()); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init after uninit failed: {}", init.stderr); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); +} + +/// The same index beside the tombstone a newer codegraph's `init` could not +/// remove: `init` refuses it unchanged and names `uninit --force`, which +/// discards it, and `init` then rebuilds it. +#[test] +fn a_tombstoned_index_a_newer_codegraph_wrote_is_discarded_by_uninit() { + let dir = TestDir::new("future-schema-tombstone"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + fs::write(paths.tombstone(), b"remove failed").unwrap(); + let before = index_bytes(&paths); + + // `status` reports the newer schema, not the tombstone's `init` recovery. + let status = cli(&["status", path_str(&project), "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: Value = serde_json::from_str(&status.stdout).expect("status JSON"); + assert_eq!(json["extractionStatus"], "current", "{}", status.stdout); + let detail = json["extractionStatusDetail"].as_str().unwrap_or(""); + assert!( + detail.contains(&format!("schema {NEWER}")) && detail.contains("uninit --force"), + "{detail}" + ); + assert_eq!(json["recoveryCommand"], Value::Null, "{}", status.stdout); + let human = cli(&["status", path_str(&project)]); + assert!( + human.stdout.contains(&format!("schema {NEWER} is newer")) + && human.stdout.contains("codegraph uninit --force") + && !human.stdout.contains("blocked by the uninit tombstone"), + "{}", + human.stdout + ); + let search = cli(&["search", "add", "-p", path_str(&project)]); + assert!( + !search.ok && search.stderr.contains("newer CodeGraph"), + "{}", + search.stderr + ); + assert_unchanged(&paths, &before, "status and search"); + + let init = cli(&["init", path_str(&project)]); + assert!(!init.ok, "init replaced a newer codegraph's index"); + assert!( + init.stderr.contains("newer CodeGraph") && init.stderr.contains("uninit --force"), + "{}", + init.stderr + ); + assert_unchanged(&paths, &before, "init"); + + let uninit = cli(&["uninit", "--force", path_str(&project)]); + assert!(uninit.ok, "uninit failed: {}", uninit.stderr); + assert!(!paths.current_db().exists()); + let init = cli(&["init", path_str(&project)]); + assert!(init.ok, "init after uninit failed: {}", init.stderr); + assert_eq!(Store::extraction_status(&paths), ExtractionStatus::Current); + assert!(!paths.tombstone().exists(), "init removed the tombstone"); +} + +/// Where `uninit --force` would fail (a tombstoned newer-schema index whose +/// extraction stamp is missing), `status` reports the newer schema without +/// offering it, in JSON and in its human output. +#[test] +fn status_offers_no_uninit_where_uninit_would_fail() { + let dir = TestDir::new("future-schema-no-discard"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + { + let conn = rusqlite::Connection::open(paths.current_db()).unwrap(); + conn.execute( + "DELETE FROM project_metadata WHERE key = 'indexed_with_extraction_version'", + [], + ) + .unwrap(); + } + record_newer_schema(&paths); + fs::write(paths.tombstone(), b"remove failed").unwrap(); + let before = index_bytes(&paths); + + let status = cli(&["status", path_str(&project), "--json"]); + assert!(status.ok, "status failed: {}", status.stderr); + let json: Value = serde_json::from_str(&status.stdout).expect("status JSON"); + let detail = json["extractionStatusDetail"].as_str().unwrap_or(""); + assert!( + detail.contains(&format!("schema {NEWER}")) && !detail.contains("uninit"), + "{detail}" + ); + let human = cli(&["status", path_str(&project)]); + assert!(human.ok, "status failed: {}", human.stderr); + assert!( + human.stdout.contains(&format!("schema {NEWER} is newer")) + && !human.stdout.contains("uninit"), + "{}", + human.stdout + ); + assert!( + !cli(&["uninit", "--force", path_str(&project)]).ok, + "uninit refuses the missing stamp" + ); + assert_unchanged(&paths, &before, "status and uninit"); +} + +#[test] +fn sync_leaves_a_newer_schema_in_the_log_untouched() { + let dir = TestDir::new("future-schema-log-sync"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema_in_the_log_only(&paths); + let before = index_bytes(&paths); + assert!(before["wal"].is_some() && before["shm"].is_none()); + + let sync = cli(&["sync", path_str(&project)]); + assert!(!sync.ok, "sync wrote a newer codegraph's index"); + assert!(sync.stderr.contains("newer CodeGraph"), "{}", sync.stderr); + assert_unchanged(&paths, &before, "sync"); +} + +/// A daemon's catch-up runs `codegraph_watch::sync_project_once`: it returns +/// the refusal and changes no byte, whether the newer version is checkpointed +/// or only in the log. +#[test] +fn the_catch_up_sync_refuses_a_newer_schema() { + type Record = fn(&IndexPaths); + let record: [(&str, Record); 2] = [ + ("checkpointed", record_newer_schema), + ("log", record_newer_schema_in_the_log_only), + ]; + for (label, record) in record { + let dir = TestDir::new(&format!("future-schema-catch-up-{label}")); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record(&paths); + let before = index_bytes(&paths); + let error = codegraph_watch::sync_project_once(&project) + .err() + .unwrap_or_else(|| panic!("{label}: the catch-up wrote a newer codegraph's index")); + assert!( + format!("{error:#}").contains("newer CodeGraph"), + "{label}: {error:#}" + ); + assert_unchanged(&paths, &before, "the catch-up"); + } +} + +/// The MCP server answers a tool call with the refusal (direct mode). +#[test] +fn an_mcp_tool_call_answers_with_the_refusal() { + let dir = TestDir::new("future-schema-mcp"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + let before = index_bytes(&paths); + + let mut child = Command::new(env!("CARGO_BIN_EXE_codegraph")) + .args(["serve", "--mcp", "--path"]) + .arg(&project) + .env("CODEGRAPH_NO_DAEMON", "1") + .env("CODEGRAPH_NO_WATCH", "1") + .env( + "CODEGRAPH_MCP_REGISTRY_DIR", + dir.path().join("mcp-registry"), + ) + .env_remove("CODEGRAPH_DIR") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn serve --mcp"); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let mut send = |value: Value| { + writeln!(stdin, "{value}").unwrap(); + stdin.flush().unwrap(); + }; + let mut read_id = |want: i64| -> Value { + loop { + let mut line = String::new(); + assert!( + stdout.read_line(&mut line).unwrap() > 0, + "serve closed stdout" + ); + let Ok(value) = serde_json::from_str::(line.trim()) else { + continue; + }; + if value.get("id").and_then(Value::as_i64) == Some(want) { + return value; + } + } + }; + send(json!({ + "jsonrpc": "2.0", "id": 1, "method": "initialize", + "params": { "protocolVersion": "2024-11-05", "capabilities": {}, + "clientInfo": { "name": "future-schema", "version": "0" } } + })); + read_id(1); + send(json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })); + send(json!({ + "jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": { "name": "codegraph_search", "arguments": { "query": "add" } } + })); + let response = read_id(2).to_string(); + assert!(response.contains("newer CodeGraph"), "{response}"); + let _ = child.kill(); + let _ = child.wait(); + assert_unchanged(&paths, &before, "the MCP session"); +} + +/// A cold MCP session in the default daemon mode on `project`: wait until the +/// project's daemon refuses to start, then return a tool call's answer. +#[cfg(unix)] +fn cold_session_answer(dir: &TestDir, project: &Path, paths: &IndexPaths) -> String { + let mut session = cold::Session::start(project, dir.path()); + let log = cold::wait_for_daemon_log(paths, "refusing to start a daemon for", "", &session); + assert!( + !cold::daemon_live(project), + "no daemon may serve it:\n{log}" + ); + let answer = session.search("add"); + session.close(); + answer +} + +/// In the default daemon mode the project's daemon does not start on a newer +/// codegraph's index, and a tool call answers with the refusal. +#[cfg(unix)] +#[test] +fn a_cold_mcp_session_answers_with_the_refusal() { + let dir = TestDir::new("future-schema-cold"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema(&paths); + let before = index_bytes(&paths); + + let answer = cold_session_answer(&dir, &project, &paths); + assert!(answer.contains("newer CodeGraph"), "{answer}"); + assert_unchanged(&paths, &before, "the cold session"); +} + +/// The same when the newer schema is only in the log: the daemon folds nothing +/// into the index, and a read refuses the live log as it refuses any. +#[cfg(unix)] +#[test] +fn a_daemon_does_not_start_on_a_newer_schema_in_the_log() { + let dir = TestDir::new("future-schema-log-daemon"); + let project = indexed_project(&dir); + let paths = index_paths(&project); + record_newer_schema_in_the_log_only(&paths); + let before = index_bytes(&paths); + + let answer = cold_session_answer(&dir, &project, &paths); + assert!(answer.contains("Failed to open project"), "{answer}"); + assert_unchanged(&paths, &before, "the daemon start"); +} diff --git a/crates/codegraph-cli/tests/parallel_index.rs b/crates/codegraph-cli/tests/parallel_index.rs index d2fa8fe4..1e42edf6 100644 --- a/crates/codegraph-cli/tests/parallel_index.rs +++ b/crates/codegraph-cli/tests/parallel_index.rs @@ -424,6 +424,7 @@ fn index_force_writes_the_generated_flag_per_file() { rows, vec![ ("api.pb.go".to_string(), 1), + ("config.go".to_string(), 0), ("generator.go".to_string(), 0), ("nightly.go".to_string(), 0), ("payroll.go".to_string(), 1), diff --git a/crates/codegraph-cli/tests/probes/main.rs b/crates/codegraph-cli/tests/probes/main.rs index 2238cb2b..fd4a6f9a 100644 --- a/crates/codegraph-cli/tests/probes/main.rs +++ b/crates/codegraph-cli/tests/probes/main.rs @@ -244,6 +244,60 @@ fn pr2437_alias_into_node_modules_keeps_the_import_external() { assert_unresolved!(project, "file:src/card.ts" => imports => "format"); } +/// Upstream #2165 (W7-09a): an import tries the tsconfig nearest its file +/// before the root's, so each app of a monorepo resolves its own `@/*`, and +/// an app declaring `~/*` only in its own config (LibreChat's `client/`) +/// resolves it at all. A file outside every app keeps the root's alias. +#[test] +fn pr2165_each_app_resolves_its_own_tsconfig_alias() { + let format = "export function format(value: number): string {\n return String(value);\n}\n"; + let main = |spec: &str| { + format!( + "import {{ format }} from '{spec}';\n\nexport function render(): string {{\n return format(1);\n}}\n" + ) + }; + let project = Project::new() + .file( + "tsconfig.json", + "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"paths\": { \"@/*\": [\"./shared/*\"] }\n }\n}\n", + ) + .file("shared/lib/format.ts", format) + .file( + "apps/web/tsconfig.json", + "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"paths\": { \"@/*\": [\"./src/*\"] }\n }\n}\n", + ) + .file("apps/web/src/lib/format.ts", format) + .file("apps/web/src/main.ts", &main("@/lib/format")) + .file( + "client/tsconfig.json", + "{\n \"compilerOptions\": {\n \"paths\": { \"~/*\": [\"./src/*\"] }\n }\n}\n", + ) + .file("client/src/lib/format.ts", format) + .file("client/src/main.ts", &main("~/lib/format")) + .file("tools/report.ts", &main("@/lib/format")) + .index(); + + assert_edge!( + project, + "file:apps/web/src/main.ts" => imports => "function format @apps/web/src/lib/format.ts", + resolved_by = "import", + ); + assert_no_edge!( + project, + "file:apps/web/src/main.ts" => imports => "function format @shared/lib/format.ts" + ); + assert_edge!( + project, + "file:client/src/main.ts" => imports => "function format @client/src/lib/format.ts", + resolved_by = "import", + ); + assert_edge!( + project, + "file:tools/report.ts" => imports => "function format @shared/lib/format.ts", + resolved_by = "import", + ); +} + /// W3: the cross-language resolution seams (plan section 3 "W3"). Every probe /// of this wave sits here, so `cargo test --test probes -- seams` runs them. mod seams { @@ -2799,6 +2853,14 @@ mod js { "{:?}", component_calls("components/Dialog.vue") ); + // `@click="close"` is still the handler it names (W7-18c's template + // pass, at the component's line). + assert!( + component_calls("components/Dialog.vue") + .contains(&"close@components/Dialog.vue:1".to_string()), + "{:?}", + component_calls("components/Dialog.vue") + ); } /// Upstream #2177 (`48498021`), its JavaScript part: fetch's Response, @@ -5527,6 +5589,75 @@ class basic_json ); } + /// Upstream #2407's override assertions (`cpp-base-class-lookup.test.ts`, + /// "what the hierarchy feeds"), drawn by the synthesis stage's + /// `cpp-override` pass (W7-14b): each base method links to the overrides + /// in the subclasses name lookup found, never to a namesake's, and + /// through an alias to the class it names. + #[test] + fn pr2407_a_base_method_links_to_the_overrides_of_its_subclasses() { + let project = base_lookup_project(); + let graph = project.graph(); + let overrides = |qualified: &str, file: &str| -> Vec { + let id = &the_class(graph, qualified, file).id; + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| { + &edge.source == id + && edge.kind == "calls" + && edge.metadata["synthesizedBy"] == "cpp-override" + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|target| target.qualified_name.clone()) + .collect(); + out.sort(); + out + }; + assert_eq!( + overrides("leveldb::Iterator::Next", "include/leveldb/iterator.h"), + ["leveldb::DBIter::Next"], + "{}", + graph.report() + ); + assert_eq!( + overrides("leveldb::SkipList::Iterator::Next", "db/skiplist.h"), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + overrides( + "google::protobuf::Message::ByteSizeLong", + "src/google/protobuf/message.h" + ), + [ + "google::protobuf::DynamicMessage::ByteSizeLong", + "google::protobuf::internal::ZeroFieldsBase::ByteSizeLong", + ], + "{}", + graph.report() + ); + assert_eq!( + overrides( + "google::protobuf::json_internal::ResolverPool::Message::ByteSizeLong", + "src/google/protobuf/json/internal/untyped_message.h" + ), + Vec::::new(), + "{}", + graph.report() + ); + assert_eq!( + overrides( + "rocksdb::InternalIteratorBase::Next", + "table/internal_iterator.h" + ), + ["rocksdb::MergingIterator::Next"], + "{}", + graph.report() + ); + } + /// Upstream #2426's last case, which needs #2407's lookup: protobuf's /// `FieldDescriptor`, walked in brace scopes after an unknown attribute /// macro broke the tree, keeps its namespaces, and its base @@ -6882,6 +7013,69 @@ class basic_json mod java { use super::support::{Graph, Project}; + /// tree-sitter-java wraps several supertypes in a `type_list`: a class + /// implementing two interfaces and an interface extending two get an + /// edge to each (upstream reads the list type by type), and no + /// reference is named after the whole list. + #[test] + fn a_heritage_list_resolves_each_type() { + let project = Project::new() + .file( + "src/app/Repo.java", + "package app;\n\npublic interface Repo {\n void save();\n}\n", + ) + .file( + "src/app/Store.java", + "package app;\n\npublic interface Store {\n void load();\n}\n", + ) + .file( + "src/app/JpaRepo.java", + "package app;\n\npublic class JpaRepo implements Repo, Store {\n public void save() { }\n public void load() { }\n}\n", + ) + .file( + "src/app/Combined.java", + "package app;\n\npublic interface Combined extends Repo, Store {\n}\n", + ) + .index(); + let graph = project.graph(); + let supertypes = |source: &str, kind: &str| -> Vec { + let mut out: Vec = graph + .edges + .iter() + .filter(|edge| edge.kind == kind) + .filter(|edge| { + graph + .node(&edge.source) + .is_some_and(|node| node.qualified_name == source) + }) + .filter_map(|edge| graph.node(&edge.target)) + .map(|node| node.qualified_name.clone()) + .collect(); + out.sort(); + out + }; + assert_eq!( + supertypes("app::JpaRepo", "implements"), + ["app::Repo", "app::Store"], + "{}", + graph.report() + ); + assert_eq!( + supertypes("app::Combined", "extends"), + ["app::Repo", "app::Store"], + "{}", + graph.report() + ); + assert!( + graph + .unresolved + .iter() + .all(|reference| !reference.reference_name.contains(',')), + "{}", + graph.report() + ); + } + /// `qualified` of every method of `file`, sorted. fn methods_in(graph: &Graph, file: &str) -> Vec { let mut out: Vec = graph @@ -10547,6 +10741,70 @@ mod swift { "{report}" ); } + + /// Upstream #2683: a call written after `try!` links what it calls. Calls + /// after `try?` and `try` are the control. Upstream's TortureTry.swift, + /// reduced to calls whose callees the project declares. + /// + /// Not yet on Windows: the held tree-sitter-swift 0.7.3 builds the `try!` + /// entry of its scanner's 64-bit mask as `1UL << 32`, which MSVC folds to + /// 0, so there `try! Message2(…)` parses as a call of `! Message2` and + /// `try! Settings.Defaults.load()` loses its receiver. 0.7.4 fixes it but + /// regenerates the grammar, which needs a walker change first + /// (docs/grammar-manifest.md); drop the `ignore` with that upgrade. + #[test] + #[cfg_attr( + windows, + ignore = "tree-sitter-swift 0.7.3 misparses `try!` on MSVC (upstream #2683)" + )] + fn pr2683_a_call_after_try_bang_links_what_it_calls() { + let project = Project::new() + .file( + "App/Models.swift", + "import Foundation\n\nstruct Message2 {\n init(data: Data?) throws {}\n}\n\nfinal class Store {\n init(path: String) throws {}\n}\n\nenum Settings {\n enum Defaults {\n static func load(from name: String) throws -> Settings.Defaults { .init() }\n }\n}\n", + ) + .file( + "App/Repository.swift", + "import Foundation\n\nfinal class Repository {\n func load(data: Data?) throws -> Message2 {\n let msg2 = try! Message2(data: data!)\n let msg3 = try? Message2(data: data!)\n let msg4 = try Message2(data: data!)\n let store = try! Store(path: \"x\")\n let settings = try! Settings.Defaults.load(from: \"defaults\")\n try! validate(msg2)\n _ = (msg3, msg4, store, settings)\n return try! Message2(data: data!)\n }\n\n func validate(_ m: Message2) throws {}\n}\n", + ) + .index(); + let graph = project.graph(); + let lines = |kind: &str, target: &str| -> Vec { + let mut lines: Vec = graph + .edges_matching("method Repository::load", kind, target) + .iter() + .filter_map(|edge| edge.line) + .collect(); + lines.sort_unstable(); + lines + }; + let report = graph.report(); + assert_eq!( + lines("instantiates", "struct Message2"), + [5, 6, 7, 12], + "{report}" + ); + assert_eq!(lines("instantiates", "class Store"), [8], "{report}"); + assert_eq!( + lines("calls", "method Settings::Defaults::load"), + [9], + "{report}" + ); + assert_eq!( + lines("calls", "method Repository::validate"), + [10], + "{report}" + ); + // The misparse left references named after the `!`. + assert!( + graph + .unresolved + .iter() + .all(|reference| !reference.reference_name.starts_with('!')), + "{}", + graph.unresolved_list() + ); + } } mod objc { @@ -12818,7 +13076,7 @@ mod go { } mod rust { - use super::support::{Graph, Project}; + use super::support::{Graph, Project, assert_edge, assert_no_edge, assert_unresolved}; /// `kind:qualifiedName` of every `calls` / `references` / `instantiates` /// target out of the functions named `name` (upstream's `targets`). @@ -12852,6 +13110,33 @@ mod rust { .collect() } + /// The member-path std gate deferred on 2026-10-03 needs no change: since + /// the #2169 port, a receiver whose declared type the project does not + /// declare (`PathBuf`, `&Path`, `std::path::PathBuf`) binds no project + /// method by name, so `index_root.join(..)` no longer reaches the project's + /// `LineIndex::join`; a project-typed receiver keeps its method. + #[test] + fn pr2169_a_std_typed_rust_receiver_binds_no_project_method_by_name() { + let project = Project::new() + .file("Cargo.toml", "[package]\nname = \"app\"\nversion = \"0.1.0\"\n") + .file( + "src/lib.rs", + "use std::path::{Path, PathBuf};\n\npub struct LineIndex;\nimpl LineIndex {\n pub fn join(&self, _x: &str) -> usize { 0 }\n}\n\npub struct EnvGuard;\nimpl EnvGuard {\n pub fn set(&self) {}\n}\npub fn env_guard() -> EnvGuard { EnvGuard }\n\npub fn annotated(index_root: PathBuf) -> PathBuf {\n index_root.join(\"x\")\n}\n\npub fn let_typed() -> PathBuf {\n let base: PathBuf = PathBuf::from(\"a\");\n base.join(\"b\")\n}\n\npub fn path_typed(p: &Path) -> PathBuf {\n p.join(\"c\")\n}\n\npub fn std_path(q: std::path::PathBuf) -> PathBuf {\n q.join(\"d\")\n}\n\npub fn project_typed() {\n let mut env = env_guard();\n env.set();\n}\n", + ) + .index(); + + for (function, call) in [ + ("annotated", "index_root.join"), + ("let_typed", "base.join"), + ("path_typed", "p.join"), + ("std_path", "q.join"), + ] { + assert_no_edge!(project, function => calls => "method LineIndex::join"); + assert_unresolved!(project, function => calls => call); + } + assert_edge!(project, "project_typed" => calls => "method EnvGuard::set"); + } + /// Upstream #2124: a bare Rust name reaches only what is in scope — /// `Some(x)` / `Ok(x)` the prelude's, not a project variant or a struct /// another file declares; a variant through a `use` of its enum's `*`; a @@ -18251,7 +18536,10 @@ mod import_bindings { /// (upstream's `LINKS`, in this port's graph: it links neither Astro's /// `Shell` to the `BaseLayout` component nor Express's mount of /// `uploadRoutes` to its `router`, upstream's two other links). - const LINKS: [&str; 20] = [ + const LINKS: [&str; 21] = [ + // The test file's top-level `const page = ` renders it + // (W7-14e's `jsx-render`), recomputed with the import's link. + "calls constant src/pages/groups_page.test.tsx -> function src/pages/groups_page.tsx::Component", "calls function src/call.ts -> function src/handler.ts::handle", "extends class src/admin.ts -> class src/base.ts::BaseUser", "imports file backend/server.js -> file backend/routes/uploadRoutes.js::uploadRoutes.js", @@ -18670,3 +18958,17905 @@ mod pascal_includes { ); } } + +/// What framework resolvers record on the edges they make (W7). +mod frameworks { + use super::support::Project; + + /// Upstream #2159: a link a framework or bridge resolver made names that + /// resolver in `metadata.framework`, as a synthesized channel names + /// itself with `synthesizedBy`: a Tauri `invoke('greet')` names `tauri`. + #[test] + fn pr2159_a_framework_resolved_edge_names_its_resolver() { + let project = Project::new() + .file("src-tauri/tauri.conf.json", "{}\n") + .file( + "src/main.ts", + "import { invoke } from '@tauri-apps/api/core';\n\nexport function run(): Promise {\n return invoke('greet');\n}\n", + ) + .file( + "src-tauri/src/main.rs", + "#[tauri::command]\npub fn greet() -> String {\n String::new()\n}\n\nfn main() {\n greet();\n}\n", + ) + .index(); + let graph = project.graph(); + let bridged = graph.edges_matching("file:src/main.ts", "calls", "function greet"); + assert_eq!(bridged.len(), 1, "{}", graph.report()); + assert_eq!(bridged[0].resolved_by(), Some("framework")); + assert_eq!( + bridged[0] + .metadata + .get("framework") + .and_then(|v| v.as_str()), + Some("tauri") + ); + // A call no framework resolved names none. + let direct = graph.edges_matching("function main", "calls", "function greet"); + assert_eq!(direct.len(), 1, "{}", graph.report()); + assert_eq!(direct[0].metadata.get("framework"), None); + } + + /// The kinds of every node named `name`, in order. + fn kinds_of(graph: &super::support::Graph, name: &str) -> Vec { + let mut kinds: Vec = graph + .nodes + .iter() + .filter(|node| node.name == name) + .map(|node| node.kind.clone()) + .collect(); + kinds.sort(); + kinds + } + + /// Upstream #841 and #2429: a const a wrapper declares as a component — + /// `forwardRef`, `memo`, `styled.x\`…\``, `styled(Base)\`…\``, typed or not + /// — is a `component` node, whose inline render function's calls are its + /// own; a lowercase memo, another template tag, a comparison and any + /// other initializer stay constants. + #[test] + fn pr0841_a_wrapper_declared_component_is_a_component() { + let project = Project::new() + .file("package.json", "{\"dependencies\":{\"react\":\"^18.0.0\"}}\n") + .file( + "ui.tsx", + "import * as React from 'react';\nimport styled from 'styled-components';\nexport const Button = React.forwardRef((props, ref) =>