diff --git a/Makefile.cbm b/Makefile.cbm index 5bf14b3b..da534cb0 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -469,6 +469,7 @@ TEST_REPRO_SRCS = \ tests/repro/repro_lsp_c_cpp.c \ tests/repro/repro_lsp_go_py.c \ tests/repro/repro_lsp_ts.c \ + tests/repro/repro_ts_inherited_method.c \ tests/repro/repro_lsp_java_cs.c \ tests/repro/repro_lsp_kt_php_rust.c diff --git a/graph-ui/src/components/StatsTab.test.tsx b/graph-ui/src/components/StatsTab.test.tsx index f0cc22d5..bc6d41fe 100644 --- a/graph-ui/src/components/StatsTab.test.tsx +++ b/graph-ui/src/components/StatsTab.test.tsx @@ -65,7 +65,7 @@ describe("StatsTab index modal", () => { fireEvent.change(await screen.findByLabelText("Repository path"), { target: { value: "D:\\work\\信租风控通后端" }, }); - fireEvent.change(screen.getByLabelText("Project name"), { + fireEvent.change(screen.getByLabelText("Project ID (optional — permanent, cannot be renamed)"), { target: { value: "信租风控通后端" }, }); fireEvent.click(screen.getByRole("button", { name: "Index This Folder" })); diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx index 07e15fd5..67c38810 100644 --- a/graph-ui/src/components/StatsTab.tsx +++ b/graph-ui/src/components/StatsTab.tsx @@ -269,6 +269,7 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat onChange={(e) => setProjectName(e.target.value)} className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-2 text-[12px] text-foreground outline-none focus:border-primary/40 placeholder:text-foreground/20" /> + {t.index.projectNameHelp} diff --git a/graph-ui/src/lib/i18n.ts b/graph-ui/src/lib/i18n.ts index 942d67ad..e98e1de4 100644 --- a/graph-ui/src/lib/i18n.ts +++ b/graph-ui/src/lib/i18n.ts @@ -45,8 +45,9 @@ export const messages = { selectRepositoryFolder: "Select Repository Folder", instructions: "Navigate to the project root and click \"Index This Folder\".", repositoryPath: "Repository path", - projectName: "Project name", - projectNamePlaceholder: "Optional display name", + projectName: "Project ID (optional — permanent, cannot be renamed)", + projectNamePlaceholder: "Derived from folder name if blank", + projectNameHelp: "Becomes the database name and query prefix. Leave blank to derive it from the path.", filterFolders: "Filter folders", noSubdirectories: "No subdirectories", indexThisFolder: "Index This Folder", @@ -116,8 +117,9 @@ export const messages = { selectRepositoryFolder: "选择仓库目录", instructions: "导航到项目根目录,然后点击“索引此目录”。", repositoryPath: "仓库路径", - projectName: "项目名称", - projectNamePlaceholder: "可选显示名称", + projectName: "项目 ID(可选,永久且不可重命名)", + projectNamePlaceholder: "留空则从路径派生", + projectNameHelp: "将作为数据库名称与查询前缀;留空则从路径派生。", filterFolders: "筛选目录", noSubdirectories: "没有子目录", indexThisFolder: "索引此目录", diff --git a/tests/repro/repro_main.c b/tests/repro/repro_main.c index 6c516be3..319a8346 100644 --- a/tests/repro/repro_main.c +++ b/tests/repro/repro_main.c @@ -105,6 +105,8 @@ extern void suite_repro_grammar_misc(void); extern void suite_repro_lsp_c_cpp(void); extern void suite_repro_lsp_go_py(void); extern void suite_repro_lsp_ts(void); +/* TS cross-file inherited-method resolution gap (post-#840 probe flip) */ +extern void suite_repro_ts_inherited_method(void); extern void suite_repro_lsp_java_cs(void); extern void suite_repro_lsp_kt_php_rust(void); @@ -172,6 +174,7 @@ int main(void) { RUN_SUITE(repro_lsp_c_cpp); RUN_SUITE(repro_lsp_go_py); RUN_SUITE(repro_lsp_ts); + RUN_SUITE(repro_ts_inherited_method); RUN_SUITE(repro_lsp_java_cs); RUN_SUITE(repro_lsp_kt_php_rust); diff --git a/tests/repro/repro_ts_inherited_method.c b/tests/repro/repro_ts_inherited_method.c new file mode 100644 index 00000000..fac58ef7 --- /dev/null +++ b/tests/repro/repro_ts_inherited_method.c @@ -0,0 +1,263 @@ +/* + * repro_ts_inherited_method.c — RED reproduction: TypeScript CROSS-FILE + * INHERITED method call resolution gap (ts_lsp). + * + * THE GAP: a typed call to a method the receiver's class INHERITS from a base + * class defined in ANOTHER file is never resolved by the TS LSP: + * + * base.ts: export class Base { greet(): string { ... } } + * derived.ts: import { Base } from "./base"; + * export class Derived extends Base { ... } + * export function callSite(): string { + * const d: Derived = new Derived(); + * return d.greet(); <-- inherited, cross-file + * } + * + * CORRECT behaviour (asserted here, so this test is RED until fixed): a CALLS + * edge from the caller (callable-sourced, QN contains "callSite") to the BASE + * method definition (target QN suffix ".Base.greet" — mirroring the + * java/kotlin/php inherited-dispatch convention: resolution lands on the base + * class's method def, since Derived declares no `greet` and no such node can + * exist), carrying a genuine TS-LSP resolution strategy ("lsp_ts_*" in the + * edge's properties_json, per the ts_emit_resolved_call contract documented in + * repro_lsp_ts.c). + * + * WHY the strategy tag is part of the invariant: before the weak-short-name + * suppression (PR #840, recovering withdrawn #836/#835), this scenario looked + * resolved via a unique_name REGISTRY fallback — "greet" is unique in a 2-file + * fixture, so a short-name guess happened to bind the right node (a false + * green; in a real repo the same guess binds an arbitrary same-named method). + * PR #840 flipped the probe lrp_ts_s6_inherited_method + * (tests/test_lsp_resolution_probe.c) to assert that this weak edge is + * SUPPRESSED (calls == 0) — correct, but it leaves the underlying resolution + * gap without any red reproduction. THIS test is that reproduction: + * - on pre-#840 code the lucky edge exists but carries NO "lsp_ts" strategy + * -> RED (the green was never genuine); + * - on post-#840 code the weak edge is suppressed, no edge exists at all + * -> RED; + * - only genuine ts_lsp cross-file inheritance resolution turns it GREEN. + * + * ROOT-CAUSE POINTER (for the eventual fixer): ts_lsp cross-file inheritance + * resolution — internal/cbm/lsp/ts_lsp.c resolve_member_call/lookup_method + * only walks methods declared on the receiver's OWN class as registered in the + * module registry; it does not follow the (correctly extracted) INHERITS edge + * from Derived to an imported Base to find `greet` there. See PR #836/#840 and + * the S6 probe lrp_ts_s6_inherited_method for the full analysis. The INHERITS + * edge and both defs ARE in the graph (asserted below as preconditions), so a + * red here is the RESOLUTION gap, not an extraction failure. + * + * NOTE: line comments only inside this header (no nested block comments, per + * coding rules). + */ + +#include "test_framework.h" +#include "repro_invariant_lib.h" +#include + +#include + +/* ── Fixture ─────────────────────────────────────────────────────────────── */ + +static const RFile kTsInherited[] = { + {"base.ts", + "export class Base {\n" + " greet(): string { return \"hello\"; }\n" + "}\n"}, + {"derived.ts", + "import { Base } from \"./base\";\n" + "\n" + "export class Derived extends Base {\n" + " extra(): number { return 2; }\n" + "}\n" + "\n" + "export function callSite(): string {\n" + " const d: Derived = new Derived();\n" + " return d.greet();\n" + "}\n"}, +}; + +/* ── Local store helpers ─────────────────────────────────────────────────── */ + +/* True if some node with `label` has a QN ending in `suffix`. */ +static int node_with_qn_suffix(cbm_store_t *store, const char *project, + const char *label, const char *suffix) { + cbm_node_t *nodes = NULL; + int count = 0; + if (cbm_store_find_nodes_by_label(store, project, label, &nodes, &count) != + CBM_STORE_OK) + return 0; + int found = 0; + size_t sl = strlen(suffix); + for (int i = 0; i < count && !found; i++) { + const char *qn = nodes[i].qualified_name; + if (qn) { + size_t ql = strlen(qn); + if (ql >= sl && strcmp(qn + ql - sl, suffix) == 0) + found = 1; + } + } + cbm_store_free_nodes(nodes, count); + return found; +} + +/* + * The PRIMARY invariant, checked on a SINGLE edge (independent per-edge checks + * could false-green by combining a strategy-less lucky edge with an unrelated + * lsp_ts-tagged edge): there exists a CALLS edge whose + * - source is callable-sourced (Function/Method) and its QN contains + * `caller_substr`; + * - target QN ends with `callee_suffix`; + * - properties_json carries `strategy_substr` (substring, so any concrete + * "lsp_ts_*" tag satisfies "lsp_ts"). + * When `dump` is non-zero every CALLS edge is printed to stderr so a RED run + * documents exactly what the graph contains instead. + */ +static int lsp_resolved_edge_exists(cbm_store_t *store, const char *project, + const char *caller_substr, + const char *callee_suffix, + const char *strategy_substr, int dump) { + cbm_edge_t *edges = NULL; + int n = 0; + if (cbm_store_find_edges_by_type(store, project, "CALLS", &edges, &n) != + CBM_STORE_OK) + return 0; + int found = 0; + size_t cl = strlen(callee_suffix); + for (int i = 0; i < n; i++) { + cbm_node_t src, tgt; + if (cbm_store_find_node_by_id(store, edges[i].source_id, &src) != CBM_STORE_OK) + continue; + if (cbm_store_find_node_by_id(store, edges[i].target_id, &tgt) != CBM_STORE_OK) + continue; + if (dump) + fprintf(stderr, " [ts-inherited] CALLS %s (%s) -> %s props=%s\n", + src.qualified_name ? src.qualified_name : "?", + src.label ? src.label : "?", + tgt.qualified_name ? tgt.qualified_name : "?", + edges[i].properties_json ? edges[i].properties_json : "{}"); + const char *slabel = src.label ? src.label : ""; + if (strcmp(slabel, "Function") != 0 && strcmp(slabel, "Method") != 0) + continue; + if (!src.qualified_name || !strstr(src.qualified_name, caller_substr)) + continue; + const char *tqn = tgt.qualified_name; + if (!tqn) + continue; + size_t tl = strlen(tqn); + if (tl < cl || strcmp(tqn + tl - cl, callee_suffix) != 0) + continue; + if (!edges[i].properties_json || + !strstr(edges[i].properties_json, strategy_substr)) + continue; + found = 1; + } + cbm_store_free_edges(edges, n); + return found; +} + +/* ── Tests ───────────────────────────────────────────────────────────────── */ + +/* + * Extraction-tier preconditions — expected GREEN on current code. These prove + * a red in the pipeline test below is the RESOLUTION gap, not a fixture or + * extraction error: both files parse without has_error, base.ts yields the + * Method def `greet`, and derived.ts yields the `greet` call site. + */ +TEST(repro_ts_inherited_extraction_preconditions) { + /* base.ts extracts cleanly and defines Method greet. */ + ASSERT_TRUE(inv_extract_clean(kTsInherited[0].content, CBM_LANG_TYPESCRIPT, + "base.ts")); + CBMFileResult *rb = + inv_rx(kTsInherited[0].content, CBM_LANG_TYPESCRIPT, "base.ts"); + ASSERT_NOT_NULL(rb); + int greet_methods = 0; + for (int i = 0; i < rb->defs.count; i++) { + CBMDefinition *d = &rb->defs.items[i]; + if (d->label && strcmp(d->label, "Method") == 0 && d->name && + strcmp(d->name, "greet") == 0) + greet_methods++; + } + cbm_free_result(rb); + ASSERT_EQ(greet_methods, 1); + + /* derived.ts extracts cleanly and contains the greet call site. */ + ASSERT_TRUE(inv_extract_clean(kTsInherited[1].content, CBM_LANG_TYPESCRIPT, + "derived.ts")); + CBMFileResult *rd = + inv_rx(kTsInherited[1].content, CBM_LANG_TYPESCRIPT, "derived.ts"); + ASSERT_NOT_NULL(rd); + int has_greet_call = inv_has_call(rd, "greet"); + cbm_free_result(rd); + ASSERT_TRUE(has_greet_call); + PASS(); +} + +/* + * THE RED REPRODUCTION — index the 2-file fixture through the full production + * pipeline and assert the CORRECT outcome: an LSP-resolved CALLS edge from + * callSite to Base.greet. Store-level preconditions (callee node present, + * INHERITS extracted) are checked first so the failure is attributable to the + * missing ts_lsp cross-file inheritance resolution and nothing else. + * Returns 0 on PASS (gap fixed), non-zero on FAIL (RED = the open gap). + */ +static int run_ts_inherited_pipeline(void) { + RProj lp; + cbm_store_t *store = rh_index_files( + &lp, kTsInherited, (int)(sizeof(kTsInherited) / sizeof(kTsInherited[0]))); + if (!store) { + printf(" %sFAIL%s %s:%d: index failed (setup, NOT the gap)\n", tf_red(), + tf_reset(), __FILE__, __LINE__); + rh_cleanup(&lp, store); + return 1; + } + + int rc = 0; + + /* Precondition: the callee def node exists in the graph. */ + if (!node_with_qn_suffix(store, lp.project, "Method", ".Base.greet")) { + printf(" %sFAIL%s %s:%d: precondition — no Method node with QN suffix " + "\".Base.greet\" (extraction problem, NOT the resolution gap)\n", + tf_red(), tf_reset(), __FILE__, __LINE__); + rc = 1; + } + + /* Precondition: `Derived extends Base` produced an INHERITS edge (the S6 + * probe diagnostic confirms extraction emits it; the gap is downstream). */ + int inherits = rh_count_edges(store, lp.project, "INHERITS"); + if (inherits < 1) { + printf(" %sFAIL%s %s:%d: precondition — INHERITS=%d (expected >=1; " + "extraction problem, NOT the resolution gap)\n", + tf_red(), tf_reset(), __FILE__, __LINE__, inherits); + rc = 1; + } + + /* PRIMARY: the inherited call is resolved BY THE TS LSP — a CALLS edge + * callSite -> *.Base.greet carrying an "lsp_ts" strategy. A short-name + * registry fallback edge (no lsp_ts tag) does NOT satisfy this; nor does + * post-#840 suppression (no edge at all). */ + if (!lsp_resolved_edge_exists(store, lp.project, "callSite", ".Base.greet", + "lsp_ts", 0)) { + /* Dump what the graph actually contains so the RED row documents it. */ + (void)lsp_resolved_edge_exists(store, lp.project, "callSite", + ".Base.greet", "lsp_ts", 1); + printf(" %sFAIL%s %s:%d: no lsp_ts-resolved CALLS edge callSite -> " + "*.Base.greet — TS cross-file INHERITED method call is " + "UNRESOLVED (ts_lsp inheritance gap, see #836/#840)\n", + tf_red(), tf_reset(), __FILE__, __LINE__); + rc = 1; + } + + rh_cleanup(&lp, store); + return rc; +} + +TEST(repro_ts_inherited_method_call_resolution) { + return run_ts_inherited_pipeline(); +} + +/* ── Suite ───────────────────────────────────────────────────────────────── */ + +SUITE(repro_ts_inherited_method) { + RUN_TEST(repro_ts_inherited_extraction_preconditions); + RUN_TEST(repro_ts_inherited_method_call_resolution); +}