diff --git a/src/pipeline/pass_doclinks.c b/src/pipeline/pass_doclinks.c index d27a22ce0..4b75357e5 100644 --- a/src/pipeline/pass_doclinks.c +++ b/src/pipeline/pass_doclinks.c @@ -30,6 +30,8 @@ #include "foundation/log.h" #include "foundation/compat_fs.h" #include "foundation/limits.h" +#include "foundation/mem_core.h" +#include "foundation/dyn_array.h" #include #include @@ -50,8 +52,7 @@ #define DOCLINK_EDGE_TYPE "REFERENCES_FILE" enum { - DOCLINK_MAX_REFS = CBM_SZ_256, /* distinct targets per referencing file */ - DOCLINK_MAX_SEGS = CBM_SZ_64, /* path segments during normalization */ + DOCLINK_MAX_SEGS = CBM_SZ_64, /* path segments during normalization */ }; /* ── Path classification ─────────────────────────────────────────── */ @@ -71,23 +72,51 @@ static bool doclink_is_markdown_path(const char *path) { return ext && (strcmp(ext, ".md") == 0 || strcmp(ext, ".mdx") == 0); } -/* ── File reading (mirrors pass_semantic.c read_file, minus TS pad) ── */ - -static char *doclink_read_file(const char *path) { +/* ── File reading (mirrors pass_definitions.c read_file, minus TS pad) ── + * + * Reports *out_status so the caller can attribute a skip to the right + * reason (open failure vs. oversized vs. OOM) instead of a silent drop — + * hitting a limit here degrades to a REPORTED skip, per limits.h. */ +static char *doclink_read_file(const char *path, long *out_size, cbm_read_status_t *out_status) { + if (out_size) { + *out_size = 0; + } + if (out_status) { + *out_status = CBM_READ_OK; + } FILE *f = cbm_fopen(path, "rb"); if (!f) { + if (out_status) { + *out_status = CBM_READ_OPEN_FAIL; + } return NULL; } (void)fseek(f, 0, SEEK_END); long size = ftell(f); (void)fseek(f, 0, SEEK_SET); - if (size <= 0 || size > cbm_max_file_bytes()) { + if (out_size) { + *out_size = size; + } + if (size <= 0) { (void)fclose(f); + if (out_status) { + *out_status = CBM_READ_EMPTY; + } return NULL; } - char *buf = malloc((size_t)size + SKIP_ONE); + if (size > cbm_max_file_bytes()) { + (void)fclose(f); + if (out_status) { + *out_status = CBM_READ_OVERSIZED; + } + return NULL; + } + char *buf = cbm_alloc(CBM_MEM_CLASS_EXTRACT, (size_t)size + SKIP_ONE); if (!buf) { (void)fclose(f); + if (out_status) { + *out_status = CBM_READ_OOM; + } return NULL; } size_t nread = fread(buf, SKIP_ONE, (size_t)size, f); @@ -140,33 +169,45 @@ static bool doclink_normalize(const char *in, char *out, size_t out_sz) { return out_len > 0; } -/* Per-file accumulator: dedupes repeated references to the same target. */ +/* One raw match before dedup. Collected in a growable vector (no per-file + * cap) and collapsed by doclink_flush(). */ typedef struct { int64_t target_id; - int count; double confidence; const char *strategy; /* static string literal */ -} doclink_ref_t; +} doclink_match_t; typedef struct { cbm_gbuf_t *gb; CBMHashTable *files_by_path; /* rel_path → cbm_gbuf_node_t* (borrowed) */ const cbm_gbuf_node_t *src; /* referencing File node */ char src_dir[CBM_SZ_512]; /* its directory ("" at repo root) */ - doclink_ref_t refs[DOCLINK_MAX_REFS]; - int ref_count; - bool truncated; + CBM_DYN_ARRAY(doclink_match_t) matches; } doclink_ctx_t; -/* Resolve a reference against the referencing file's directory, then the - * repo root, mirroring how humans write doc links. Returns the already- - * indexed File node or NULL — unresolvable references are dropped. */ +/* How a reference was written, which decides where it may resolve. Mixing + * these up let a rooted "/x" still match a same-named file in the + * referencing directory, and let an explicit "./x" fall back to an + * unrelated same-named file at the repo root. */ +typedef enum { + DOCLINK_REF_BARE = 0, /* "x": referencing dir, then repo root */ + DOCLINK_REF_RELATIVE, /* "./x": referencing dir only */ + DOCLINK_REF_ROOTED, /* "/x": repo root only */ +} doclink_ref_kind_t; + +/* Resolve a reference against the referencing file's directory and/or the + * repo root, per its kind. Returns the already-indexed File node or NULL — + * unresolvable references are dropped. */ static const cbm_gbuf_node_t *doclink_resolve(doclink_ctx_t *dc, const char *ref) { const char *r = ref; - while (r[0] == '.' && r[SKIP_ONE] == '/') { - r += PAIR_LEN; - } - if (r[0] == '/') { + doclink_ref_kind_t kind = DOCLINK_REF_BARE; + if (r[0] == '.' && r[SKIP_ONE] == '/') { + kind = DOCLINK_REF_RELATIVE; + while (r[0] == '.' && r[SKIP_ONE] == '/') { + r += PAIR_LEN; + } + } else if (r[0] == '/') { + kind = DOCLINK_REF_ROOTED; r++; /* "/docs/x.md" is repo-root-relative by doc convention */ } if (r[0] == '\0') { @@ -174,7 +215,7 @@ static const cbm_gbuf_node_t *doclink_resolve(doclink_ctx_t *dc, const char *ref } char norm[CBM_SZ_512]; - if (dc->src_dir[0] != '\0') { + if (kind != DOCLINK_REF_ROOTED && dc->src_dir[0] != '\0') { char joined[CBM_SZ_512]; int n = snprintf(joined, sizeof(joined), "%s/%s", dc->src_dir, r); if (n > 0 && (size_t)n < sizeof(joined) && doclink_normalize(joined, norm, sizeof(norm))) { @@ -184,61 +225,67 @@ static const cbm_gbuf_node_t *doclink_resolve(doclink_ctx_t *dc, const char *ref } } } - if (doclink_normalize(r, norm, sizeof(norm))) { + if (kind != DOCLINK_REF_RELATIVE && doclink_normalize(r, norm, sizeof(norm))) { return cbm_ht_get(dc->files_by_path, norm); } return NULL; } -/* Record one match. Same-pair repeats bump the count; a higher-confidence - * strategy upgrades the edge's confidence + strategy label. */ +/* Record one match. No cap, no dedup here — dc->matches is a plain append + * log; doclink_flush() sorts and collapses same-target matches so a + * generated file with hundreds of distinct targets loses nothing. */ static void doclink_record(doclink_ctx_t *dc, const cbm_gbuf_node_t *target, double confidence, const char *strategy) { if (!target || target->id == dc->src->id) { return; /* never self-reference */ } - for (int i = 0; i < dc->ref_count; i++) { - if (dc->refs[i].target_id == target->id) { - dc->refs[i].count++; - if (confidence > dc->refs[i].confidence) { - dc->refs[i].confidence = confidence; - dc->refs[i].strategy = strategy; - } - return; - } - } - if (dc->ref_count >= DOCLINK_MAX_REFS) { - dc->truncated = true; - return; - } - dc->refs[dc->ref_count].target_id = target->id; - dc->refs[dc->ref_count].count = SKIP_ONE; - dc->refs[dc->ref_count].confidence = confidence; - dc->refs[dc->ref_count].strategy = strategy; - dc->ref_count++; + doclink_match_t m = {.target_id = target->id, .confidence = confidence, .strategy = strategy}; + cbm_da_push(&dc->matches, m); } -/* Emit accumulated references as REFERENCES_FILE edges. Returns edge count. */ +static int doclink_match_cmp(const void *a, const void *b) { + int64_t ta = ((const doclink_match_t *)a)->target_id; + int64_t tb = ((const doclink_match_t *)b)->target_id; + return (ta > tb) - (ta < tb); +} + +/* Emit accumulated references as REFERENCES_FILE edges. Sorts by target id + * (O(M log M)) and collapses same-target runs: count = occurrences, the + * highest-confidence match in the run wins strategy + confidence. Returns + * edge count. */ static int doclink_flush(doclink_ctx_t *dc) { + if (dc->matches.count > 1) { + qsort(dc->matches.items, (size_t)dc->matches.count, sizeof(dc->matches.items[0]), + doclink_match_cmp); + } + int emitted = 0; - for (int i = 0; i < dc->ref_count; i++) { + int i = 0; + while (i < dc->matches.count) { + int64_t target_id = dc->matches.items[i].target_id; + double confidence = dc->matches.items[i].confidence; + const char *strategy = dc->matches.items[i].strategy; + int count = SKIP_ONE; + int j = i + SKIP_ONE; + while (j < dc->matches.count && dc->matches.items[j].target_id == target_id) { + count++; + if (dc->matches.items[j].confidence > confidence) { + confidence = dc->matches.items[j].confidence; + strategy = dc->matches.items[j].strategy; + } + j++; + } + char props[CBM_SZ_256]; (void)snprintf(props, sizeof(props), - "{\"strategy\":\"%s\",\"confidence\":%.2f,\"count\":%d}", - dc->refs[i].strategy, dc->refs[i].confidence, dc->refs[i].count); - if (cbm_gbuf_insert_edge(dc->gb, dc->src->id, dc->refs[i].target_id, DOCLINK_EDGE_TYPE, - props) > 0) { + "{\"strategy\":\"%s\",\"confidence\":%.2f,\"count\":%d}", strategy, + confidence, count); + if (cbm_gbuf_insert_edge(dc->gb, dc->src->id, target_id, DOCLINK_EDGE_TYPE, props) > 0) { emitted++; } + i = j; } - if (dc->truncated) { - char cap_buf[CBM_SZ_16]; - (void)snprintf(cap_buf, sizeof(cap_buf), "%d", DOCLINK_MAX_REFS); - cbm_log_info("doclinks.truncated", "file", dc->src->file_path ? dc->src->file_path : "", - "cap", cap_buf); - } - dc->ref_count = 0; - dc->truncated = false; + cbm_da_clear(&dc->matches); return emitted; } @@ -380,11 +427,15 @@ static void doclink_scan_md_line(doclink_ctx_t *dc, char *line) { /* ── Per-file driver ─────────────────────────────────────────────── */ -/* Scan one referencing file's content line by line and emit its edges. */ -static int doclink_scan_file(doclink_ctx_t *dc, const cbm_gbuf_node_t *node, const char *source) { +/* Scan one referencing file's content line by line and emit its edges. + * `source` is the caller's private mutable buffer (freshly read for this + * file, never reused): lines are terminated in place ('\n' -> '\0') and + * scanned by pointer, so there is no fixed-size copy and no line-length + * cap to straddle a token across (a prior CBM_SZ_4K copy buffer could cut + * "src/foo.cpp" to "src/foo.c" at the boundary and bind to the wrong + * file). A trailing '\r' (CRLF) is trimmed the same way. */ +static int doclink_scan_file(doclink_ctx_t *dc, const cbm_gbuf_node_t *node, char *source) { dc->src = node; - dc->ref_count = 0; - dc->truncated = false; dc->src_dir[0] = '\0'; const char *slash = strrchr(node->file_path, '/'); if (slash) { @@ -396,18 +447,20 @@ static int doclink_scan_file(doclink_ctx_t *dc, const cbm_gbuf_node_t *node, con dc->src_dir[dlen] = '\0'; } - const char *p = source; - char line[CBM_SZ_4K]; + char *p = source; while (*p) { - const char *eol = strchr(p, '\n'); - size_t line_len = eol ? (size_t)(eol - p) : strlen(p); - if (line_len >= sizeof(line)) { - line_len = sizeof(line) - SKIP_ONE; + char *line = p; + char *eol = strchr(p, '\n'); + if (eol) { + *eol = '\0'; + p = eol + SKIP_ONE; + } else { + p += strlen(p); + } + size_t line_len = strlen(line); + if (line_len > 0 && line[line_len - SKIP_ONE] == '\r') { + line[line_len - SKIP_ONE] = '\0'; } - memcpy(line, p, line_len); - line[line_len] = '\0'; - p = eol ? eol + SKIP_ONE : p + line_len; - doclink_scan_md_line(dc, line); } return doclink_flush(dc); @@ -426,10 +479,16 @@ static bool doclink_has_doc_files(const cbm_gbuf_node_t *const *files, int file_ } /* Scan every markdown File node's on-disk content, emitting edges. - * md_edges receives the emitted edge count. */ -static void doclink_scan_repo(doclink_ctx_t *dc, const char *repo_path, + * md_edges receives the emitted edge count. Checks cancellation once per + * file — on a docs-heavy repo this is the whole pass's cancel latency — + * and every skip is reported via cbm_pipeline_add_file_error, never + * silent. */ +static void doclink_scan_repo(cbm_pipeline_ctx_t *ctx, doclink_ctx_t *dc, const char *repo_path, const cbm_gbuf_node_t *const *files, int file_count, int *md_edges) { for (int i = 0; i < file_count; i++) { + if (cbm_pipeline_check_cancel(ctx)) { + return; + } if (!files[i]->file_path || !doclink_is_markdown_path(files[i]->file_path)) { continue; } @@ -439,12 +498,29 @@ static void doclink_scan_repo(doclink_ctx_t *dc, const char *repo_path, if (n <= 0 || (size_t)n >= sizeof(abs_path)) { continue; } - char *source = doclink_read_file(abs_path); + long file_size = 0; + cbm_read_status_t rst = CBM_READ_OK; + char *source = doclink_read_file(abs_path, &file_size, &rst); if (!source) { + if (rst == CBM_READ_OVERSIZED) { + long cap = cbm_max_file_bytes(); + char reason[96]; + (void)snprintf(reason, sizeof(reason), "oversized (%lld MB > %lld MB)", + (long long)(file_size / (CBM_SZ_1K * CBM_SZ_1K)), + (long long)(cap / (CBM_SZ_1K * CBM_SZ_1K))); + cbm_pipeline_add_file_error(ctx->pipeline, files[i]->file_path, reason, + "oversized"); + cbm_log_warn("doclinks.file_oversized", "path", files[i]->file_path); + } else if (rst == CBM_READ_OPEN_FAIL || rst == CBM_READ_OOM) { + cbm_pipeline_add_file_error(ctx->pipeline, files[i]->file_path, "read failed", + "read"); + cbm_log_warn("doclinks.file_unreadable", "path", files[i]->file_path); + } + /* CBM_READ_EMPTY: benign 0-byte file, nothing to index, not reported. */ continue; } int emitted = doclink_scan_file(dc, files[i], source); - free(source); + cbm_free(CBM_MEM_CLASS_EXTRACT, source); *md_edges += emitted; } } @@ -458,7 +534,7 @@ int cbm_pipeline_pass_doclinks(cbm_pipeline_ctx_t *ctx) { return 0; } - /* Early exit: no markdown/shell files means nothing to scan. */ + /* Early exit: no markdown files means nothing to scan. */ if (!doclink_has_doc_files(files, file_count)) { cbm_log_info("doclinks.skip", "reason", "no_doc_files"); return 0; @@ -483,7 +559,8 @@ int cbm_pipeline_pass_doclinks(cbm_pipeline_ctx_t *ctx) { } int md_edges = 0; - doclink_scan_repo(&dc, ctx->repo_path, files, file_count, &md_edges); + doclink_scan_repo(ctx, &dc, ctx->repo_path, files, file_count, &md_edges); + cbm_da_free(&dc.matches); cbm_ht_free(dc.files_by_path); char buf1[CBM_SZ_16]; diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 275094731..e7782adef 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -665,7 +665,7 @@ int cbm_pipeline_pass_decorator_tags(cbm_gbuf_t *gbuf, const char *project); /* Pre-dump pass: config ↔ code linking. */ int cbm_pipeline_pass_configlink(cbm_pipeline_ctx_t *ctx); -/* Pre-dump pass: markdown/shell → file REFERENCES_FILE linking. */ +/* Pre-dump pass: markdown → file REFERENCES_FILE linking. */ int cbm_pipeline_pass_doclinks(cbm_pipeline_ctx_t *ctx); /* Pre-dump pass: SIMILAR_TO edges via MinHash fingerprinting. */ diff --git a/tests/test_doclinks.c b/tests/test_doclinks.c index a03542d9b..af24c2447 100644 --- a/tests/test_doclinks.c +++ b/tests/test_doclinks.c @@ -1,5 +1,5 @@ /* - * test_doclinks.c — Tests for markdown/shell → file reference linking. + * test_doclinks.c — Tests for markdown → file reference linking. * * Unit-test approach mirrors test_configlink.c: create real files in a * tmpdir (the pass reads content from disk), set up File nodes in a gbuf, @@ -294,6 +294,237 @@ TEST(doclinks_null_repo_path_skips) { PASS(); } +/* ── Rooted "/x" resolves against the repo root ONLY ─────────────── */ + +TEST(doclinks_rooted_reference_skips_directory_join) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + /* docs/guide.md links "/README.md" (repo-root-relative by doc + * convention). Both docs/README.md and the root README.md exist: a + * rooted reference must resolve to the root file, never to a + * same-named file in the referencing directory. */ + th_write_file(TH_PATH(fx.tmpdir, "docs/guide.md"), "See [home](/README.md).\n"); + th_write_file(TH_PATH(fx.tmpdir, "docs/README.md"), "# Wrong target\n"); + th_write_file(TH_PATH(fx.tmpdir, "README.md"), "# Root\n"); + + int64_t doc_id = add_file_node(fx.gb, fx.project, "docs/guide.md"); + int64_t docs_readme_id = add_file_node(fx.gb, fx.project, "docs/README.md"); + int64_t root_readme_id = add_file_node(fx.gb, fx.project, "README.md"); + + run_doclinks(fx.gb, fx.project, fx.tmpdir); + + ASSERT_NOT_NULL(find_ref_edge(fx.gb, doc_id, root_readme_id)); + ASSERT_NULL(find_ref_edge(fx.gb, doc_id, docs_readme_id)); + + dl_fix_free(&fx); + PASS(); +} + +/* ── Explicit "./x" resolves against the referencing dir ONLY ────── */ + +TEST(doclinks_explicit_relative_no_root_fallback) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + /* docs/a.md links "./config.md". docs/config.md does not exist, but a + * root config.md does — an explicit "./" reference must not fall back + * to an unrelated same-named file at the repo root. */ + th_write_file(TH_PATH(fx.tmpdir, "docs/a.md"), "See [cfg](./config.md).\n"); + th_write_file(TH_PATH(fx.tmpdir, "config.md"), "# Root config\n"); + + int64_t doc_id = add_file_node(fx.gb, fx.project, "docs/a.md"); + add_file_node(fx.gb, fx.project, "config.md"); + + int n = run_doclinks(fx.gb, fx.project, fx.tmpdir); + ASSERT_EQ(n, 0); + ASSERT_EQ(ref_edge_count(fx.gb), 0); + (void)doc_id; + + dl_fix_free(&fx); + PASS(); +} + +/* ── A line past the old 4 KiB copy cap is scanned in full ───────── */ + +TEST(doclinks_long_line_no_truncation) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + /* Padding + "src/foo.cpp" (no trailing newline) straddles the old + * CBM_SZ_4K line-copy cap so that, under the old bug, the copy cut + * exactly after "src/foo.c" and dropped "pp": a bare mention of + * src/foo.cpp silently became a reference to the wrong file. Both + * files exist so a wrong match is observable. */ + enum { FILLER_WORDS = 681 }; /* 681 * strlen("lorem ") == 4086 */ + char content[CBM_SZ_4K * 2]; + size_t pos = 0; + for (int i = 0; i < FILLER_WORDS; i++) { + memcpy(content + pos, "lorem ", 6); + pos += 6; + } + ASSERT_EQ((int)pos, 4086); + memcpy(content + pos, "src/foo.cpp", 11); + pos += 11; + content[pos] = '\0'; + + th_write_file(TH_PATH(fx.tmpdir, "README.md"), content); + th_write_file(TH_PATH(fx.tmpdir, "src/foo.c"), "// wrong target\n"); + th_write_file(TH_PATH(fx.tmpdir, "src/foo.cpp"), "// right target\n"); + + int64_t doc_id = add_file_node(fx.gb, fx.project, "README.md"); + int64_t short_id = add_file_node(fx.gb, fx.project, "src/foo.c"); + int64_t long_id = add_file_node(fx.gb, fx.project, "src/foo.cpp"); + + run_doclinks(fx.gb, fx.project, fx.tmpdir); + + ASSERT_NOT_NULL(find_ref_edge(fx.gb, doc_id, long_id)); + ASSERT_NULL(find_ref_edge(fx.gb, doc_id, short_id)); + + dl_fix_free(&fx); + PASS(); +} + +/* ── More than the old 256-target cap: nothing is dropped ────────── */ + +TEST(doclinks_many_distinct_targets_no_cap) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + enum { TARGETS = 300 }; /* > the old DOCLINK_MAX_REFS (256) */ + char content[TARGETS * 24]; + size_t pos = 0; + int64_t doc_id = add_file_node(fx.gb, fx.project, "MANY.md"); + for (int i = 0; i < TARGETS; i++) { + char rel[32]; + char line[40]; + snprintf(rel, sizeof(rel), "gen/f%03d.txt", i); + th_write_file(TH_PATH(fx.tmpdir, rel), "x\n"); + add_file_node(fx.gb, fx.project, rel); + int n = snprintf(line, sizeof(line), "%s\n", rel); + memcpy(content + pos, line, (size_t)n); + pos += (size_t)n; + } + content[pos] = '\0'; + th_write_file(TH_PATH(fx.tmpdir, "MANY.md"), content); + + int n = run_doclinks(fx.gb, fx.project, fx.tmpdir); + ASSERT_EQ(n, TARGETS); + ASSERT_EQ(ref_edge_count(fx.gb), TARGETS); + (void)doc_id; + + dl_fix_free(&fx); + PASS(); +} + +/* ── ".." past the repo root is dropped, not resolved ────────────── */ + +TEST(doclinks_dotdot_escaping_root_dropped) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + /* README.md sits at the repo root (depth 0): "../outside.md" has + * nowhere to go up from and must be dropped, not resolved. */ + th_write_file(TH_PATH(fx.tmpdir, "README.md"), "See [x](../outside.md).\n"); + + add_file_node(fx.gb, fx.project, "README.md"); + + int n = run_doclinks(fx.gb, fx.project, fx.tmpdir); + ASSERT_EQ(n, 0); + ASSERT_EQ(ref_edge_count(fx.gb), 0); + + dl_fix_free(&fx); + PASS(); +} + +/* ── A bare URL in prose is not mistaken for a repo file path ────── */ + +TEST(doclinks_bare_url_in_prose_no_edge) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "README.md"), + "Fetch it from http://host/path/file.c directly.\n"); + th_write_file(TH_PATH(fx.tmpdir, "path/file.c"), "int x;\n"); + + add_file_node(fx.gb, fx.project, "README.md"); + add_file_node(fx.gb, fx.project, "path/file.c"); + + int n = run_doclinks(fx.gb, fx.project, fx.tmpdir); + ASSERT_EQ(n, 0); + ASSERT_EQ(ref_edge_count(fx.gb), 0); + + dl_fix_free(&fx); + PASS(); +} + +/* ── Exact-path matching: a bare basename never fuzzy-binds ──────── */ + +TEST(doclinks_backtick_basename_does_not_fuzzy_match) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + /* `main.c` in docs/ must resolve against docs/main.c or the repo-root + * main.c ONLY (exact path match) — never against src/main.c just + * because the basename matches. */ + th_write_file(TH_PATH(fx.tmpdir, "docs/a.md"), "See `main.c` for the entry point.\n"); + th_write_file(TH_PATH(fx.tmpdir, "src/main.c"), "int main(void) { return 0; }\n"); + + add_file_node(fx.gb, fx.project, "docs/a.md"); + add_file_node(fx.gb, fx.project, "src/main.c"); + + int n = run_doclinks(fx.gb, fx.project, fx.tmpdir); + ASSERT_EQ(n, 0); + ASSERT_EQ(ref_edge_count(fx.gb), 0); + + dl_fix_free(&fx); + PASS(); +} + +/* ── CRLF line endings do not corrupt a matched target ───────────── */ + +TEST(doclinks_crlf_line_endings) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "README.md"), + "# Title\r\nSee [build](scripts/build.sh) for details.\r\n"); + th_write_file(TH_PATH(fx.tmpdir, "scripts/build.sh"), "#!/bin/sh\n"); + + int64_t doc_id = add_file_node(fx.gb, fx.project, "README.md"); + int64_t script_id = add_file_node(fx.gb, fx.project, "scripts/build.sh"); + + run_doclinks(fx.gb, fx.project, fx.tmpdir); + ASSERT_NOT_NULL(find_ref_edge(fx.gb, doc_id, script_id)); + + dl_fix_free(&fx); + PASS(); +} + +/* ── Fenced code blocks are scanned like prose (pinned, not fixed) ── */ + +TEST(doclinks_fenced_code_block_bare_mention_pinned) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "README.md"), + "Build it:\n\n```\ngcc src/main.c -o out\n```\n"); + th_write_file(TH_PATH(fx.tmpdir, "src/main.c"), "int main(void) { return 0; }\n"); + + int64_t doc_id = add_file_node(fx.gb, fx.project, "README.md"); + int64_t code_id = add_file_node(fx.gb, fx.project, "src/main.c"); + + run_doclinks(fx.gb, fx.project, fx.tmpdir); + + const cbm_gbuf_edge_t *e = find_ref_edge(fx.gb, doc_id, code_id); + ASSERT_NOT_NULL(e); + ASSERT_TRUE(edge_has_strategy(e, "md_bare_mention")); + ASSERT_NOT_NULL(strstr(e->properties_json, "\"confidence\":0.70")); + + dl_fix_free(&fx); + PASS(); +} + /* ── Suite ───────────────────────────────────────────────────────── */ SUITE(doclinks) { @@ -309,4 +540,17 @@ SUITE(doclinks) { RUN_TEST(doclinks_resolves_relative_to_file_dir); RUN_TEST(doclinks_unresolvable_target_no_edge); RUN_TEST(doclinks_null_repo_path_skips); + + /* Reference-kind precedence (rooted / explicit-relative / bare) */ + RUN_TEST(doclinks_rooted_reference_skips_directory_join); + RUN_TEST(doclinks_explicit_relative_no_root_fallback); + + /* Negative controls / regression guards */ + RUN_TEST(doclinks_long_line_no_truncation); + RUN_TEST(doclinks_many_distinct_targets_no_cap); + RUN_TEST(doclinks_dotdot_escaping_root_dropped); + RUN_TEST(doclinks_bare_url_in_prose_no_edge); + RUN_TEST(doclinks_backtick_basename_does_not_fuzzy_match); + RUN_TEST(doclinks_crlf_line_endings); + RUN_TEST(doclinks_fenced_code_block_bare_mention_pinned); } diff --git a/tests/test_main.c b/tests/test_main.c index 1278c1c70..db3748288 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -1182,7 +1182,7 @@ int main(int argc, char **argv) { /* Config link */ RUN_SELECTED_SUITE(configlink); - /* Doc/shell file reference link */ + /* Markdown file reference link */ RUN_SELECTED_SUITE(doclinks); /* Infrastructure scanning */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d32ab3538..6ef16a89b 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -314,6 +314,36 @@ TEST(pipeline_grpc_routes_cover_every_service_past_the_old_cap) { PASS(); } +/* Pipeline-level: the doclinks pass runs as part of a real index and its + * REFERENCES_FILE edges land in the store, not just in a unit test that + * calls cbm_pipeline_pass_doclinks() directly. Registration-list gap: every + * doclinks unit test calls the pass function by hand, so deleting its + * {predump_doclinks, "doclinks", false} entry in pipeline.c would leave + * every one of them green. */ +TEST(pipeline_doclinks_edge_lands_in_store) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_doclinks_pipeline_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + + write_temp_file(tmp, "README.md", "See [main](src/main.go) for the entry point.\n"); + write_temp_file(tmp, "src/main.go", "package main\n\nfunc main() {}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/doclinks.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + ASSERT_GT(cbm_store_count_edges_by_type(s, project, "REFERENCES_FILE"), 0); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Spilling must be invisible in the OUTPUT: the same repository indexed with * results parked on disk must produce the same graph as one indexed entirely in * memory. It did not. The namespace map that `use`/`using`/package imports @@ -15079,6 +15109,7 @@ SUITE(pipeline) { RUN_TEST(store_bulk_persistence); /* Integration: structure pass */ RUN_TEST(pipeline_grpc_routes_cover_every_service_past_the_old_cap); + RUN_TEST(pipeline_doclinks_edge_lands_in_store); RUN_TEST(pipeline_spill_resolves_namespace_imports_like_memory); RUN_TEST(pipeline_structure_nodes); RUN_TEST(pipeline_committed_counts_match_persisted);