diff --git a/src/pipeline/doc_links_md.c b/src/pipeline/doc_links_md.c index 9e76d41a4..b23a53c9b 100644 --- a/src/pipeline/doc_links_md.c +++ b/src/pipeline/doc_links_md.c @@ -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; diff --git a/tests/test_doc_links_md.c b/tests/test_doc_links_md.c index 21e74f37e..54d1d3a0d 100644 --- a/tests/test_doc_links_md.c +++ b/tests/test_doc_links_md.c @@ -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);