fix(incremental): re-resolve documents whose member rows name a changed file (#2587)

An unresolved `path::member` row (missing or ambiguous) leads to its
file by no edge, so an incremental run never re-resolved the document
when that file changed. Two edits then left a row where a full index
has an edge: a body edit that writes a missing member's name (no
definition added), and a same-named pair of methods shrinking to one
(the name stays, so no removed name reaches the rows).

The closure now also takes the files whose missing or ambiguous rows
with "::" name a changed or deleted file, matched by base name since a
reference may be written relative to the document or to the root. When
memory runs out, every such row's file is taken.

Test: doc_links_md_member_missing_incremental requires incremental ==
full on the closure-repair route for both edits and for dropping the
name again. Without the new closure rule the "member written" step
diverges; with it limited to missing rows the "group shrinks" step
does.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-28 00:43:16 +01:00
1 parent 1a7239f52b
commit 69155bcf6c
2 files changed
+125 -2

No files matched your search

+62 -2
View File
@@ -1496,7 +1496,12 @@ static int surface_added_names(const char *stored_json, const char *fresh_json,
* change to it is GLOBAL (it sets the global usings of a whole project);
* - a REMOVED name can make another file's unresolved reference resolve (an
* overload group shrinks) or change its reason: the files whose rows
* mention it re-resolve with the closure. */
* mention it re-resolve with the closure;
* - an unresolved `path::member` row (missing, ambiguous) depends on the
* member's file as a whole, its text included (a member the file writes
* without a definition is no missing member, doc_links_md.c) and the size
* of a same-named group: the files whose such rows name a changed or
* deleted file, by its base name, re-resolve with the closure. */
/* Add s[0..n) to a heap-keyed name set. false when it cannot be recorded (too
* long for a key, or out of memory): callers fail closed. */
@@ -1644,6 +1649,58 @@ static void doc_row_name_dependents(const cbm_doc_link_row_t *rows, int n,
}
}
/* Is an unresolved row's reason one a member lookup gives (doc_links_md.c)? */
static bool doc_row_member_reason(const char *reason) {
return reason && (strcmp(reason, "missing") == 0 || strcmp(reason, "ambiguous") == 0);
}
/* Files whose unresolved `path::member` rows name a member of one of `paths`
* (the changed and deleted files), and no edge leads from such a row to the
* file: a body edit can write or drop a missing member's name without
* changing a definition, and a same-named group can shrink to one without
* removing its name. Matched by the base name before "::", since a
* reference may be written relative to the document or to the root. When
* memory runs out every such row's file is added: re-resolving a file that did
* not need it costs time, keeping a stale row costs correctness. Keys are
* borrowed from `rows`. */
static void doc_row_member_dependents(const cbm_doc_link_row_t *rows, int n, char *const *paths,
int npaths, CBMHashTable *out_paths) {
if (npaths <= 0) {
return;
}
CBMHashTable *bases = cbm_ht_create(CBM_SZ_64); /* NULL: fail closed, every member row */
for (int i = 0; bases && i < npaths; i++) {
const char *slash = strrchr(paths[i], '/');
const char *base = slash ? slash + SKIP_ONE : paths[i];
cbm_ht_set(bases, base, (void *)base); /* borrowed from `paths` */
}
for (int i = 0; i < n; i++) {
const char *raw = rows[i].raw;
const char *rel = rows[i].rel_path;
const char *sep = raw ? strstr(raw, "::") : NULL;
if (!sep || !rel || !rel[0] || !doc_row_member_reason(rows[i].reason) ||
cbm_ht_get(out_paths, rel)) {
continue;
}
const char *base = sep;
while (base > raw && base[-1] != '/') {
base--;
}
char key[CBM_SZ_512];
size_t bl = (size_t)(sep - base);
bool hit = !bases || bl >= sizeof(key);
if (!hit && bl > 0) {
memcpy(key, base, bl);
key[bl] = '\0';
hit = cbm_ht_get(bases, key) != NULL;
}
if (hit) {
cbm_ht_set(out_paths, rel, (void *)rel);
}
}
cbm_ht_free(bases);
}
/* Doc-link carry-forward state of the legacy partial route: the previous
* rows, the stored scopes of the files it does not re-extract, and the set
* of re-extracted or deleted paths whose old rows it replaces. */
@@ -2652,8 +2709,11 @@ static int closure_try_plan(cbm_pipeline_t *p, cbm_store_t *store, const char *p
}
}
/* Files whose unresolved doc-link references name something a changed or
* deleted file no longer declares re-resolve too. */
* deleted file no longer declares re-resolve too, and so do the files
* whose unresolved `path::member` rows name a changed or deleted file. */
doc_row_name_dependents(plan->doc_rows, plan->doc_row_count, removed_names, row_deps);
doc_row_member_dependents(plan->doc_rows, plan->doc_row_count, changed_paths,
n_changed + n_deleted, row_deps);
{
row_dep_walk_t walk = {.closure = closure_set, .files = files_by_path, .added = 0};
cbm_ht_foreach(row_deps, row_dep_visitor, &walk);
+63
View File
@@ -604,6 +604,68 @@ TEST(doc_links_md_incremental) {
PASS();
}
/* #2587 incremental == full: whether a `path::member` row is missing depends
* on the member file's text, and the row leads to that file by no edge. A
* body edit that writes the name (no definition added) turns the row into a
* file edge, one that drops it turns the edge back into the row; a same-named
* group that shrinks to one turns an ambiguous row into an edge. */
TEST(doc_links_md_member_missing_incremental) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_inc_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char repo[512];
snprintf(repo, sizeof(repo), "%s/repo", tmp);
th_write_file(TH_PATH(repo, "docs/helpers.md"), "# Helpers\n\nSee `src/tools.py::helper2`.\n");
th_write_file(TH_PATH(repo, "docs/areas.md"), "# Areas\n\nSee `src/shapes.py::area`.\n");
th_write_file(TH_PATH(repo, "src/tools.py"), "def helper():\n"
" return 1\n");
th_write_file(TH_PATH(repo, "src/shapes.py"), "class Circle:\n"
" def area(self):\n"
" return 3\n"
"\n"
"\n"
"class Square:\n"
" def area(self):\n"
" return 4\n");
char db[512];
char full_db[512];
snprintf(db, sizeof(db), "%s/inc.db", tmp);
snprintf(full_db, sizeof(full_db), "%s/full.db", tmp);
ASSERT_EQ(dm_index(repo, db, NULL), 0);
char reason[64];
dm_row(db, "docs/helpers.md", "src/tools.py::helper2", reason, sizeof(reason), NULL, 0);
ASSERT_STR_EQ(reason, "missing");
dm_row(db, "docs/areas.md", "src/shapes.py::area", reason, sizeof(reason), NULL, 0);
ASSERT_STR_EQ(reason, "ambiguous");
/* the body writes the name, no definition added: the row becomes an edge */
th_write_file(TH_PATH(repo, "src/tools.py"), "def helper():\n"
" return 1 # helper2 follows\n");
ASSERT_EQ(dm_step(repo, db, full_db, "member written", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR),
0);
ASSERT_EQ(md_file_edges(db, "Helpers", "src/tools.py"), 1);
/* and drops it again: missing again */
th_write_file(TH_PATH(repo, "src/tools.py"), "def helper():\n"
" return 1\n");
ASSERT_EQ(dm_step(repo, db, full_db, "member dropped", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR),
0);
dm_row(db, "docs/helpers.md", "src/tools.py::helper2", reason, sizeof(reason), NULL, 0);
ASSERT_STR_EQ(reason, "missing");
/* one of two `area` methods goes, the name stays: the one left binds */
th_write_file(TH_PATH(repo, "src/shapes.py"), "class Circle:\n"
" pass\n"
"\n"
"\n"
"class Square:\n"
" def area(self):\n"
" return 4\n");
ASSERT_EQ(dm_step(repo, db, full_db, "group shrinks", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0);
char props[512];
ASSERT_EQ(md_edge(db, "Areas", "Square.area", props, sizeof(props)), 1);
th_cleanup(tmp);
PASS();
}
/* ── ADRs ────────────────────────────────────────────────────────── */
static void md_write_adrs(const char *tmp, const char *first_status) {
@@ -1028,6 +1090,7 @@ SUITE(doc_links_md) {
RUN_TEST(doc_links_md_member_not_defined_issue2587);
RUN_TEST(doc_links_md_member_written_issue2587);
RUN_TEST(doc_links_md_incremental);
RUN_TEST(doc_links_md_member_missing_incremental);
RUN_TEST(doc_links_md_adr);
RUN_TEST(doc_links_md_adr_label_choice);
RUN_TEST(doc_links_md_adr_reported);