Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile.cbm
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion graph-ui/src/components/StatsTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand Down
1 change: 1 addition & 0 deletions graph-ui/src/components/StatsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
<span className="block text-[10px] text-foreground/25 mt-1">{t.index.projectNameHelp}</span>
</label>
</div>

Expand Down
10 changes: 6 additions & 4 deletions graph-ui/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -116,8 +117,9 @@ export const messages = {
selectRepositoryFolder: "选择仓库目录",
instructions: "导航到项目根目录,然后点击“索引此目录”。",
repositoryPath: "仓库路径",
projectName: "项目名称",
projectNamePlaceholder: "可选显示名称",
projectName: "项目 ID(可选,永久且不可重命名)",
projectNamePlaceholder: "留空则从路径派生",
projectNameHelp: "将作为数据库名称与查询前缀;留空则从路径派生。",
filterFolders: "筛选目录",
noSubdirectories: "没有子目录",
indexThisFolder: "索引此目录",
Expand Down
3 changes: 3 additions & 0 deletions tests/repro/repro_main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down
263 changes: 263 additions & 0 deletions tests/repro/repro_ts_inherited_method.c
Original file line number Diff line number Diff line change
@@ -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 <store/store.h>

#include <string.h>

/* ── 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);
}
Loading