From f3bdf11a039b8ed8d685b04ddecedaac1e409167 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Deust?= Date: Tue, 25 Aug 2026 10:53:40 +0000 Subject: [PATCH 1/3] feat(pipeline): link markdown file references into the graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markdown docs reference other repo files constantly (a coding-standards doc links the modules it governs, a README points at entry points) but none of that surfaced as edges, so fan-in queries were blind to documentation hubs. In an A/B retrieval benchmark I ran in August on a docs-heavy repo, the graph's top fan-in answer was off by roughly 17x: the most-referenced file was a standards doc with 173 references and zero inbound edges. New pre-dump pass, pass_doclinks.c, modeled on pass_configlink.c: three strategies emit REFERENCES_FILE edges between existing File nodes only (unresolvable targets are dropped, the pass never invents nodes): inline links, backtick paths, bare path mentions. Targets resolve against the referencing file's directory and the repo root; repeated references collapse into one edge carrying strategy, confidence and count. Registered in the pre-dump sequence after configlink and in the incremental post-passes; REFERENCES_FILE added to the skill's edge-type list and the structural/language contract tests. Tests mirror test_configlink.c (real files in a tmpdir, File nodes in a gbuf, run the pass, assert edges): inline link, backtick, bare mention, http/anchor ignored, anchor-suffixed file link, dedupe with count, relative-vs-root resolution, unresolvable-target guard, NULL repo_path skip. A companion change adds the same linking for shell files (source lines and script invocations); split out to keep each change reviewable. Validation: scripts/build.sh clean; focused serial runner (doclinks, configlink, pipeline, edge_structural, lang_contract) 340 passed under ASan/UBSan; cppcheck clean on the new files with the repo's flags. Signed-off-by: Clément Deust Co-Authored-By: Claude Fable 5 --- Makefile.cbm | 3 +- src/cli/cli.c | 4 +- src/pipeline/pass_doclinks.c | 495 ++++++++++++++++++++++++++++ src/pipeline/pipeline.c | 4 + src/pipeline/pipeline_incremental.c | 8 + src/pipeline/pipeline_internal.h | 3 + tests/test_doclinks.c | 312 ++++++++++++++++++ tests/test_edge_structural.c | 1 + tests/test_lang_contract.c | 1 + tests/test_main.c | 4 + 10 files changed, 832 insertions(+), 3 deletions(-) create mode 100644 src/pipeline/pass_doclinks.c create mode 100644 tests/test_doclinks.c diff --git a/Makefile.cbm b/Makefile.cbm index 305cb6937..7a3cd31e1 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -449,6 +449,7 @@ PIPELINE_SRCS = \ src/pipeline/pass_gitdiff.c \ src/pipeline/pass_configures.c \ src/pipeline/pass_configlink.c \ + src/pipeline/pass_doclinks.c \ src/pipeline/pass_route_nodes.c \ src/pipeline/pass_enrichment.c \ src/pipeline/pass_envscan.c \ @@ -675,7 +676,7 @@ TEST_DISCOVER_SRCS = \ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c +TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c TEST_WATCHER_SRCS = tests/test_watcher.c diff --git a/src/cli/cli.c b/src/cli/cli.c index b59a894a6..b868fdca2 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1513,8 +1513,8 @@ static const char skill_content[] = "\n" "## Edge Types\n" "CALLS, HTTP_CALLS, ASYNC_CALLS, DATA_FLOWS, IMPORTS, DEFINES, DEFINES_METHOD,\n" - "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, CALL_REFERENCE, CONFIGURES, FILE_CHANGES_WITH,\n" - "SIMILAR_TO, SEMANTICALLY_RELATED, CONTAINS_FILE, CONTAINS_FOLDER,\n" + "HANDLES, IMPLEMENTS, OVERRIDE, USAGE, CALL_REFERENCE, CONFIGURES, REFERENCES_FILE,\n" + "FILE_CHANGES_WITH, SIMILAR_TO, SEMANTICALLY_RELATED, CONTAINS_FILE, CONTAINS_FOLDER,\n" "CONTAINS_PACKAGE\n" "\n" "## Cypher Examples (for query_graph)\n" diff --git a/src/pipeline/pass_doclinks.c b/src/pipeline/pass_doclinks.c new file mode 100644 index 000000000..d27a22ce0 --- /dev/null +++ b/src/pipeline/pass_doclinks.c @@ -0,0 +1,495 @@ +/* + * pass_doclinks.c — Documentation → file reference linking (pre-dump pass). + * + * Markdown docs reference other repo files constantly — a coding-standards + * doc links to the modules it governs, a README points at entry points — + * but none of that surfaced as graph edges, so fan-in queries were blind to + * documentation hubs: on a docs-heavy repo the top fan-in answer was off by + * an order of magnitude because the most-referenced doc had zero inbound + * edges. + * + * Three strategies emit REFERENCES_FILE edges between EXISTING File nodes + * (targets that don't resolve to an indexed file are dropped — the pass + * never invents nodes): + * MD 1. Inline link: [text](relative/path.ext) (not http/mailto/#anchor) + * MD 2. Backtick path: `path/with/slash.ext` or `file.ext` + * MD 3. Bare mention: relative/path.ext (slash + extension) + * + * Targets resolve relative to the referencing file's directory AND the repo + * root (docs are written both ways). Repeated references between the same + * file pair are collapsed to one edge carrying a "count" property; the edge + * keeps the highest-confidence strategy that matched. + * + * Operates on the graph buffer before dump to .db file. + */ +#include "pipeline/pipeline.h" +#include "pipeline/pipeline_internal.h" +#include "graph_buffer/graph_buffer.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/log.h" +#include "foundation/compat_fs.h" +#include "foundation/limits.h" + +#include +#include +#include +#include +#include +#include + +#define SLEN(s) (sizeof(s) - SKIP_ONE) + +/* ── Doc link confidence scores ──────────────────────────────────── */ +/* Markdown strategies */ +#define DOCLINK_MD_INLINE 0.95 +#define DOCLINK_MD_BACKTICK 0.85 +#define DOCLINK_MD_BARE 0.70 + +/* Edge type emitted by this pass. */ +#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 */ +}; + +/* ── Path classification ─────────────────────────────────────────── */ + +/* Extension of the path's basename (including the dot), or NULL. */ +static const char *doclink_path_ext(const char *path) { + if (!path) { + return NULL; + } + const char *base = strrchr(path, '/'); + base = base ? base + SKIP_ONE : path; + return strrchr(base, '.'); +} + +static bool doclink_is_markdown_path(const char *path) { + const char *ext = doclink_path_ext(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 *f = cbm_fopen(path, "rb"); + if (!f) { + 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()) { + (void)fclose(f); + return NULL; + } + char *buf = malloc((size_t)size + SKIP_ONE); + if (!buf) { + (void)fclose(f); + return NULL; + } + size_t nread = fread(buf, SKIP_ONE, (size_t)size, f); + (void)fclose(f); + if (nread > (size_t)size) { + nread = (size_t)size; + } + buf[nread] = '\0'; + return buf; +} + +/* ── Path normalization + resolution ─────────────────────────────── */ + +/* Normalize "a/./b/../c" into "a/c". Rejects paths that escape the repo + * root (leading ".."), empty results, and over-long/over-deep inputs. */ +static bool doclink_normalize(const char *in, char *out, size_t out_sz) { + size_t seg_starts[DOCLINK_MAX_SEGS]; + int depth = 0; + size_t out_len = 0; + const char *p = in; + out[0] = '\0'; + while (*p) { + const char *seg = p; + const char *slash = strchr(p, '/'); + size_t seg_len = slash ? (size_t)(slash - p) : strlen(p); + p = slash ? slash + SKIP_ONE : p + seg_len; + if (seg_len == 0 || (seg_len == SKIP_ONE && seg[0] == '.')) { + continue; + } + if (seg_len == PAIR_LEN && seg[0] == '.' && seg[SKIP_ONE] == '.') { + if (depth == 0) { + return false; /* escapes the repo root */ + } + depth--; + out_len = seg_starts[depth]; + out[out_len] = '\0'; + continue; + } + if (depth >= DOCLINK_MAX_SEGS || out_len + seg_len + PAIR_LEN >= out_sz) { + return false; + } + seg_starts[depth++] = out_len; + if (out_len > 0) { + out[out_len++] = '/'; + } + memcpy(out + out_len, seg, seg_len); + out_len += seg_len; + out[out_len] = '\0'; + } + return out_len > 0; +} + +/* Per-file accumulator: dedupes repeated references to the same target. */ +typedef struct { + int64_t target_id; + int count; + double confidence; + const char *strategy; /* static string literal */ +} doclink_ref_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; +} 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. */ +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] == '/') { + r++; /* "/docs/x.md" is repo-root-relative by doc convention */ + } + if (r[0] == '\0') { + return NULL; + } + + char norm[CBM_SZ_512]; + if (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))) { + const cbm_gbuf_node_t *node = cbm_ht_get(dc->files_by_path, norm); + if (node) { + return node; + } + } + } + if (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. */ +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++; +} + +/* Emit accumulated references as REFERENCES_FILE edges. Returns edge count. */ +static int doclink_flush(doclink_ctx_t *dc) { + int emitted = 0; + for (int i = 0; i < dc->ref_count; i++) { + 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) { + emitted++; + } + } + 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; + return emitted; +} + +/* ── Markdown scanning ───────────────────────────────────────────── */ + +/* Characters allowed in a path-shaped token (backtick / bare mention). */ +static bool doclink_token_pathlike(const char *tok) { + bool last_seg_has_dot = false; + for (const char *p = tok; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '/') { + last_seg_has_dot = false; + continue; + } + if (c == '.') { + last_seg_has_dot = true; + continue; + } + if (!isalnum(c) && c != '_' && c != '-' && c != '+' && c != '@' && c != '~') { + return false; + } + } + /* the basename must carry an extension — bare words are not paths */ + return last_seg_has_dot; +} + +/* A markdown link target worth resolving: not a URL, mailto, or pure anchor. */ +static bool doclink_md_target_ok(const char *target) { + if (target[0] == '\0' || target[0] == '#') { + return false; + } + if (strstr(target, "://") != NULL || strncmp(target, "mailto:", SLEN("mailto:")) == 0) { + return false; + } + return true; +} + +/* MD 1: inline links [text](target). Consumed spans are blanked so the + * backtick / bare-mention scans below cannot re-match the same path. */ +static void doclink_scan_md_links(doclink_ctx_t *dc, char *line) { + char *p = line; + while ((p = strstr(p, "](")) != NULL) { + char *close = strchr(p + PAIR_LEN, ')'); + if (!close) { + return; + } + char target[CBM_SZ_512]; + size_t tlen = (size_t)(close - (p + PAIR_LEN)); + if (tlen < sizeof(target)) { + memcpy(target, p + PAIR_LEN, tlen); + target[tlen] = '\0'; + char *cut = strchr(target, ' '); /* [t](path "title") */ + if (cut) { + *cut = '\0'; + } + cut = strchr(target, '#'); /* [t](path#anchor) */ + if (cut) { + *cut = '\0'; + } + if (doclink_md_target_ok(target)) { + doclink_record(dc, doclink_resolve(dc, target), DOCLINK_MD_INLINE, + "md_inline_link"); + } + } + /* blank the whole [text](target) span, link text included, so a + * path-shaped link text is not re-counted as a bare mention */ + char *open = p; + while (open > line && *open != '[') { + open--; + } + if (*open != '[') { + open = p; + } + memset(open, ' ', (size_t)(close - open) + SKIP_ONE); + p = close + SKIP_ONE; + } +} + +/* MD 2: backtick-quoted paths `src/foo.c` / `build.sh`. */ +static void doclink_scan_md_backticks(doclink_ctx_t *dc, char *line) { + char *p = line; + while ((p = strchr(p, '`')) != NULL) { + char *end = strchr(p + SKIP_ONE, '`'); + if (!end) { + return; + } + char tok[CBM_SZ_512]; + size_t tlen = (size_t)(end - (p + SKIP_ONE)); + if (tlen > 0 && tlen < sizeof(tok)) { + memcpy(tok, p + SKIP_ONE, tlen); + tok[tlen] = '\0'; + if (doclink_token_pathlike(tok)) { + doclink_record(dc, doclink_resolve(dc, tok), DOCLINK_MD_BACKTICK, + "md_backtick_path"); + } + } + memset(p, ' ', (size_t)(end - p) + SKIP_ONE); + p = end + SKIP_ONE; + } +} + +static bool doclink_md_delim(char c) { + return isspace((unsigned char)c) || strchr("()[]{}<>\"',;:`*|", c) != NULL; +} + +/* MD 3: bare relative path mentions — must contain a slash AND an extension + * (and, via doclink_resolve, an indexed file) to count. */ +static void doclink_scan_md_bare(doclink_ctx_t *dc, const char *line) { + const char *p = line; + while (*p) { + while (*p && doclink_md_delim(*p)) { + p++; + } + const char *start = p; + while (*p && !doclink_md_delim(*p)) { + p++; + } + size_t tlen = (size_t)(p - start); + char tok[CBM_SZ_512]; + if (tlen == 0 || tlen >= sizeof(tok)) { + continue; + } + memcpy(tok, start, tlen); + tok[tlen] = '\0'; + while (tlen > 0 && tok[tlen - SKIP_ONE] == '.') { + tok[--tlen] = '\0'; /* sentence-ending period */ + } + if (strchr(tok, '/') != NULL && doclink_token_pathlike(tok)) { + doclink_record(dc, doclink_resolve(dc, tok), DOCLINK_MD_BARE, "md_bare_mention"); + } + } +} + +static void doclink_scan_md_line(doclink_ctx_t *dc, char *line) { + doclink_scan_md_links(dc, line); + doclink_scan_md_backticks(dc, line); + doclink_scan_md_bare(dc, 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) { + dc->src = node; + dc->ref_count = 0; + dc->truncated = false; + dc->src_dir[0] = '\0'; + const char *slash = strrchr(node->file_path, '/'); + if (slash) { + size_t dlen = (size_t)(slash - node->file_path); + if (dlen >= sizeof(dc->src_dir)) { + return 0; + } + memcpy(dc->src_dir, node->file_path, dlen); + dc->src_dir[dlen] = '\0'; + } + + const char *p = source; + char line[CBM_SZ_4K]; + 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; + } + 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); +} + +/* ── Pass entry point ────────────────────────────────────────────── */ + +/* True when at least one File node is a markdown file. */ +static bool doclink_has_doc_files(const cbm_gbuf_node_t *const *files, int file_count) { + for (int i = 0; i < file_count; i++) { + if (doclink_is_markdown_path(files[i]->file_path)) { + return true; + } + } + return false; +} + +/* 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, + const cbm_gbuf_node_t *const *files, int file_count, int *md_edges) { + for (int i = 0; i < file_count; i++) { + if (!files[i]->file_path || !doclink_is_markdown_path(files[i]->file_path)) { + continue; + } + + char abs_path[CBM_PATH_MAX]; + int n = snprintf(abs_path, sizeof(abs_path), "%s/%s", repo_path, files[i]->file_path); + if (n <= 0 || (size_t)n >= sizeof(abs_path)) { + continue; + } + char *source = doclink_read_file(abs_path); + if (!source) { + continue; + } + int emitted = doclink_scan_file(dc, files[i], source); + free(source); + *md_edges += emitted; + } +} + +int cbm_pipeline_pass_doclinks(cbm_pipeline_ctx_t *ctx) { + cbm_gbuf_t *gb = ctx->gbuf; + + const cbm_gbuf_node_t **files = NULL; + int file_count = 0; + if (cbm_gbuf_find_by_label(gb, "File", &files, &file_count) != 0 || file_count == 0) { + return 0; + } + + /* Early exit: no markdown/shell files means nothing to scan. */ + if (!doclink_has_doc_files(files, file_count)) { + cbm_log_info("doclinks.skip", "reason", "no_doc_files"); + return 0; + } + if (!ctx->repo_path) { + cbm_log_info("doclinks.skip", "reason", "no_repo_path"); + return 0; + } + + doclink_ctx_t dc; + memset(&dc, 0, sizeof(dc)); + dc.gb = gb; + dc.files_by_path = cbm_ht_create((uint32_t)file_count); + if (!dc.files_by_path) { + return 0; + } + for (int i = 0; i < file_count; i++) { + if (files[i]->file_path) { + /* key borrowed from the node (owned by gbuf, outlives the pass) */ + cbm_ht_set(dc.files_by_path, files[i]->file_path, (void *)files[i]); + } + } + + int md_edges = 0; + doclink_scan_repo(&dc, ctx->repo_path, files, file_count, &md_edges); + cbm_ht_free(dc.files_by_path); + + char buf1[CBM_SZ_16]; + (void)snprintf(buf1, sizeof(buf1), "%d", md_edges); + cbm_log_info("doclinks.strategy", "name", "markdown", "edges", buf1); + cbm_log_info("doclinks.done", "total", buf1); + + return md_edges; +} diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 6bc0310dd..fa62441a1 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1054,6 +1054,9 @@ static void predump_sem(cbm_pipeline_ctx_t *ctx) { static void predump_cfg(cbm_pipeline_ctx_t *ctx) { cbm_pipeline_pass_configlink(ctx); } +static void predump_doclinks(cbm_pipeline_ctx_t *ctx) { + cbm_pipeline_pass_doclinks(ctx); +} static void predump_complexity(cbm_pipeline_ctx_t *ctx) { cbm_pipeline_pass_complexity(ctx); } @@ -1288,6 +1291,7 @@ static void run_predump_passes(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) { } passes[] = { {predump_deco, "decorator_tags", false}, {predump_cfg, "configlink", false}, + {predump_doclinks, "doclinks", false}, {predump_route, "route_match", false}, {predump_ensemble, "ensemble_routing", false}, {predump_sim, "similarity", true}, diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 894b73257..154518658 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -1446,6 +1446,14 @@ static int run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_file return rc < 0 ? rc : CBM_NOT_FOUND; } + cbm_clock_gettime(CLOCK_MONOTONIC, &t); + rc = cbm_pipeline_pass_doclinks(ctx); + cbm_log_info("pass.timing", "pass", "incr_doclinks", "elapsed_ms", + itoa_buf((int)elapsed_ms(t))); + if (rc < 0 || cbm_pipeline_check_cancel(ctx)) { + return rc < 0 ? rc : CBM_NOT_FOUND; + } + /* SIMILAR_TO + SEMANTICALLY_RELATED edges only in moderate/full modes */ if (ctx->mode <= CBM_MODE_MODERATE) { cbm_clock_gettime(CLOCK_MONOTONIC, &t); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 78ce486a0..275094731 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -665,6 +665,9 @@ 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. */ +int cbm_pipeline_pass_doclinks(cbm_pipeline_ctx_t *ctx); + /* Pre-dump pass: SIMILAR_TO edges via MinHash fingerprinting. */ int cbm_pipeline_pass_similarity(cbm_pipeline_ctx_t *ctx); diff --git a/tests/test_doclinks.c b/tests/test_doclinks.c new file mode 100644 index 000000000..a03542d9b --- /dev/null +++ b/tests/test_doclinks.c @@ -0,0 +1,312 @@ +/* + * test_doclinks.c — Tests for markdown/shell → 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, + * run the pass, check REFERENCES_FILE edges. + */ +#include "../src/foundation/compat.h" +#include "test_framework.h" +#include "test_helpers.h" +#include "pipeline/pipeline.h" +#include "pipeline/pipeline_internal.h" +#include "graph_buffer/graph_buffer.h" + +#include +#include +#include +#include + +/* ── Helpers ─────────────────────────────────────────────────────── */ + +/* Run the pass with a minimal ctx (same shape as run_configlink). */ +static int run_doclinks(cbm_gbuf_t *gb, const char *project, const char *repo_path) { + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = { + .project_name = project, + .repo_path = repo_path, + .gbuf = gb, + .cancelled = &cancelled, + }; + return cbm_pipeline_pass_doclinks(&ctx); +} + +/* Create a File node the way pass_structure does: QN via fqn_compute with + * "__file__", file_path = repo-relative path. Returns the node id. */ +static int64_t add_file_node(cbm_gbuf_t *gb, const char *project, const char *rel) { + char *qn = cbm_pipeline_fqn_compute(project, rel, "__file__"); + const char *slash = strrchr(rel, '/'); + const char *basename = slash ? slash + 1 : rel; + int64_t id = cbm_gbuf_upsert_node(gb, "File", basename, qn, rel, 0, 0, NULL); + free(qn); + return id; +} + +/* Find the REFERENCES_FILE edge between two node ids. NULL if absent. */ +static const cbm_gbuf_edge_t *find_ref_edge(cbm_gbuf_t *gb, int64_t src, int64_t tgt) { + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + cbm_gbuf_find_edges_by_type(gb, "REFERENCES_FILE", &edges, &count); + for (int i = 0; i < count; i++) { + if (edges[i]->source_id == src && edges[i]->target_id == tgt) { + return edges[i]; + } + } + return NULL; +} + +/* True when the edge's props JSON carries the given strategy. */ +static bool edge_has_strategy(const cbm_gbuf_edge_t *e, const char *strategy) { + char needle[64]; + snprintf(needle, sizeof(needle), "\"strategy\":\"%s\"", strategy); + return e && e->properties_json && strstr(e->properties_json, needle) != NULL; +} + +/* Total REFERENCES_FILE edge count. */ +static int ref_edge_count(cbm_gbuf_t *gb) { + return cbm_gbuf_edge_count_by_type(gb, "REFERENCES_FILE"); +} + +/* Fixture: tmpdir + project + gbuf. */ +typedef struct { + char tmpdir[256]; + char *project; + cbm_gbuf_t *gb; +} dl_fix_t; + +static bool dl_fix_init(dl_fix_t *fx) { + snprintf(fx->tmpdir, sizeof(fx->tmpdir), "/tmp/cbm_doclinks_XXXXXX"); + if (!cbm_mkdtemp(fx->tmpdir)) { + return false; + } + fx->project = cbm_project_name_from_path(fx->tmpdir); + fx->gb = cbm_gbuf_new(fx->project, fx->tmpdir); + return fx->gb != NULL; +} + +static void dl_fix_free(dl_fix_t *fx) { + cbm_gbuf_free(fx->gb); + free(fx->project); + th_rmtree(fx->tmpdir); +} + +/* ── Markdown: inline link ───────────────────────────────────────── */ + +TEST(doclinks_md_inline_link) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "docs/guide.md"), + "# Guide\n\nSee [the build script](../scripts/build.sh) for details.\n"); + th_write_file(TH_PATH(fx.tmpdir, "scripts/build.sh"), "#!/bin/sh\necho build\n"); + + int64_t doc_id = add_file_node(fx.gb, fx.project, "docs/guide.md"); + int64_t script_id = add_file_node(fx.gb, fx.project, "scripts/build.sh"); + + int n = run_doclinks(fx.gb, fx.project, fx.tmpdir); + ASSERT_GT(n, 0); + + const cbm_gbuf_edge_t *e = find_ref_edge(fx.gb, doc_id, script_id); + ASSERT_NOT_NULL(e); + ASSERT_TRUE(edge_has_strategy(e, "md_inline_link")); + ASSERT_NOT_NULL(strstr(e->properties_json, "\"confidence\":0.95")); + + dl_fix_free(&fx); + PASS(); +} + +/* ── Markdown: backtick path ─────────────────────────────────────── */ + +TEST(doclinks_md_backtick_path) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "README.md"), + "Run `scripts/build.sh` before pushing. `not_a_file.xyz` is unknown.\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); + + const cbm_gbuf_edge_t *e = find_ref_edge(fx.gb, doc_id, script_id); + ASSERT_NOT_NULL(e); + ASSERT_TRUE(edge_has_strategy(e, "md_backtick_path")); + /* `not_a_file.xyz` has no File node → no invented target */ + ASSERT_EQ(ref_edge_count(fx.gb), 1); + + dl_fix_free(&fx); + PASS(); +} + +/* ── Markdown: bare mention ──────────────────────────────────────── */ + +TEST(doclinks_md_bare_mention) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "STANDARDS.md"), + "All handlers live in src/handlers.c and follow the pattern there.\n"); + th_write_file(TH_PATH(fx.tmpdir, "src/handlers.c"), "int h(void) { return 0; }\n"); + + int64_t doc_id = add_file_node(fx.gb, fx.project, "STANDARDS.md"); + int64_t code_id = add_file_node(fx.gb, fx.project, "src/handlers.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(); +} + +/* ── Markdown: non-file links ignored (http / mailto / #anchor) ──── */ + +TEST(doclinks_md_non_file_link_ignored) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "README.md"), + "See [the site](https://example.com/scripts/build.sh) or\n" + "[mail us](mailto:dev@example.com) or [below](#usage).\n"); + th_write_file(TH_PATH(fx.tmpdir, "scripts/build.sh"), "#!/bin/sh\n"); + + add_file_node(fx.gb, fx.project, "README.md"); + add_file_node(fx.gb, fx.project, "scripts/build.sh"); + + 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(); +} + +/* ── Markdown: anchor suffix on a file link still resolves ───────── */ + +TEST(doclinks_md_link_with_anchor_resolves_file) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "docs/a.md"), "See [setup](../INSTALL.md#quick-start).\n"); + th_write_file(TH_PATH(fx.tmpdir, "INSTALL.md"), "# Install\n"); + + int64_t doc_id = add_file_node(fx.gb, fx.project, "docs/a.md"); + int64_t tgt_id = add_file_node(fx.gb, fx.project, "INSTALL.md"); + + run_doclinks(fx.gb, fx.project, fx.tmpdir); + ASSERT_NOT_NULL(find_ref_edge(fx.gb, doc_id, tgt_id)); + + dl_fix_free(&fx); + PASS(); +} + +/* ── Dedupe: repeated references collapse to one counted edge ────── */ + +TEST(doclinks_dedupe_keeps_count) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "README.md"), + "Use [build](scripts/build.sh) daily.\n" + "Run `scripts/build.sh` first, then scripts/build.sh again.\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"); + + int n = run_doclinks(fx.gb, fx.project, fx.tmpdir); + /* three matches, ONE edge */ + ASSERT_EQ(n, 1); + ASSERT_EQ(ref_edge_count(fx.gb), 1); + + const cbm_gbuf_edge_t *e = find_ref_edge(fx.gb, doc_id, script_id); + ASSERT_NOT_NULL(e); + ASSERT_NOT_NULL(strstr(e->properties_json, "\"count\":3")); + /* highest-confidence match kind wins the edge label */ + ASSERT_TRUE(edge_has_strategy(e, "md_inline_link")); + ASSERT_NOT_NULL(strstr(e->properties_json, "\"confidence\":0.95")); + + dl_fix_free(&fx); + PASS(); +} + +/* ── Path resolution: relative to the referencing file's directory ── */ + +TEST(doclinks_resolves_relative_to_file_dir) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + /* docs/deep/page.md links an upward "../../src/main.c" path and a + * sibling-relative "notes.md" path, neither repo-root-relative. */ + th_write_file(TH_PATH(fx.tmpdir, "docs/deep/page.md"), + "See [main](../../src/main.c) and [notes](notes.md).\n"); + th_write_file(TH_PATH(fx.tmpdir, "docs/deep/notes.md"), "# notes\n"); + th_write_file(TH_PATH(fx.tmpdir, "src/main.c"), "int main(void) { return 0; }\n"); + + int64_t page_id = add_file_node(fx.gb, fx.project, "docs/deep/page.md"); + int64_t notes_id = add_file_node(fx.gb, fx.project, "docs/deep/notes.md"); + int64_t main_id = add_file_node(fx.gb, fx.project, "src/main.c"); + + run_doclinks(fx.gb, fx.project, fx.tmpdir); + + ASSERT_NOT_NULL(find_ref_edge(fx.gb, page_id, main_id)); + ASSERT_NOT_NULL(find_ref_edge(fx.gb, page_id, notes_id)); + + dl_fix_free(&fx); + PASS(); +} + +/* ── Never invent nodes: unresolvable targets produce nothing ────── */ + +TEST(doclinks_unresolvable_target_no_edge) { + dl_fix_t fx; + ASSERT_TRUE(dl_fix_init(&fx)); + + th_write_file(TH_PATH(fx.tmpdir, "README.md"), + "See [gone](docs/removed.md) and `also/missing.sh`.\n"); + + add_file_node(fx.gb, fx.project, "README.md"); + + int node_count_before = cbm_gbuf_node_count(fx.gb); + int n = run_doclinks(fx.gb, fx.project, fx.tmpdir); + ASSERT_EQ(n, 0); + ASSERT_EQ(ref_edge_count(fx.gb), 0); + ASSERT_EQ(cbm_gbuf_node_count(fx.gb), node_count_before); + + dl_fix_free(&fx); + PASS(); +} + +/* ── NULL repo_path (configlink-style unit setups) is a clean skip ── */ + +TEST(doclinks_null_repo_path_skips) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/test"); + add_file_node(gb, "test", "README.md"); + int n = run_doclinks(gb, "test", NULL); + ASSERT_EQ(n, 0); + cbm_gbuf_free(gb); + PASS(); +} + +/* ── Suite ───────────────────────────────────────────────────────── */ + +SUITE(doclinks) { + /* Markdown strategies */ + RUN_TEST(doclinks_md_inline_link); + RUN_TEST(doclinks_md_backtick_path); + RUN_TEST(doclinks_md_bare_mention); + RUN_TEST(doclinks_md_non_file_link_ignored); + RUN_TEST(doclinks_md_link_with_anchor_resolves_file); + + /* Dedupe + resolution + guards */ + RUN_TEST(doclinks_dedupe_keeps_count); + RUN_TEST(doclinks_resolves_relative_to_file_dir); + RUN_TEST(doclinks_unresolvable_target_no_edge); + RUN_TEST(doclinks_null_repo_path_skips); +} diff --git a/tests/test_edge_structural.c b/tests/test_edge_structural.c index 3f43628fa..798fef3d0 100644 --- a/tests/test_edge_structural.c +++ b/tests/test_edge_structural.c @@ -258,6 +258,7 @@ static const char *ES_ALL_EDGE_TYPES[] = {"CALLS", "INHERITS", "INFRA_MAPS", "OVERRIDE", + "REFERENCES_FILE", "SEMANTICALLY_RELATED", "SIMILAR_TO", "TESTS_FILE", diff --git a/tests/test_lang_contract.c b/tests/test_lang_contract.c index 5a32bcda0..6eb993564 100644 --- a/tests/test_lang_contract.c +++ b/tests/test_lang_contract.c @@ -1070,6 +1070,7 @@ static const char *ALL_EDGE_TYPES[] = {"CALLS", "INHERITS", "INFRA_MAPS", "OVERRIDE", + "REFERENCES_FILE", "SEMANTICALLY_RELATED", "SIMILAR_TO", "TESTS_FILE", diff --git a/tests/test_main.c b/tests/test_main.c index 31cceac60..1278c1c70 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -852,6 +852,7 @@ extern void suite_store_pragmas(void); extern void suite_store_checkpoint(void); extern void suite_traces(void); extern void suite_configlink(void); +extern void suite_doclinks(void); extern void suite_infrascan(void); extern void suite_cli(void); extern void suite_agent_clients(void); @@ -1181,6 +1182,9 @@ int main(int argc, char **argv) { /* Config link */ RUN_SELECTED_SUITE(configlink); + /* Doc/shell file reference link */ + RUN_SELECTED_SUITE(doclinks); + /* Infrastructure scanning */ RUN_SELECTED_SUITE(infrascan); From 6ae6cd4c3f280cb8e3ffec2858269055e390ffec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Deust?= Date: Tue, 22 Sep 2026 08:48:33 +0200 Subject: [PATCH 2/3] fix(pipeline): address doclinks review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace raw malloc/free with cbm_alloc/cbm_free (CBM_MEM_CLASS_EXTRACT) and a growable CBM_DYN_ARRAY for matches. Remove the 4KiB line-copy cap; lines are split in place instead of copied. Remove the DOCLINK_MAX_REFS(256) cap; the ref vector now grows, is sorted by target id, then collapsed. Track reference kind (bare, relative, rooted) so /x never attempts a directory join and ./x never falls back to root. Report per-file skips via cbm_pipeline_add_file_error and cbm_log_warn, and check cbm_pipeline_check_cancel in the per-file loop. Add negative-control tests for all of the above, plus CRLF handling, fenced-code-block pinning, bare URLs, backtick-basename exactness, and ".." escaping. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Clément Deust --- src/pipeline/pass_doclinks.c | 231 +++++++++++++++++++---------- src/pipeline/pipeline_internal.h | 2 +- tests/test_doclinks.c | 246 ++++++++++++++++++++++++++++++- tests/test_main.c | 2 +- tests/test_pipeline.c | 31 ++++ 5 files changed, 432 insertions(+), 80 deletions(-) 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; + } + if (size > cbm_max_file_bytes()) { (void)fclose(f); + if (out_status) { + *out_status = CBM_READ_OVERSIZED; + } return NULL; } - char *buf = malloc((size_t)size + SKIP_ONE); + 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); +} + +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. Returns edge count. */ +/* 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); From 9a9534cbc45d40afa4e8bf070030a806acea1f5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Deust?= Date: Tue, 22 Sep 2026 08:54:40 +0200 Subject: [PATCH 3/3] docs: list REFERENCES_FILE in the README edge types table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The full Edge Types list near the graph schema section still missed the new REFERENCES_FILE type added by the doclinks pass. The "selected" list earlier in the file is explicitly a subset and is left as is. Co-Authored-By: Claude Sonnet 5 Signed-off-by: Clément Deust --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 66b50fe08..9c82c18ae 100644 --- a/README.md +++ b/README.md @@ -704,7 +704,7 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool ### Edge Types -`CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `HTTP_CALLS`, `ASYNC_CALLS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH` +`CONTAINS_PACKAGE`, `CONTAINS_FOLDER`, `CONTAINS_FILE`, `DEFINES`, `DEFINES_METHOD`, `IMPORTS`, `CALLS`, `CALL_REFERENCE`, `HTTP_CALLS`, `ASYNC_CALLS`, `IMPLEMENTS`, `HANDLES`, `USAGE`, `CONFIGURES`, `REFERENCES_FILE`, `WRITES`, `MEMBER_OF`, `TESTS`, `USES_TYPE`, `FILE_CHANGES_WITH` ### Qualified Names