Files
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

518 lines
20 KiB
C

/*
* repro_issue787.c -- Reproduce-first / regression guard for bug #787.
*
* Issue #787: "Nondeterministic Java USAGE-edge misattribution across
* same-package files — USAGE sources vary per run"
*
* ROOT CAUSE (introduced by PR #667, merge commit 36d83280):
* PR #667 switched Java/Go module QNs from filename-stem-based
* ("proj.pkg.OwnerController") to DIRECTORY-based ("proj.pkg"), so all
* files in the same Java package share a single module QN. This QN also
* collides with the pipeline's Folder node for that directory.
*
* When a source-node finder (find_enclosing_node in pass_usages.c,
* find_source_node in pass_parallel.c, calls_find_source in pass_calls.c)
* resolves a CLASS-LEVEL usage (e.g., a field type reference
* `private IRepository repo;`), the enclosing_func_qn equals the shared
* directory module QN. Looking that QN up in the graph buffer returns the
* ONE Folder/Project node shared by every file in the package. That shared
* node's file_path was clobbered by each file's always-emitted Module def —
* through TWO code paths:
* - sequential: cbm_gbuf_upsert_node updated name/file_path/range in
* place (its #667 guard only protected the label);
* - parallel: merge_update_existing applied unconditional "src wins"
* when merging worker-local gbufs, even relabelling the Folder node to
* Module — and worker merge ORDER varies per run (the race flavor).
* USAGE edges from all same-package class-level references then share a
* single source node whose file_path is whichever file won the last write.
*
* Spring-petclinic oracle:
* MATCH (c {name:'OwnerRepository'})<-[r:USAGE]-(m) RETURN m.file_path
* should yield exactly 7 distinct files; HEAD (dcf98dc) returns 4-6 with
* bogus entries varying per run.
*
* FIX (three co-ordinated parts):
* 1. graph_buffer.c cbm_gbuf_upsert_node: a Module def colliding with a
* Project/Folder node no longer updates ANY field (was: label only).
* 2. graph_buffer.c merge_update_existing: same guard on the parallel
* worker-gbuf merge path.
* 3. pipeline finders (pass_usages/pass_parallel/pass_calls): a lookup that
* lands on a structural directory container (Folder/Project — see
* cbm_pipeline_node_is_dir_container) is treated as a miss, falling
* through to the per-file File node, so every file's class-level usages
* attribute to that file's unique File node.
*
* FIXTURE DESIGN (minimal spring-petclinic analog):
* Package owner/ — three Java files:
* IRepository.java defines interface IRepository (the used type)
* ServiceA.java field `IRepository repo;` (class-level USAGE)
* ServiceB.java field `IRepository repo;` (class-level USAGE)
* Package web/ — one Java file:
* WebController.java field `IRepository repo;` (cross-package USAGE)
*
* Correct USAGE edges to IRepository must source from EXACTLY:
* owner/ServiceA.java, owner/ServiceB.java, web/WebController.java
* (3 distinct source files, each appearing exactly once).
*
* Pre-fix: owner/ServiceA.java and owner/ServiceB.java share the same
* source node (the "proj.owner" Folder/Module node), so the query returns
* at most 2 distinct file_paths (and one of the two is random).
* Post-fix: each file gets its own source → 3 distinct, stable file_paths.
*
* WHY RED on buggy HEAD:
* The stability assertion (same source set across N runs) fires because the
* shared Folder node's file_path changes between indexing passes (different
* file write orders can produce different results each run). The exactness
* assertion (set equals the expected three files) fires because the owner/
* package's two callers are conflated into one node whose file_path is
* whichever of ServiceA.java / ServiceB.java was extracted last.
*
* NOTE: this test uses N=5 independent full-pipeline index+query rounds so
* that any ordering-based nondeterminism has several opportunities to
* surface. Each round opens a fresh in-memory project (different tmpdir →
* different project name → completely isolated store).
*/
#include <foundation/compat.h>
#include "test_framework.h"
#include "repro_harness.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
/* ── Fixture files ──────────────────────────────────────────────────────── */
/* IRepository: the type that will be a USAGE target. */
static const char k_irepository[] =
"package org.example.owner;\n"
"\n"
"public interface IRepository {\n"
" void save(Object o);\n"
" Object findById(int id);\n"
"}\n";
/* ServiceA: reference to IRepository ONLY at class level (field declaration —
* no constructor parameter). A class-level reference's enclosing scope is the
* MODULE QN, which for Java is the package directory QN shared by every file
* in owner/ — the collision at the heart of #787. Keeping the field as the
* file's only IRepository reference makes the buggy collapse deterministic:
* ServiceA.java and ServiceB.java always conflate to ONE source node
* regardless of file processing order, so the distinct-source-file set is
* short in every ordering (no false green when the "right" file happens to
* win the last-writer race). */
static const char k_service_a[] =
"package org.example.owner;\n"
"\n"
"public class ServiceA {\n"
" private IRepository repo;\n"
"\n"
" public void doA() {\n"
" repo.save(\"a\");\n"
" }\n"
"}\n";
/* ServiceB: same shape as ServiceA — class-level-only reference. */
static const char k_service_b[] =
"package org.example.owner;\n"
"\n"
"public class ServiceB {\n"
" private IRepository repo;\n"
"\n"
" public void doB() {\n"
" repo.save(\"b\");\n"
" }\n"
"}\n";
/* WebController: cross-package reference to IRepository. */
static const char k_web_controller[] =
"package org.example.web;\n"
"\n"
"import org.example.owner.IRepository;\n"
"\n"
"public class WebController {\n"
" private IRepository repo;\n"
"\n"
" public WebController(IRepository repo) {\n"
" this.repo = repo;\n"
" }\n"
"\n"
" public void handle() {\n"
" repo.save(\"web\");\n"
" }\n"
"}\n";
static const RFile k_files[] = {
{"owner/IRepository.java", k_irepository},
{"owner/ServiceA.java", k_service_a},
{"owner/ServiceB.java", k_service_b},
{"web/WebController.java", k_web_controller},
};
static const int k_nfiles = (int)(sizeof(k_files) / sizeof(k_files[0]));
/* The full pipeline switches to the parallel (worker) path above
* MIN_FILES_FOR_PARALLEL=50 files (pipeline.c). The bug has TWO faces:
* - sequential: cbm_gbuf_upsert_node clobbered the Folder node in place;
* - parallel: merge_update_existing clobbered it during worker-local gbuf
* merge (worker order → run-to-run nondeterminism).
* The 4-file fixture only exercises the sequential face, so a second fixture
* pads the same core files with FILLER_COUNT inert same-package classes to
* push the file count over the threshold and exercise the merge face too. */
enum { REPRO787_FILLER_COUNT = 60, REPRO787_NAME_SZ = 64, REPRO787_BODY_SZ = 192 };
/* Build the >50-file fixture into files[]/name_bufs[]/body_bufs[] (caller
* arrays sized k_nfiles + REPRO787_FILLER_COUNT). Returns total file count.
* Caller frees name_bufs/body_bufs entries [k_nfiles..total). */
static int build_parallel_fixture(RFile *files, char **name_bufs, char **body_bufs) {
int n = 0;
for (int i = 0; i < k_nfiles; i++) {
files[n++] = k_files[i];
}
for (int i = 0; i < REPRO787_FILLER_COUNT; i++) {
char *name = malloc(REPRO787_NAME_SZ);
char *body = malloc(REPRO787_BODY_SZ);
if (!name || !body) {
free(name);
free(body);
break;
}
snprintf(name, REPRO787_NAME_SZ, "owner/Filler%02d.java", i);
snprintf(body, REPRO787_BODY_SZ,
"package org.example.owner;\n"
"\n"
"public class Filler%02d {\n"
" private int value%02d;\n"
"}\n",
i, i);
name_bufs[n] = name;
body_bufs[n] = body;
files[n].name = name;
files[n].content = body;
n++;
}
return n;
}
/* ── Helper: collect USAGE source file_paths for IRepository ───────────── */
/*
* collect_usage_sources: index the fixture once, find the IRepository node,
* walk all USAGE edges that target it, and write the source file_paths
* (up to `cap`) into `out`. DISTINCT-file (set) semantics match the petclinic
* oracle ("exactly 7 user files"): one file may legitimately contribute several
* USAGE edges from different source nodes (a method-scoped reference sources
* from the Method node, a class-level field reference from the file-scope
* node) — ownership is per FILE, so duplicate paths are collapsed. Returns the
* number of distinct sources found (may be truncated by `cap`).
*
* The caller is responsible for free()ing each string in `out[0..return-1]`.
* Returns -1 on setup failure.
*/
static int collect_usage_sources_n(const RFile *files, int nfiles, char **out, int cap) {
RProj lp;
cbm_store_t *store = rh_index_files(&lp, files, nfiles);
if (!store) {
return -1;
}
/* Locate the IRepository node by name. */
cbm_node_t *candidates = NULL;
int ncand = 0;
int rc = cbm_store_find_nodes_by_name(store, lp.project, "IRepository",
&candidates, &ncand);
if (rc != CBM_STORE_OK || ncand == 0) {
cbm_store_free_nodes(candidates, ncand);
rh_cleanup(&lp, store);
return -1;
}
/* Pick the Interface / Class node (label check). */
int64_t target_id = 0;
for (int i = 0; i < ncand; i++) {
const char *lbl = candidates[i].label ? candidates[i].label : "";
if (strcmp(lbl, "Interface") == 0 || strcmp(lbl, "Class") == 0) {
target_id = candidates[i].id;
break;
}
}
cbm_store_free_nodes(candidates, ncand);
if (!target_id) {
rh_cleanup(&lp, store);
return -1;
}
/* Walk inbound USAGE edges. */
cbm_edge_t *edges = NULL;
int nedges = 0;
rc = cbm_store_find_edges_by_target_type(store, target_id, "USAGE", &edges, &nedges);
if (rc != CBM_STORE_OK) {
rh_cleanup(&lp, store);
return -1;
}
int found = 0;
for (int i = 0; i < nedges && found < cap; i++) {
cbm_node_t src_node;
if (cbm_store_find_node_by_id(store, edges[i].source_id, &src_node) != CBM_STORE_OK) {
continue;
}
if (!src_node.file_path || !src_node.file_path[0]) {
continue;
}
/* Set semantics: skip a file_path already collected. */
int dup = 0;
for (int j = 0; j < found; j++) {
if (strcmp(out[j], src_node.file_path) == 0) {
dup = 1;
break;
}
}
if (dup) {
continue;
}
out[found++] = strdup(src_node.file_path);
}
cbm_store_free_edges(edges, nedges);
rh_cleanup(&lp, store);
return found;
}
/* Check that paths[] contains exactly the expected file suffixes, once each.
* Returns 1 if all expected suffixes appear exactly once, 0 otherwise.
* Prints a diagnostic on mismatch. */
static int check_sources_exact(char **paths, int count,
const char **expected, int nexpected) {
if (count != nexpected) {
printf(" source count %d != expected %d\n", count, nexpected);
for (int i = 0; i < count; i++) {
printf(" got: %s\n", paths[i] ? paths[i] : "(null)");
}
return 0;
}
for (int e = 0; e < nexpected; e++) {
int seen = 0;
for (int i = 0; i < count; i++) {
if (paths[i] && strstr(paths[i], expected[e])) {
seen++;
}
}
if (seen != 1) {
printf(" expected suffix '%s' appears %d time(s) (want 1)\n",
expected[e], seen);
for (int i = 0; i < count; i++) {
printf(" got: %s\n", paths[i] ? paths[i] : "(null)");
}
return 0;
}
}
return 1;
}
/* Expected distinct USAGE source files (shared by all three tests). */
static const char *k_expected[] = {
"owner/ServiceA.java",
"owner/ServiceB.java",
"web/WebController.java",
};
static const int k_nexpected = (int)(sizeof(k_expected) / sizeof(k_expected[0]));
/* ── Tests ──────────────────────────────────────────────────────────────── */
/*
* repro_issue787_usage_exact_sources
*
* Single-run correctness check (sequential pipeline path): after indexing
* the 4-file fixture exactly once, the USAGE edges targeting IRepository
* must source from exactly the three expected files.
*
* RED on buggy HEAD: the two same-package callers (ServiceA.java, ServiceB.java)
* collapse onto a single shared Folder/Module node whose file_path is
* whichever of the package's files was processed last. The distinct source
* set has 2 entries instead of 3 (possibly including a bogus non-referencing
* file such as IRepository.java itself).
*/
TEST(repro_issue787_usage_exact_sources) {
#define MAX_SRCS 16
char *paths[MAX_SRCS];
memset(paths, 0, sizeof(paths));
int count = collect_usage_sources_n(k_files, k_nfiles, paths, MAX_SRCS);
if (count < 0) {
FAIL("fixture indexing or IRepository lookup failed");
}
int ok = check_sources_exact(paths, count, k_expected, k_nexpected);
for (int i = 0; i < MAX_SRCS; i++) {
free(paths[i]);
paths[i] = NULL;
}
#undef MAX_SRCS
if (!ok) {
FAIL("USAGE edge source file_paths do not match expected set");
}
PASS();
}
/*
* repro_issue787_usage_stable_across_runs
*
* Stability check: index the same fixture N=5 times independently (each
* run uses a fresh tmpdir + fresh DB), collect the USAGE source file_paths,
* and assert they are IDENTICAL across all runs.
*
* RED on buggy HEAD: the shared Folder/Module node for "proj.owner" is
* upserted once per file in the package; its file_path is set to the last
* file written. Across runs the write order may differ (filesystem readdir
* order, thread scheduling, etc.), so the collapsed source file_path varies,
* and the set of paths returned by the query changes run to run.
*
* Even if the first-run result happened to be correct, the stability
* assertion would still catch a subsequent run that diverged.
*
* N=5 gives five independent opportunities to expose nondeterminism without
* making the test prohibitively slow (each index+query round is < 1 s).
*/
TEST(repro_issue787_usage_stable_across_runs) {
#define N_RUNS 5
#define MAX_SRCS 16
/* Collect results across N runs. */
char *all_paths[N_RUNS][MAX_SRCS];
int all_counts[N_RUNS];
memset(all_paths, 0, sizeof(all_paths));
memset(all_counts, 0, sizeof(all_counts));
for (int run = 0; run < N_RUNS; run++) {
all_counts[run] = collect_usage_sources_n(k_files, k_nfiles, all_paths[run], MAX_SRCS);
if (all_counts[run] < 0) {
/* Free already-allocated strings from prior runs. */
for (int r = 0; r <= run; r++) {
for (int i = 0; i < MAX_SRCS; i++) { free(all_paths[r][i]); }
}
FAIL("fixture indexing or IRepository lookup failed on one run");
}
}
/* Assert all runs agree with run 0. */
int stable = 1;
for (int run = 1; run < N_RUNS; run++) {
if (all_counts[run] != all_counts[0]) {
printf(" run %d returned %d sources, run 0 returned %d\n",
run, all_counts[run], all_counts[0]);
stable = 0;
continue;
}
/* Each path in run 0 must appear (by suffix) in run `run`. */
for (int i = 0; i < all_counts[0]; i++) {
if (!all_paths[0][i]) {
continue;
}
int found = 0;
for (int j = 0; j < all_counts[run]; j++) {
if (all_paths[run][j] &&
strcmp(all_paths[0][i], all_paths[run][j]) == 0) {
found = 1;
break;
}
}
if (!found) {
printf(" run %d missing source '%s' (present in run 0)\n",
run, all_paths[0][i]);
stable = 0;
}
}
}
for (int r = 0; r < N_RUNS; r++) {
for (int i = 0; i < MAX_SRCS; i++) { free(all_paths[r][i]); }
}
if (!stable) {
FAIL("USAGE source file_paths differ across runs — nondeterministic");
}
/* Also check correctness of run 0. */
char *paths0[MAX_SRCS];
memset(paths0, 0, sizeof(paths0));
int count0 = collect_usage_sources_n(k_files, k_nfiles, paths0, MAX_SRCS);
if (count0 < 0) {
FAIL("verification re-index failed");
}
int ok = check_sources_exact(paths0, count0, k_expected, k_nexpected);
for (int i = 0; i < MAX_SRCS; i++) { free(paths0[i]); }
if (!ok) {
FAIL("USAGE sources are stable but do not match expected set");
}
#undef N_RUNS
#undef MAX_SRCS
PASS();
}
/*
* repro_issue787_usage_stable_parallel
*
* PARALLEL-path check: same core fixture padded past MIN_FILES_FOR_PARALLEL
* (>50 files) so index_repository takes the worker/merge path, repeated N=3
* times. Asserts the distinct USAGE source set equals the expected files on
* every run.
*
* RED on buggy HEAD via a DIFFERENT mechanism than the sequential tests:
* merge_update_existing (graph_buffer.c) applied unconditional "src wins" when
* a worker-local gbuf's per-file Module def (directory QN) collided with the
* main gbuf's Folder node, relabelling it Module and setting its file_path to
* whichever worker merged last — worker scheduling makes the collapsed source
* file vary RUN TO RUN (the race flavor reported in #787).
*/
TEST(repro_issue787_usage_stable_parallel) {
#define N_RUNS_PAR 3
#define MAX_SRCS 80
RFile files[(int)(sizeof(k_files) / sizeof(k_files[0])) + REPRO787_FILLER_COUNT];
char *name_bufs[(int)(sizeof(k_files) / sizeof(k_files[0])) + REPRO787_FILLER_COUNT];
char *body_bufs[(int)(sizeof(k_files) / sizeof(k_files[0])) + REPRO787_FILLER_COUNT];
memset(name_bufs, 0, sizeof(name_bufs));
memset(body_bufs, 0, sizeof(body_bufs));
int total = build_parallel_fixture(files, name_bufs, body_bufs);
int all_ok = 1;
for (int run = 0; run < N_RUNS_PAR && all_ok; run++) {
char *paths[MAX_SRCS];
memset(paths, 0, sizeof(paths));
int count = collect_usage_sources_n(files, total, paths, MAX_SRCS);
if (count < 0) {
all_ok = 0;
printf(" parallel run %d: indexing or lookup failed\n", run + 1);
} else if (!check_sources_exact(paths, count, k_expected, k_nexpected)) {
printf(" ^ parallel run %d\n", run + 1);
all_ok = 0;
}
for (int i = 0; i < MAX_SRCS; i++) {
free(paths[i]);
}
}
for (int i = 0; i < total; i++) {
free(name_bufs[i]);
free(body_bufs[i]);
}
#undef N_RUNS_PAR
#undef MAX_SRCS
if (!all_ok) {
FAIL("parallel-path USAGE sources wrong or unstable");
}
PASS();
}
/* ── Suite ──────────────────────────────────────────────────────────────── */
SUITE(repro_issue787) {
RUN_TEST(repro_issue787_usage_exact_sources);
RUN_TEST(repro_issue787_usage_stable_across_runs);
RUN_TEST(repro_issue787_usage_stable_parallel);
}