Merge pull request #2602 from bline/fix/doc-link-moved-directory

fix(doc-links): report references into a moved directory as missing (#2588)
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-10-26 02:18:59 +01:00
commit a49481df70
2 files changed
+94 -7

No files matched your search

+44 -7
View File
@@ -16,16 +16,18 @@
* document wrote the path.
*
* Unresolved:
* missing the path is not in the graph, but its directory is: the
* document names something that is not there (doc rot)
* missing the path is not in the graph, but its directory is, or a
* directory above it that the reference wrote is: the document
* names something that is not there (doc rot; a moved or
* renamed directory)
* ambiguous a file name alone that is not next to the document but exists
* elsewhere in the repository: the document does not say which
* Not a reference to this repository at all (LOCAL: no edge, no row): a link
* back into the same document; a path that is neither indexed nor of a type
* the indexer reads (an image, an archive, an extensionless page); a path
* whose directory is not in the repository either (a file a tutorial asks
* the reader to create, a system path, a path above the root); and, in a
* code span, a rooted single segment without a file extension, which is a
* none of whose directories is in the repository either (a file a tutorial
* asks the reader to create, a system path, a path above the root); and, in
* a code span, a rooted single segment without a file extension, which is a
* URL route (`/docs`, `/health`), not the directory of that name.
*/
#include "pipeline/doc_links.h"
@@ -408,6 +410,25 @@ static const cbm_gbuf_node_t *mdr_folder(const mdr_index_t *x, const cbm_gbuf_t
return path[0] ? (const cbm_gbuf_node_t *)cbm_ht_get(x->folders, path) : NULL;
}
/* Whether a directory above `path` that the reference itself wrote, one
* longer than the `base_len` bytes it was read from, is a Folder. */
static bool mdr_written_ancestor(const mdr_index_t *x, const cbm_gbuf_t *graph, const char *path,
size_t base_len) {
char dir[MDR_PATH_CAP];
size_t n = strlen(path);
if (n >= sizeof(dir)) {
return false;
}
memcpy(dir, path, n + SKIP_ONE);
for (char *s = strrchr(dir, '/'); s && (size_t)(s - dir) > base_len; s = strrchr(dir, '/')) {
*s = '\0';
if (mdr_folder(x, graph, dir)) {
return true;
}
}
return false;
}
static bool mdr_code_label(const char *label) {
for (size_t i = 0; label && MDR_CODE_LABELS[i]; i++) {
if (strcmp(label, MDR_CODE_LABELS[i]) == 0) {
@@ -1126,8 +1147,19 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons
char tries[MDR_TRIES][MDR_PATH_CAP];
int nt = 0;
int attempted = 0;
size_t base_len[MDR_TRIES]; /* the directory each try reads from, before what p wrote */
if (!rooted) {
attempted++;
char lead[MDR_PATH_CAP]; /* p's leading `./` and `../` segments */
char base[MDR_PATH_CAP];
size_t ll = 0;
while (ll < sizeof(lead) - PAIR_LEN - 1 && (strncmp(p + ll, "./", PAIR_LEN) == 0 ||
strncmp(p + ll, "../", PAIR_LEN + 1) == 0)) {
ll += p[ll + 1] == '/' ? PAIR_LEN : PAIR_LEN + 1;
}
memcpy(lead, p, ll);
lead[ll] = '\0';
base_len[nt] = mdr_join(doc_dir, lead, base, sizeof(base)) ? strlen(base) : 0;
nt += mdr_join(doc_dir, p, tries[nt], MDR_PATH_CAP);
}
if (!relative && !r->name_only) {
@@ -1137,7 +1169,7 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons
q++;
}
if (mdr_join("", q, tries[nt], MDR_PATH_CAP) && (nt == 0 || strcmp(tries[0], tries[nt]))) {
nt++;
base_len[nt++] = 0;
}
}
for (int i = 0; i < nt; i++) {
@@ -1174,7 +1206,8 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons
return;
}
/* doc rot when the reference points into the repository: its directory is
* there (a bare name in a link: the document's own directory) */
* there (a bare name in a link: the document's own directory), or a
* directory above it that the reference wrote is (one moved or renamed) */
for (int i = 0; i < nt; i++) {
char parent[MDR_PATH_CAP];
const char *ps = strrchr(tries[i], '/');
@@ -1185,6 +1218,10 @@ static void mdr_resolve_path(const mdr_index_t *x, const cbm_gbuf_t *graph, cons
out->reason = CBM_DOCLINK_REASON_MISSING;
return;
}
if (pl > 0 && strchr(p, '/') && mdr_written_ancestor(x, graph, tries[i], base_len[i])) {
out->reason = CBM_DOCLINK_REASON_MISSING;
return;
}
if (pl == 0 && r->link && !rooted) {
out->reason = CBM_DOCLINK_REASON_MISSING;
return;
+50
View File
@@ -444,6 +444,55 @@ TEST(doc_links_md_incremental) {
PASS();
}
/* #2588: a reference into a directory that was moved or renamed named a
* path whose directory is gone, so it counted as no reference to this
* repository: no edge and no row. A directory above it that the reference
* wrote is still there, so it is doc rot. A path none of whose directories
* the repository has stays no reference. */
TEST(doc_links_md_moved_directory_issue2588) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_mv_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char repo[512];
snprintf(repo, sizeof(repo), "%s/repo", tmp);
th_write_file(TH_PATH(repo, "docs/notes.md"), "# Notes\n"
"\n"
"See `src/util/rounding.py::clamp` and "
"[rounding](../src/util/rounding.py).\n"
"Create `scratch/new/thing.py` first.\n");
th_write_file(TH_PATH(repo, "src/ledger.py"), "def balance(ledger):\n"
" return sum(ledger)\n");
th_write_file(TH_PATH(repo, "src/util/rounding.py"), "def clamp(x, lo, hi):\n"
" return max(lo, min(hi, x))\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 props[512];
char reason[64];
ASSERT_EQ(md_edge(db, "Notes", "rounding.clamp", props, sizeof(props)), 1);
/* src/util moves to src/helpers: both references are missing, on the
* incremental route as in a full index */
th_write_file(TH_PATH(repo, "src/helpers/rounding.py"), "def clamp(x, lo, hi):\n"
" return max(lo, min(hi, x))\n");
ASSERT_EQ(th_rmtree(TH_PATH(repo, "src/util")), 0);
ASSERT_EQ(dm_step(repo, db, full_db, "directory moved", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0);
ASSERT_EQ(md_edge(db, "Notes", "rounding.clamp", props, sizeof(props)), 0);
dm_row(db, "docs/notes.md", "src/util/rounding.py::clamp", reason, sizeof(reason), NULL, 0);
ASSERT_STR_EQ(reason, "missing");
dm_row(db, "docs/notes.md", "../src/util/rounding.py", reason, sizeof(reason), NULL, 0);
ASSERT_STR_EQ(reason, "missing");
/* no directory of it in the repository: still no reference */
dm_row(db, "docs/notes.md", "scratch/new/thing.py", reason, sizeof(reason), NULL, 0);
ASSERT_STR_EQ(reason, "");
th_cleanup(tmp);
PASS();
}
/* ── ADRs ────────────────────────────────────────────────────────── */
static void md_write_adrs(const char *tmp, const char *first_status) {
@@ -866,6 +915,7 @@ SUITE(doc_links_md) {
RUN_TEST(doc_links_md_classify_span);
RUN_TEST(doc_links_md_resolve);
RUN_TEST(doc_links_md_incremental);
RUN_TEST(doc_links_md_moved_directory_issue2588);
RUN_TEST(doc_links_md_adr);
RUN_TEST(doc_links_md_adr_label_choice);
RUN_TEST(doc_links_md_adr_reported);