mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
1601 lines
80 KiB
C
1601 lines
80 KiB
C
/*
|
|
* test_doc_links_md.c — Markdown documents -> MENTIONS edges.
|
|
*
|
|
* Extraction (doclink_md.c: which text is a reference, its family, line and
|
|
* section), the span classifier, the resolver (doc_links_md.c: paths, ranges,
|
|
* members, qualified names, the unresolved reasons, what is no reference at
|
|
* all), back navigation, and incremental == full. Pipeline tests index a real
|
|
* fixture through cbm_pipeline_run (helpers: test_doc_mentions_helpers.h).
|
|
*/
|
|
#include "../src/foundation/compat.h"
|
|
#include "test_framework.h"
|
|
#include "test_helpers.h"
|
|
#include "test_doc_mentions_helpers.h"
|
|
|
|
#include "cbm.h"
|
|
#include "doclink.h"
|
|
#include "pipeline/pipeline.h"
|
|
#include "pipeline/pipeline_internal.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
|
|
/* ── the fixture ─────────────────────────────────────────────────── */
|
|
|
|
/* docs/guide.md, line by line (the line numbers are asserted below). */
|
|
static const char MD_GUIDE[] =
|
|
"Intro names `pkg/config.go` before any heading.\n" /* 1 */
|
|
"\n" /* 2 */
|
|
"# Guide\n" /* 3 */
|
|
"\n" /* 4 */
|
|
"See [the config](../pkg/config.go) and [routing][r].\n" /* 5 */
|
|
"\n" /* 6 */
|
|
"[r]: ../app/routing.py\n" /* 7 */
|
|
"\n" /* 8 */
|
|
"## Paths\n" /* 9 */
|
|
"\n" /* 10 */
|
|
"Range `app/routing.py#L4-L5`, span `app/routing.py:1-9`, bare "
|
|
"app/applications.py.\n" /* 11 */
|
|
"Dir `app/middleware/`, route `/docs`,  and "
|
|
"https://example.org/a/b.py.\n" /* 12 */
|
|
"\n" /* 13 */
|
|
"## Names\n" /* 14 */
|
|
"\n" /* 15 */
|
|
"Use `app.routing.APIRouter.add`, `app.middleware`, `pkg.Config.Name`, "
|
|
"`self.Name`.\n" /* 16 */
|
|
"Also `os.path.join`, `app.Nope`, `test_routing.only_in_tests`, `main.py`, "
|
|
"`../app/gone.py`.\n" /* 17 */
|
|
"\n" /* 18 */
|
|
"```\n" /* 19 */
|
|
"`app/fenced.py` inside a fence\n" /* 20 */
|
|
"```\n" /* 21 */
|
|
"<!-- `app/commented.py` in a comment -->\n" /* 22 */
|
|
"<code>app/html.py</code> in HTML, `cfg.Name` through an instance.\n"; /* 23 */
|
|
|
|
static void md_write_fixture(const char *tmp) {
|
|
th_write_file(TH_PATH(tmp, "docs/guide.md"), MD_GUIDE);
|
|
th_write_file(TH_PATH(tmp, "app/__init__.py"), "");
|
|
th_write_file(TH_PATH(tmp, "app/routing.py"), "class APIRouter:\n" /* 1 */
|
|
" prefix = ''\n" /* 2 */
|
|
"\n" /* 3 */
|
|
" def add(self, p):\n" /* 4 */
|
|
" return p\n" /* 5 */
|
|
"\n" /* 6 */
|
|
"\n" /* 7 */
|
|
"def helper():\n" /* 8 */
|
|
" return 1\n"); /* 9 */
|
|
th_write_file(TH_PATH(tmp, "app/applications.py"), "class App:\n"
|
|
" def middleware(self):\n"
|
|
" return None\n");
|
|
th_write_file(TH_PATH(tmp, "app/middleware/__init__.py"), "VALUE = 1\n");
|
|
th_write_file(TH_PATH(tmp, "tests/test_routing.py"), "def only_in_tests():\n"
|
|
" return 0\n");
|
|
th_write_file(TH_PATH(tmp, "lib/main.py"), "def run():\n"
|
|
" return None\n");
|
|
th_write_file(TH_PATH(tmp, "pkg/config.go"), "package pkg\n"
|
|
"\n"
|
|
"type Config struct {\n"
|
|
"\tName string\n"
|
|
"}\n"
|
|
"\n"
|
|
"func Util() {}\n");
|
|
}
|
|
|
|
/* ── extraction ──────────────────────────────────────────────────── */
|
|
|
|
static const CBMDocLink *md_token(const CBMFileResult *r, const char *raw) {
|
|
return dm_find_token(r, raw);
|
|
}
|
|
|
|
static bool md_ends_with(const char *s, const char *suffix) {
|
|
size_t a = s ? strlen(s) : 0;
|
|
size_t b = strlen(suffix);
|
|
return a >= b && strcmp(s + a - b, suffix) == 0;
|
|
}
|
|
|
|
TEST(doc_links_md_extract_tokens) {
|
|
CBMFileResult *r = dm_extract(MD_GUIDE, CBM_LANG_MARKDOWN, "docs/guide.md");
|
|
ASSERT_NOT_NULL(r);
|
|
ASSERT_FALSE(r->doc_links.failed);
|
|
/* before the first heading: the file is the source */
|
|
const CBMDocLink *t = md_token(r, "pkg/config.go");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->line, 1);
|
|
ASSERT_EQ(t->syntax, CBM_DOCLINK_MD_CODE_PATH);
|
|
ASSERT_TRUE((t->flags & CBM_DOCLINK_FLAG_FILE) != 0);
|
|
/* an inline link and a link reference definition, under "Guide" */
|
|
t = md_token(r, "../pkg/config.go");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->line, 5);
|
|
ASSERT_EQ(t->syntax, CBM_DOCLINK_MD_LINK);
|
|
ASSERT_TRUE(md_ends_with(t->source_qn, "Guide"));
|
|
ASSERT_EQ(t->def_line, 3);
|
|
ASSERT_EQ(t->flags & CBM_DOCLINK_FLAG_FILE, 0);
|
|
t = md_token(r, "../app/routing.py");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->line, 7);
|
|
ASSERT_EQ(t->syntax, CBM_DOCLINK_MD_LINK);
|
|
/* paths under "Paths" */
|
|
t = md_token(r, "app/routing.py#L4-L5");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->line, 11);
|
|
ASSERT_TRUE(md_ends_with(t->source_qn, "Paths"));
|
|
ASSERT_NOT_NULL(md_token(r, "app/routing.py:1-9"));
|
|
t = md_token(r, "app/applications.py");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->syntax, CBM_DOCLINK_MD_PATH);
|
|
t = md_token(r, "app/middleware/");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->syntax, CBM_DOCLINK_MD_CODE_PATH);
|
|
/* a token (the resolver calls it a route); one directory name alone is a
|
|
* bare path: in a held-out audit `doc/` was another project's as often as not */
|
|
t = md_token(r, "/docs");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->syntax, CBM_DOCLINK_MD_BARE_PATH);
|
|
/* an image, a URL: no tokens */
|
|
ASSERT_NULL(md_token(r, "img/a.png"));
|
|
ASSERT_NULL(md_token(r, "https://example.org/a/b.py"));
|
|
ASSERT_NULL(md_token(r, "a/b.py"));
|
|
/* qualified names under "Names" */
|
|
t = md_token(r, "app.routing.APIRouter.add");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->syntax, CBM_DOCLINK_MD_CODE_NAME);
|
|
ASSERT_EQ(t->line, 16);
|
|
ASSERT_TRUE(md_ends_with(t->source_qn, "Names"));
|
|
ASSERT_NOT_NULL(md_token(r, "pkg.Config.Name"));
|
|
ASSERT_NULL(md_token(r, "self.Name")); /* one identifier after `self.`: a bare name */
|
|
ASSERT_NOT_NULL(md_token(r, "os.path.join"));
|
|
/* a file name alone is its own family: in the held-out audit every wrong
|
|
* code path was one (`config.yaml`: the reader's own file) */
|
|
t = md_token(r, "main.py");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->syntax, CBM_DOCLINK_MD_BARE_PATH);
|
|
/* fenced code and HTML comments are not scanned; HTML <code> is */
|
|
ASSERT_NULL(md_token(r, "app/fenced.py"));
|
|
ASSERT_NULL(md_token(r, "app/commented.py"));
|
|
t = md_token(r, "app/html.py");
|
|
ASSERT_NOT_NULL(t);
|
|
ASSERT_EQ(t->line, 23);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Two headings with one title share one Section node, which owns only one of
|
|
* them: their references belong to the file, never to the other heading. */
|
|
TEST(doc_links_md_repeated_headings) {
|
|
const char *src = "# Changes\n" /* 1 */
|
|
"\n" /* 2 */
|
|
"## Fixes\n" /* 3 */
|
|
"\n" /* 4 */
|
|
"See `app/one.py`.\n" /* 5 */
|
|
"\n" /* 6 */
|
|
"## Fixes\n" /* 7 */
|
|
"\n" /* 8 */
|
|
"See `app/two.py`.\n" /* 9 */
|
|
"\n" /* 10 */
|
|
"## Unique\n" /* 11 */
|
|
"\n" /* 12 */
|
|
"See `app/three.py`.\n"; /* 13 */
|
|
CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "CHANGES.md");
|
|
ASSERT_NOT_NULL(r);
|
|
int fixes = 0;
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
if (strcmp(r->defs.items[i].name, "Fixes") == 0) {
|
|
ASSERT_STR_EQ(r->defs.items[i].qualified_name, "p.CHANGES.Fixes");
|
|
fixes++;
|
|
}
|
|
}
|
|
ASSERT_EQ(fixes, 2);
|
|
const CBMDocLink *one = md_token(r, "app/one.py");
|
|
const CBMDocLink *two = md_token(r, "app/two.py");
|
|
const CBMDocLink *three = md_token(r, "app/three.py");
|
|
ASSERT_NOT_NULL(one);
|
|
ASSERT_NOT_NULL(two);
|
|
ASSERT_NOT_NULL(three);
|
|
ASSERT_TRUE((one->flags & CBM_DOCLINK_FLAG_FILE) != 0);
|
|
ASSERT_TRUE((two->flags & CBM_DOCLINK_FLAG_FILE) != 0);
|
|
ASSERT_EQ(three->flags & CBM_DOCLINK_FLAG_FILE, 0);
|
|
ASSERT_TRUE(md_ends_with(three->source_qn, "Unique"));
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Unchanged bare names have priority even when the bare title repeats. */
|
|
TEST(doc_links_md_repeated_bare_name_priority) {
|
|
const char *sources[] = {
|
|
"# A.H\n# A.H\n# A\n## H\n# B\n## H\n",
|
|
"# A\n## H\n# B\n## H\n# A.H\n# A.H\n",
|
|
};
|
|
for (size_t k = 0; k < sizeof(sources) / sizeof(sources[0]); k++) {
|
|
CBMFileResult *r = dm_extract(sources[k], CBM_LANG_MARKDOWN, "names.md");
|
|
ASSERT_NOT_NULL(r);
|
|
int bare = 0;
|
|
int qualified = 0;
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
const CBMDefinition *d = &r->defs.items[i];
|
|
if (strcmp(d->name, "A.H") == 0) {
|
|
ASSERT_STR_EQ(d->qualified_name, "p.names.A.H");
|
|
bare++;
|
|
} else if (strcmp(d->name, "H") == 0 &&
|
|
strcmp(d->qualified_name, "p.names.A.H~2") == 0) {
|
|
qualified++;
|
|
}
|
|
}
|
|
ASSERT_EQ(bare, 2);
|
|
ASSERT_EQ(qualified, 1);
|
|
cbm_free_result(r);
|
|
}
|
|
PASS();
|
|
}
|
|
|
|
TEST(doc_links_md_heading_suffix_reservations) {
|
|
CBMFileResult *r = dm_extract("# A.H\n# A.H~2\n# A.H~3\n"
|
|
"# A\n## H\n# B\n## H\n",
|
|
CBM_LANG_MARKDOWN, "names.md");
|
|
ASSERT_NOT_NULL(r);
|
|
int moved = 0;
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
const CBMDefinition *d = &r->defs.items[i];
|
|
if (strcmp(d->name, "A.H~2") == 0)
|
|
ASSERT_STR_EQ(d->qualified_name, "p.names.A.H~2");
|
|
if (strcmp(d->name, "A.H~3") == 0)
|
|
ASSERT_STR_EQ(d->qualified_name, "p.names.A.H~3");
|
|
if (strcmp(d->qualified_name, "p.names.A.H~4") == 0)
|
|
moved++;
|
|
}
|
|
ASSERT_EQ(moved, 1);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* A suffix must also leave room for a parent-qualified name in another run. */
|
|
TEST(doc_links_md_generated_name_reservations) {
|
|
const char *src = "# A.H\n# A\n## H\n## H~2\n# B\n## H\n## H~2\n";
|
|
CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "names.md");
|
|
ASSERT_NOT_NULL(r);
|
|
int moved = 0;
|
|
int reserved = 0;
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
const CBMDefinition *d = &r->defs.items[i];
|
|
if (strcmp(d->qualified_name, "p.names.A.H~3") == 0) {
|
|
ASSERT_STR_EQ(d->name, "H");
|
|
moved++;
|
|
}
|
|
if (strcmp(d->qualified_name, "p.names.A.H~2") == 0) {
|
|
ASSERT_STR_EQ(d->name, "H~2");
|
|
reserved++;
|
|
}
|
|
}
|
|
ASSERT_EQ(moved, 1);
|
|
ASSERT_EQ(reserved, 1);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
TEST(doc_links_md_distinct_chains_same_dotted_name) {
|
|
CBMFileResult *r =
|
|
dm_extract("# A.B\n## C\n# A\n## B\n### C\n### C\n", CBM_LANG_MARKDOWN, "names.md");
|
|
ASSERT_NOT_NULL(r);
|
|
int kept = 0;
|
|
int moved = 0;
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
const CBMDefinition *d = &r->defs.items[i];
|
|
if (strcmp(d->name, "C") != 0)
|
|
continue;
|
|
if (d->start_line == 2) {
|
|
ASSERT_STR_EQ(d->qualified_name, "p.names.A.B.C");
|
|
kept++;
|
|
} else {
|
|
ASSERT_STR_EQ(d->qualified_name, "p.names.A.B.C~2");
|
|
moved++;
|
|
}
|
|
}
|
|
ASSERT_EQ(kept, 1);
|
|
ASSERT_EQ(moved, 2);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
TEST(doc_links_md_setext_parent_names) {
|
|
CBMFileResult *r =
|
|
dm_extract("A\n===\nH\n---\nB\n===\nH\n---\n", CBM_LANG_MARKDOWN, "names.md");
|
|
ASSERT_NOT_NULL(r);
|
|
int children = 0;
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
const CBMDefinition *d = &r->defs.items[i];
|
|
if (strcmp(d->name, "H") == 0) {
|
|
ASSERT_STR_EQ(d->qualified_name, d->start_line == 3 ? "p.names.A.H" : "p.names.B.H");
|
|
children++;
|
|
}
|
|
}
|
|
ASSERT_EQ(children, 2);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* Paragraph continuation markers are not part of a Setext heading title. */
|
|
TEST(doc_links_md_quoted_setext_heading_text) {
|
|
const char *sources[] = {"First\nSecond\n---\n", "> First\n> Second\n> ---\n", "> H\n> ---\n"};
|
|
for (size_t k = 0; k < sizeof(sources) / sizeof(sources[0]); k++) {
|
|
CBMFileResult *r = dm_extract(sources[k], CBM_LANG_MARKDOWN, "names.md");
|
|
ASSERT_NOT_NULL(r);
|
|
int headings = 0;
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
const CBMDefinition *d = &r->defs.items[i];
|
|
if (strcmp(d->label, "Section") != 0)
|
|
continue;
|
|
/* Display names use the first line; the QN must retain every line. */
|
|
ASSERT_STR_EQ(d->name, k == 2 ? "H" : "First");
|
|
ASSERT_STR_EQ(d->qualified_name, k == 2 ? "p.names.H" : "p.names.First-Second");
|
|
headings++;
|
|
}
|
|
ASSERT_EQ(headings, 1);
|
|
cbm_free_result(r);
|
|
}
|
|
PASS();
|
|
}
|
|
|
|
/* Setext and ATX markers share hierarchy; block containers retain their own
|
|
* stacks even when a quote is visited between two outer headings. */
|
|
TEST(doc_links_md_mixed_heading_container_scope) {
|
|
const char *src = "# Outside\n\nH\n---\n\n"
|
|
"> # Quote\n>\n> H\n> ---\n\n"
|
|
"H\n---\n\n# Later\n\nH\n---\n";
|
|
CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "names.md");
|
|
ASSERT_NOT_NULL(r);
|
|
int outside = 0;
|
|
int quote = 0;
|
|
int later = 0;
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
const CBMDefinition *d = &r->defs.items[i];
|
|
if (strcmp(d->name, "H") != 0)
|
|
continue;
|
|
if (strcmp(d->qualified_name, "p.names.Outside.H") == 0)
|
|
outside++;
|
|
if (strcmp(d->qualified_name, "p.names.Quote.H") == 0)
|
|
quote++;
|
|
if (strcmp(d->qualified_name, "p.names.Later.H") == 0)
|
|
later++;
|
|
}
|
|
ASSERT_EQ(outside, 2);
|
|
ASSERT_EQ(quote, 1);
|
|
ASSERT_EQ(later, 1);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* An unnamed H1 still ends A's scope; it must not create an empty Section. */
|
|
TEST(doc_links_md_empty_heading_resets_parent) {
|
|
CBMFileResult *r = dm_extract("# A\n## H\n#\n## H\n", CBM_LANG_MARKDOWN, "names.md");
|
|
ASSERT_NOT_NULL(r);
|
|
int children = 0;
|
|
for (int i = 0; i < r->defs.count; i++) {
|
|
const CBMDefinition *d = &r->defs.items[i];
|
|
if (strcmp(d->label, "Section") == 0) {
|
|
ASSERT_TRUE(d->name && d->name[0]);
|
|
}
|
|
if (strcmp(d->name, "H") == 0) {
|
|
ASSERT_STR_EQ(d->qualified_name, d->start_line == 2 ? "p.names.A.H" : "p.names.H");
|
|
children++;
|
|
}
|
|
}
|
|
ASSERT_EQ(children, 2);
|
|
cbm_free_result(r);
|
|
PASS();
|
|
}
|
|
|
|
/* This measures the collector's actual cursor moves/node visits, excluding
|
|
* hidden parser work. The former ts_node_parent root scans are established by
|
|
* runtime-source review, not retrospectively measured by this counter. */
|
|
static uint64_t md_flat_collection_work(int n) {
|
|
size_t cap = (size_t)n * 32 + 1;
|
|
char *src = malloc(cap);
|
|
if (!src)
|
|
return 0;
|
|
size_t used = 0;
|
|
for (int i = 0; i < n; i++) {
|
|
used += (size_t)snprintf(src + used, cap - used, "# H%d\n", i);
|
|
}
|
|
CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "names.md");
|
|
free(src);
|
|
uint64_t steps = r ? r->markdown_collection_steps : 0;
|
|
cbm_free_result(r);
|
|
return steps;
|
|
}
|
|
|
|
TEST(doc_links_md_flat_collection_work) {
|
|
uint64_t small = md_flat_collection_work(128);
|
|
uint64_t large = md_flat_collection_work(256);
|
|
ASSERT_TRUE(small > 0);
|
|
ASSERT_TRUE(large < small * 3);
|
|
PASS();
|
|
}
|
|
|
|
/* Count name lookup/group checks, never elapsed time. Independent collision groups must
|
|
* not search every heading; a single group must not rescan assigned chains
|
|
* or restart its suffix search at ~2 for each new chain. */
|
|
static uint64_t md_collision_work(int n, bool one_base) {
|
|
size_t cap = (size_t)n * 256 + 1;
|
|
char *src = malloc(cap);
|
|
if (!src)
|
|
return 0;
|
|
size_t used = 0;
|
|
for (int k = 0; k < n; k++) {
|
|
if (!one_base) {
|
|
used += (size_t)snprintf(src + used, cap - used,
|
|
"# P%d\n## H%d\n# Q%d\n## H%d\n# P%d.H%d\n", k, k, k, k, k, k);
|
|
} else {
|
|
/* 276 distinct partitions of 25 dotted components into three
|
|
* parent segments, all with the same final dotted name. */
|
|
int ordinal = 0;
|
|
int first = 1;
|
|
int second = 2;
|
|
for (int i = 1; i < 24; i++) {
|
|
for (int j = i + 1; j < 25; j++) {
|
|
if (ordinal++ == k) {
|
|
first = i;
|
|
second = j;
|
|
}
|
|
}
|
|
}
|
|
used += (size_t)snprintf(src + used, cap - used, "# a");
|
|
for (int i = 1; i < 25; i++) {
|
|
used += (size_t)snprintf(src + used, cap - used,
|
|
i == first ? "\n## a"
|
|
: i == second ? "\n### a"
|
|
: ".a");
|
|
}
|
|
used += (size_t)snprintf(src + used, cap - used, "\n#### H\n");
|
|
}
|
|
}
|
|
CBMFileResult *r = dm_extract(src, CBM_LANG_MARKDOWN, "names.md");
|
|
free(src);
|
|
uint64_t work = r ? r->markdown_name_checks : 0;
|
|
cbm_free_result(r);
|
|
return work;
|
|
}
|
|
|
|
TEST(doc_links_md_independent_collision_work) {
|
|
uint64_t small = md_collision_work(128, false);
|
|
uint64_t large = md_collision_work(256, false);
|
|
ASSERT_TRUE(small > 0);
|
|
ASSERT_TRUE(large < small * 3);
|
|
PASS();
|
|
}
|
|
|
|
TEST(doc_links_md_shared_collision_work) {
|
|
uint64_t small = md_collision_work(128, true);
|
|
uint64_t large = md_collision_work(256, true);
|
|
ASSERT_TRUE(small > 0);
|
|
ASSERT_TRUE(large < small * 3);
|
|
PASS();
|
|
}
|
|
|
|
/* MENTIONS edges from the Section whose qualified name ends with `src` to the
|
|
* node whose qualified name ends with `tgt`. */
|
|
static int rep_edge(const char *db, const char *src, const char *tgt) {
|
|
char props[512];
|
|
int n = 0;
|
|
dm_edge(db, src, tgt, props, sizeof(props), &n);
|
|
return n;
|
|
}
|
|
|
|
/* #2586: two `## Implementation` headings under different parents had one
|
|
* qualified name, so the graph kept one Section (the last) and the references
|
|
* under both went to the File. A repeated heading is now qualified by the
|
|
* headings above it; a unique heading keeps its name alone. */
|
|
TEST(doc_links_md_repeated_headings_by_parent_issue2586) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_rep_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n"
|
|
"\n"
|
|
"## Implementation\n"
|
|
"\n"
|
|
"Uses `src/ledger.py::balance`.\n"
|
|
"\n"
|
|
"# Service B\n"
|
|
"\n"
|
|
"## Implementation\n"
|
|
"\n"
|
|
"Uses `src/ledger.py::post_entry`.\n"
|
|
"\n"
|
|
"```mermaid\n"
|
|
"graph LR\n"
|
|
" Ledger --> Balance\n"
|
|
"```\n"
|
|
"\n"
|
|
"## Notes\n"
|
|
"\n"
|
|
"See `src/ledger.py`.\n");
|
|
th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n"
|
|
" ledger.append(amount)\n"
|
|
" return balance(ledger)\n"
|
|
"\n"
|
|
"\n"
|
|
"def balance(ledger):\n"
|
|
" return sum(ledger)\n");
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/md.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
|
|
/* two Sections, each qualified by its parent heading */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' "
|
|
"AND name = 'Implementation' AND file_path = 'docs/repeated.md'"),
|
|
2);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' "
|
|
"AND qualified_name LIKE '%.repeated.Service-A.Implementation' "
|
|
"AND start_line = 3"),
|
|
1);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' "
|
|
"AND qualified_name LIKE '%.repeated.Service-B.Implementation' "
|
|
"AND start_line = 9"),
|
|
1);
|
|
/* unique headings keep their names */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' AND "
|
|
"(qualified_name LIKE '%.repeated.Service-A' OR "
|
|
"qualified_name LIKE '%.repeated.Notes')"),
|
|
2);
|
|
|
|
/* each reference from its own Section, none from the File */
|
|
ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.balance"), 1);
|
|
ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1);
|
|
ASSERT_EQ(rep_edge(db, "Service-A.Implementation", "ledger.post_entry"), 0);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"WHERE e.type = 'MENTIONS' AND s.label = 'File' "
|
|
"AND s.file_path = 'docs/repeated.md'"),
|
|
0);
|
|
|
|
/* the diagram under the second heading is that Section's */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'DEFINES' "
|
|
"AND t.label = 'Diagram' "
|
|
"AND s.qualified_name LIKE '%.Service-B.Implementation'"),
|
|
1);
|
|
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* #2586 review: a heading text may hold dots, so `# Service-A.Implementation`
|
|
* named the same as the qualified `## Implementation` under `# Service A`.
|
|
* The unique heading keeps its name; the qualified one takes `~2`. Five
|
|
* headings, five Sections, every reference from its own. */
|
|
TEST(doc_links_md_repeated_heading_name_collision_issue2586) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_col_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
th_write_file(TH_PATH(tmp, "docs/repeated.md"), "# Service A\n"
|
|
"\n"
|
|
"## Implementation\n"
|
|
"\n"
|
|
"Uses `src/ledger.py::balance`.\n"
|
|
"\n"
|
|
"# Service B\n"
|
|
"\n"
|
|
"## Implementation\n"
|
|
"\n"
|
|
"Uses `src/ledger.py::post_entry`.\n"
|
|
"\n"
|
|
"# Service-A.Implementation\n"
|
|
"\n"
|
|
"See `src/ledger.py`.\n");
|
|
th_write_file(TH_PATH(tmp, "src/ledger.py"), "def post_entry(ledger, amount):\n"
|
|
" ledger.append(amount)\n"
|
|
" return balance(ledger)\n"
|
|
"\n"
|
|
"\n"
|
|
"def balance(ledger):\n"
|
|
" return sum(ledger)\n");
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/md.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' "
|
|
"AND file_path = 'docs/repeated.md'"),
|
|
5);
|
|
/* the unique heading keeps its name; the qualified one moves aside */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' "
|
|
"AND qualified_name LIKE '%.repeated.Service-A.Implementation' "
|
|
"AND start_line = 13"),
|
|
1);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' "
|
|
"AND qualified_name LIKE '%.repeated.Service-A.Implementation~2' "
|
|
"AND start_line = 3"),
|
|
1);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Section' "
|
|
"AND qualified_name LIKE '%.repeated.Service-B.Implementation' "
|
|
"AND start_line = 9"),
|
|
1);
|
|
|
|
/* each reference from its own Section, none from the File */
|
|
ASSERT_EQ(rep_edge(db, "Service-A.Implementation~2", "ledger.balance"), 1);
|
|
ASSERT_EQ(rep_edge(db, "Service-B.Implementation", "ledger.post_entry"), 1);
|
|
ASSERT_EQ(rep_edge(db, "repeated.Service-A.Implementation", "ledger.py.__file__"), 1);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"WHERE e.type = 'MENTIONS' AND s.label = 'File' "
|
|
"AND s.file_path = 'docs/repeated.md'"),
|
|
0);
|
|
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
typedef struct {
|
|
const char *text;
|
|
CBMDocLinkMdShape shape;
|
|
const char *path;
|
|
const char *member;
|
|
uint32_t first;
|
|
uint32_t last;
|
|
bool instance;
|
|
bool colon;
|
|
} md_case_t;
|
|
|
|
TEST(doc_links_md_classify_span) {
|
|
static const md_case_t cases[] = {
|
|
{"src/app/main.go", CBM_DOCLINK_MD_FILE, "src/app/main.go", NULL, 0, 0, false, false},
|
|
{"src/app/", CBM_DOCLINK_MD_DIR, "src/app/", NULL, 0, 0, false, false},
|
|
{"src/app/main.go#L3-L9", CBM_DOCLINK_MD_FILE, "src/app/main.go", NULL, 3, 9, false, false},
|
|
{"src/app/main.go:12", CBM_DOCLINK_MD_FILE, "src/app/main.go", NULL, 12, 12, false, false},
|
|
{"src/lib.rs::Type::run", CBM_DOCLINK_MD_FILE, "src/lib.rs", "Type::run", 0, 0, false,
|
|
false},
|
|
{"src\\win\\file.cs", CBM_DOCLINK_MD_FILE, "src/win/file.cs", NULL, 0, 0, false, false},
|
|
{"main.go:7-8", CBM_DOCLINK_MD_FILENAME, "main.go", NULL, 7, 8, false, false},
|
|
{"README.md", CBM_DOCLINK_MD_FILENAME, "README.md", NULL, 0, 0, false, false},
|
|
{"Makefile", CBM_DOCLINK_MD_FILENAME, "Makefile", NULL, 0, 0, false, false},
|
|
/* a built or shipped file is a file name, never a member (`OliveTin.exe`
|
|
* took the module stem of OliveTin.exe.manifest in a held-out audit) */
|
|
{"OliveTin.exe", CBM_DOCLINK_MD_FILENAME, "OliveTin.exe", NULL, 0, 0, false, false},
|
|
{"app.manifest", CBM_DOCLINK_MD_FILENAME, "app.manifest", NULL, 0, 0, false, false},
|
|
{"main.rs::run", CBM_DOCLINK_MD_FILENAME, "main.rs", "run", 0, 0, false, false},
|
|
{"fastapi.routing.APIRouter", CBM_DOCLINK_MD_QUALIFIED, "fastapi.routing.APIRouter", NULL,
|
|
0, 0, false, false},
|
|
{"crate::extract::Json", CBM_DOCLINK_MD_QUALIFIED, "extract.Json", NULL, 0, 0, false,
|
|
false},
|
|
{"Vec<u8>::new", CBM_DOCLINK_MD_QUALIFIED, "Vec.new", NULL, 0, 0, false, false},
|
|
{"self.app.state", CBM_DOCLINK_MD_QUALIFIED, "app.state", NULL, 0, 0, true, false},
|
|
{"pkg.module:attr", CBM_DOCLINK_MD_QUALIFIED, "pkg.module.attr", NULL, 0, 0, false, true},
|
|
{"Foo#bar", CBM_DOCLINK_MD_QUALIFIED, "Foo.bar", NULL, 0, 0, false, false},
|
|
{"App\\Http\\Kernel", CBM_DOCLINK_MD_QUALIFIED, "App.Http.Kernel", NULL, 0, 0, false,
|
|
false},
|
|
{"server.New()", CBM_DOCLINK_MD_QUALIFIED, "server.New", NULL, 0, 0, false, false},
|
|
/* no path and no qualified name */
|
|
{"foo", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"self.x", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"Node.js", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"github.com/x/y", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"https://x.org/a.py", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"$HOME/a.py", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"PATH=/usr/bin", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"--verbose", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"go run main.go", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"\"a/b.py\"", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"pkg/Type.Member", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"@scope/pkg", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
/* a traversal or a root names no directory (a held-out link: `../` in a
|
|
* warning about attacker-controlled paths) */
|
|
{"../", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"./", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
{"../..", CBM_DOCLINK_MD_NONE, NULL, NULL, 0, 0, false, false},
|
|
};
|
|
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
|
const md_case_t *c = &cases[i];
|
|
char buf[256];
|
|
CBMDocLinkMdPath p;
|
|
bool ok = cbm_doclink_md_classify_span(c->text, strlen(c->text), buf, sizeof(buf), &p);
|
|
if (ok != (c->shape != CBM_DOCLINK_MD_NONE) || p.shape != c->shape) {
|
|
printf(" case %s: shape %d, expected %d\n", c->text, (int)p.shape, (int)c->shape);
|
|
FAIL("classification differs");
|
|
}
|
|
if (!ok) {
|
|
continue;
|
|
}
|
|
if (strcmp(p.path, c->path) != 0 ||
|
|
(c->member ? !p.member || strcmp(p.member, c->member) != 0 : p.member != NULL) ||
|
|
p.first_line != c->first || p.last_line != c->last || p.instance != c->instance ||
|
|
p.colon != c->colon) {
|
|
printf(" case %s: path %s member %s lines %u-%u instance %d colon %d\n", c->text,
|
|
p.path, p.member ? p.member : "(none)", p.first_line, p.last_line, p.instance,
|
|
p.colon);
|
|
FAIL("classification differs");
|
|
}
|
|
}
|
|
/* a name never fits a buffer by being cut */
|
|
char tiny[8];
|
|
CBMDocLinkMdPath p;
|
|
ASSERT_FALSE(cbm_doclink_md_classify_span("src/app/main.go", 15, tiny, sizeof(tiny), &p));
|
|
PASS();
|
|
}
|
|
|
|
/* ── resolution ──────────────────────────────────────────────────── */
|
|
|
|
/* Properties of the MENTIONS edge from the section or file whose qualified
|
|
* name ends with `src` to the node whose qualified name ends with `tgt`. */
|
|
static int md_edge(const char *db, const char *src, const char *tgt, char *props, size_t cap) {
|
|
int n = 0;
|
|
dm_edge(db, src, tgt, props, cap, &n);
|
|
return n;
|
|
}
|
|
|
|
TEST(doc_links_md_resolve) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
md_write_fixture(tmp);
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/md.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
char props[512];
|
|
char reason[64];
|
|
char syntax[64];
|
|
|
|
/* a link and a link reference definition: their files */
|
|
ASSERT_EQ(md_edge(db, "Guide", "config.go.__file__", props, sizeof(props)), 1);
|
|
ASSERT_NOT_NULL(strstr(props, "\"via\":\"markdown\""));
|
|
ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"link\""));
|
|
ASSERT_NOT_NULL(strstr(props, "\"tier\":\"exact\""));
|
|
ASSERT_NOT_NULL(strstr(props, "\"line\":5"));
|
|
ASSERT_EQ(md_edge(db, "Guide", "routing.py.__file__", props, sizeof(props)), 1);
|
|
/* the reference before the first heading: from the File node */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND s.label = 'File' AND s.file_path = 'docs/guide.md' "
|
|
"AND t.file_path = 'pkg/config.go' AND t.label = 'File'"),
|
|
1);
|
|
/* a range held by one definition: that definition, the range on the edge */
|
|
ASSERT_EQ(md_edge(db, "Paths", "APIRouter.add", props, sizeof(props)), 1);
|
|
ASSERT_NOT_NULL(strstr(props, "\"target_lines\":[4,5]"));
|
|
ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"code_path\""));
|
|
/* a range across definitions: the file, with the range */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND s.qualified_name LIKE '%.Paths' AND t.label = 'File' "
|
|
"AND t.file_path = 'app/routing.py' "
|
|
"AND e.properties LIKE '%\"target_lines\":[1,9]%'"),
|
|
1);
|
|
/* a bare path in prose; a directory */
|
|
ASSERT_EQ(md_edge(db, "Paths", "applications.py.__file__", props, sizeof(props)), 1);
|
|
ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"path\""));
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND s.qualified_name LIKE '%.Paths' AND t.label = 'Folder' "
|
|
"AND t.file_path = 'app/middleware'"),
|
|
1);
|
|
/* a route, an image, a URL: neither edges nor rows */
|
|
dm_row(db, "docs/guide.md", "/docs", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "");
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes t ON t.id = e.target_id "
|
|
"WHERE e.type = 'MENTIONS' AND t.label = 'Folder' "
|
|
"AND t.file_path = 'docs'"),
|
|
0);
|
|
dm_row(db, "docs/guide.md", "img/a.png", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "");
|
|
|
|
/* qualified names: a method, a field through its type */
|
|
ASSERT_EQ(md_edge(db, "Names", "routing.APIRouter.add", props, sizeof(props)), 1);
|
|
ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"code_name\""));
|
|
ASSERT_EQ(md_edge(db, "Names", "Config.Name", props, sizeof(props)), 1);
|
|
/* `app.middleware`: the package as written, not the method App.middleware */
|
|
ASSERT_EQ(md_edge(db, "Names", "App.middleware", props, sizeof(props)), 0);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND s.qualified_name LIKE '%.Names' "
|
|
"AND t.file_path = 'app/middleware/__init__.py'"),
|
|
1);
|
|
/* a field never through an instance */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes t ON t.id = e.target_id "
|
|
"WHERE e.type = 'MENTIONS' AND t.label = 'Field' "
|
|
"AND e.properties LIKE '%\"line\":23%'"),
|
|
0);
|
|
|
|
/* unresolved, by reason */
|
|
dm_row(db, "docs/guide.md", "app.Nope", reason, sizeof(reason), syntax, sizeof(syntax));
|
|
ASSERT_STR_EQ(reason, "missing");
|
|
ASSERT_STR_EQ(syntax, "code_name");
|
|
dm_row(db, "docs/guide.md", "test_routing.only_in_tests", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "test_only_target");
|
|
dm_row(db, "docs/guide.md", "main.py", reason, sizeof(reason), syntax, sizeof(syntax));
|
|
ASSERT_STR_EQ(reason, "ambiguous"); /* lib/main.py, but not next to the document */
|
|
ASSERT_STR_EQ(syntax, "bare_path");
|
|
dm_row(db, "docs/guide.md", "../app/gone.py", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "missing");
|
|
dm_row(db, "docs/guide.md", "app/html.py", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "missing");
|
|
/* another project's code is no reference to this repository */
|
|
dm_row(db, "docs/guide.md", "os.path.join", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "");
|
|
|
|
/* back navigation: the sections that mention a method, one hop away */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND t.qualified_name LIKE '%.APIRouter.add' AND s.label = 'Section'"),
|
|
2);
|
|
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* #2587: a `path::member` reference whose member the code file does not
|
|
* define was an exact edge to the file, so a renamed or deleted definition
|
|
* looked like a valid, coarser link. It is a missing reference; a member the
|
|
* file defines more than once, with no owner to pick one, is ambiguous. */
|
|
TEST(doc_links_md_member_not_defined_issue2587) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
th_write_file(TH_PATH(tmp, "docs/members.md"),
|
|
"# Members\n"
|
|
"\n"
|
|
"See `src/util/rounding.py::clamp` and "
|
|
"`src/util/rounding.py::no_such_function`.\n"
|
|
"Areas: `src/shapes.py::Square::area`, `src/shapes.py::area`.\n"
|
|
"Notes: `docs/other.md::Intro`.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/other.md"), "# Intro\n\nText.\n");
|
|
th_write_file(TH_PATH(tmp, "src/util/rounding.py"), "def clamp(x, lo, hi):\n"
|
|
" return max(lo, min(hi, x))\n");
|
|
th_write_file(TH_PATH(tmp, "src/shapes.py"), "class Circle:\n"
|
|
" def area(self):\n"
|
|
" return 3\n"
|
|
"\n"
|
|
"\n"
|
|
"class Square:\n"
|
|
" def area(self):\n"
|
|
" return 4\n");
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/md.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
char props[512];
|
|
char reason[64];
|
|
|
|
/* a member the file defines, alone or through its owner */
|
|
ASSERT_EQ(md_edge(db, "Members", "rounding.clamp", props, sizeof(props)), 1);
|
|
ASSERT_EQ(md_edge(db, "Members", "Square.area", props, sizeof(props)), 1);
|
|
ASSERT_EQ(md_edge(db, "Members", "Circle.area", props, sizeof(props)), 0);
|
|
|
|
/* a member the file does not define: no edge to the file, a missing row */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND s.qualified_name LIKE '%.Members' AND t.label = 'File' "
|
|
"AND t.file_path = 'src/util/rounding.py'"),
|
|
0);
|
|
dm_row(db, "docs/members.md", "src/util/rounding.py::no_such_function", reason, sizeof(reason),
|
|
NULL, 0);
|
|
ASSERT_STR_EQ(reason, "missing");
|
|
|
|
/* defined twice and no owner written: ambiguous, not the file */
|
|
dm_row(db, "docs/members.md", "src/shapes.py::area", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "ambiguous");
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND s.qualified_name LIKE '%.Members' AND t.label = 'File' "
|
|
"AND t.file_path = 'src/shapes.py'"),
|
|
0);
|
|
|
|
/* a document's headings are no definitions: the file edge stays */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND s.qualified_name LIKE '%.Members' AND t.label = 'File' "
|
|
"AND t.file_path = 'docs/other.md'"),
|
|
1);
|
|
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* MENTIONS edges from the section whose qualified name ends with `section` to
|
|
* the File node of `file`. */
|
|
static int md_file_edges(const char *db, const char *section, const char *file) {
|
|
char sql[512];
|
|
snprintf(sql, sizeof(sql),
|
|
"SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND s.qualified_name LIKE '%%.%s' AND t.label = 'File' AND t.file_path = '%s'",
|
|
section, file);
|
|
return dm_count(db, sql);
|
|
}
|
|
|
|
/* #2587, the members a code file has although the graph defines no node for
|
|
* them: a call form, a macro, a prototype, a namespace, an enum member, a
|
|
* class attribute. Each is there -- the file writes it -- so it keeps its
|
|
* edge; only a name the file does not write at all is missing. */
|
|
static const struct {
|
|
const char *section;
|
|
const char *raw;
|
|
const char *file;
|
|
bool missing;
|
|
} MD_WRITTEN[] = {
|
|
{"CallForm", "src/util.py::clamp()", NULL, false},
|
|
{"Macro", "include/limits.h::MAX_ITEMS", "include/limits.h", false},
|
|
{"FunctionMacro", "include/limits.h::SQUARE", "include/limits.h", false},
|
|
{"Prototype", "include/limits.h::limits_init", "include/limits.h", false},
|
|
{"Namespace", "src/ns.ts::Shapes", "src/ns.ts", false},
|
|
{"EnumMember", "src/color.py::Color::RED", "src/color.py", false},
|
|
{"Attribute", "src/model.py::Model::name", "src/model.py", false},
|
|
/* not written: `blue` and LIGHT_BLUE are no BLUE */
|
|
{"NotWritten", "src/color.py::Color::BLUE", "src/color.py", true},
|
|
{"NotDefined", "src/util.py::no_such_function", "src/util.py", true},
|
|
};
|
|
|
|
TEST(doc_links_md_member_written_issue2587) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
char doc[2048] = "";
|
|
size_t used = 0;
|
|
for (size_t i = 0; i < sizeof(MD_WRITTEN) / sizeof(MD_WRITTEN[0]); i++) {
|
|
used += (size_t)snprintf(doc + used, sizeof(doc) - used, "# %s\n\nSee `%s`.\n\n",
|
|
MD_WRITTEN[i].section, MD_WRITTEN[i].raw);
|
|
ASSERT(used < sizeof(doc));
|
|
}
|
|
th_write_file(TH_PATH(tmp, "docs/written.md"), doc);
|
|
th_write_file(TH_PATH(tmp, "src/util.py"), "def clamp(x, lo, hi):\n"
|
|
" return max(lo, min(hi, x))\n");
|
|
th_write_file(TH_PATH(tmp, "include/limits.h"), "#ifndef LIMITS_H\n"
|
|
"#define LIMITS_H\n"
|
|
"#define MAX_ITEMS 64\n"
|
|
"#define SQUARE(x) ((x) * (x))\n"
|
|
"int limits_init(void);\n"
|
|
"#endif\n");
|
|
th_write_file(TH_PATH(tmp, "src/ns.ts"), "export namespace Shapes {\n"
|
|
" export const unit = 1;\n"
|
|
"}\n");
|
|
th_write_file(TH_PATH(tmp, "src/color.py"), "from enum import Enum\n"
|
|
"\n"
|
|
"\n"
|
|
"class Color(Enum):\n"
|
|
" RED = 1\n"
|
|
" LIGHT_BLUE = 2 # blue\n");
|
|
th_write_file(TH_PATH(tmp, "src/model.py"), "class Model:\n"
|
|
" name: str\n");
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/md.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
char props[512];
|
|
char reason[64];
|
|
|
|
/* every reference, under its own heading: an edge, or a missing row */
|
|
int wrong = 0;
|
|
for (size_t i = 0; i < sizeof(MD_WRITTEN) / sizeof(MD_WRITTEN[0]); i++) {
|
|
dm_row(db, "docs/written.md", MD_WRITTEN[i].raw, reason, sizeof(reason), NULL, 0);
|
|
int edges = MD_WRITTEN[i].file
|
|
? md_file_edges(db, MD_WRITTEN[i].section, MD_WRITTEN[i].file)
|
|
: dm_mentions_from(db, MD_WRITTEN[i].section);
|
|
bool ok = MD_WRITTEN[i].missing ? strcmp(reason, "missing") == 0 && edges == 0
|
|
: reason[0] == '\0' && edges >= 1;
|
|
if (!ok) {
|
|
printf(" %s: row '%s', %d edge(s)\n", MD_WRITTEN[i].raw, reason, edges);
|
|
wrong++;
|
|
}
|
|
}
|
|
ASSERT_EQ(wrong, 0);
|
|
/* the call form binds the function itself */
|
|
ASSERT_EQ(md_edge(db, "CallForm", "util.clamp", props, sizeof(props)), 1);
|
|
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* ── incremental == full ─────────────────────────────────────────── */
|
|
|
|
TEST(doc_links_md_incremental) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_inc_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
char repo[512];
|
|
snprintf(repo, sizeof(repo), "%s/repo", tmp);
|
|
md_write_fixture(repo);
|
|
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);
|
|
|
|
/* a body edit of a linked code file: its linking document re-resolves */
|
|
th_write_file(TH_PATH(repo, "app/routing.py"), "class APIRouter:\n"
|
|
" prefix = ''\n"
|
|
"\n"
|
|
" def add(self, p):\n"
|
|
" return p + p\n"
|
|
"\n"
|
|
"\n"
|
|
"def helper():\n"
|
|
" return 2\n");
|
|
ASSERT_EQ(dm_step(repo, db, full_db, "code body edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR),
|
|
0);
|
|
/* the document itself changes: a new link, a reference gone */
|
|
char guide[sizeof(MD_GUIDE) + 64];
|
|
snprintf(guide, sizeof(guide), "%s\nSee also [helpers](../app/routing.py#L8-L9).\n", MD_GUIDE);
|
|
th_write_file(TH_PATH(repo, "docs/guide.md"), guide);
|
|
ASSERT_EQ(dm_step(repo, db, full_db, "document edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0);
|
|
/* a body edit that moves lines: the document bound by lines (#L8-L9, the
|
|
* helper until now) re-resolves, though no name changed */
|
|
th_write_file(TH_PATH(repo, "app/routing.py"), "# moved\n"
|
|
"# down\n"
|
|
"class APIRouter:\n"
|
|
" prefix = ''\n"
|
|
"\n"
|
|
" def add(self, p):\n"
|
|
" return p + p\n"
|
|
"\n"
|
|
"\n"
|
|
"def helper():\n"
|
|
" return 2\n");
|
|
ASSERT_EQ(dm_step(repo, db, full_db, "lines moved", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0);
|
|
/* the name a row calls missing is added: the run goes FULL and the row
|
|
* becomes an edge, exactly as a full index has it */
|
|
th_write_file(TH_PATH(repo, "app/nope.py"), "def Nope():\n"
|
|
" return None\n");
|
|
ASSERT_EQ(dm_step(repo, db, full_db, "missing name added", CBM_INCREMENTAL_ROUTE_FORCED_FULL),
|
|
0);
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* #2587 incremental == full: whether a `path::member` row is missing depends
|
|
* on the member file's text, and the row leads to that file by no edge. A
|
|
* body edit that writes the name (no definition added) turns the row into a
|
|
* file edge, one that drops it turns the edge back into the row; a same-named
|
|
* group that shrinks to one turns an ambiguous row into an edge. The group is
|
|
* Ruby: a Python file whose surface changes takes the full route (the Python
|
|
* namespace admission), and a language with a doc-link scope (Rust, Go, ...)
|
|
* reports the lost method as a removed name, so neither reaches the rule for
|
|
* ambiguous member rows. */
|
|
TEST(doc_links_md_member_missing_incremental) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_inc_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
char repo[512];
|
|
snprintf(repo, sizeof(repo), "%s/repo", tmp);
|
|
th_write_file(TH_PATH(repo, "docs/helpers.md"), "# Helpers\n\nSee `src/tools.py::helper2`.\n");
|
|
th_write_file(TH_PATH(repo, "docs/areas.md"), "# Areas\n\nSee `src/shapes.rb::area`.\n");
|
|
th_write_file(TH_PATH(repo, "src/tools.py"), "def helper():\n"
|
|
" return 1\n");
|
|
th_write_file(TH_PATH(repo, "src/shapes.rb"), "class Circle\n"
|
|
" def area\n"
|
|
" 3\n"
|
|
" end\n"
|
|
"end\n"
|
|
"\n"
|
|
"class Square\n"
|
|
" def area\n"
|
|
" 4\n"
|
|
" end\n"
|
|
"end\n");
|
|
char db[512];
|
|
char full_db[512];
|
|
snprintf(db, sizeof(db), "%s/inc.db", tmp);
|
|
snprintf(full_db, sizeof(full_db), "%s/full.db", tmp);
|
|
ASSERT_EQ(dm_index(repo, db, NULL), 0);
|
|
char reason[64];
|
|
dm_row(db, "docs/helpers.md", "src/tools.py::helper2", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "missing");
|
|
dm_row(db, "docs/areas.md", "src/shapes.rb::area", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "ambiguous");
|
|
|
|
/* the body writes the name, no definition added: the row becomes an edge */
|
|
th_write_file(TH_PATH(repo, "src/tools.py"), "def helper():\n"
|
|
" return 1 # helper2 follows\n");
|
|
ASSERT_EQ(dm_step(repo, db, full_db, "member written", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR),
|
|
0);
|
|
ASSERT_EQ(md_file_edges(db, "Helpers", "src/tools.py"), 1);
|
|
/* and drops it again: missing again */
|
|
th_write_file(TH_PATH(repo, "src/tools.py"), "def helper():\n"
|
|
" return 1\n");
|
|
ASSERT_EQ(dm_step(repo, db, full_db, "member dropped", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR),
|
|
0);
|
|
dm_row(db, "docs/helpers.md", "src/tools.py::helper2", reason, sizeof(reason), NULL, 0);
|
|
ASSERT_STR_EQ(reason, "missing");
|
|
/* one of two `area` methods goes, the name stays: the one left binds */
|
|
th_write_file(TH_PATH(repo, "src/shapes.rb"), "class Circle\n"
|
|
"end\n"
|
|
"\n"
|
|
"class Square\n"
|
|
" def area\n"
|
|
" 4\n"
|
|
" end\n"
|
|
"end\n");
|
|
ASSERT_EQ(dm_step(repo, db, full_db, "group shrinks", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0);
|
|
char props[512];
|
|
ASSERT_EQ(md_edge(db, "Areas", "Square.area", props, sizeof(props)), 1);
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* #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) {
|
|
char first[1024];
|
|
snprintf(first, sizeof(first),
|
|
"---\n"
|
|
"status: %s\n"
|
|
"date: 2024-03-05\n"
|
|
"deciders: Ana, Bo\n"
|
|
"---\n"
|
|
"# Use X for storage\n"
|
|
"\n"
|
|
"## Context and Problem Statement\n"
|
|
"\n"
|
|
"We need storage.\n"
|
|
"\n"
|
|
"## Decision Outcome\n"
|
|
"\n"
|
|
"We use X, see `app/routing.py`.\n"
|
|
"\n"
|
|
"Superseded by [ADR-0002](0002-use-y.md).\n",
|
|
first_status);
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0001-use-x.md"), first);
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0002-use-y.md"), "# 2. Use Y instead\n"
|
|
"\n"
|
|
"Date: 12 May 2024\n"
|
|
"\n"
|
|
"## Status\n"
|
|
"\n"
|
|
"Accepted\n"
|
|
"\n"
|
|
"Supersedes [ADR-0001](0001-use-x.md).\n"
|
|
"\n"
|
|
"## Context\n"
|
|
"\n"
|
|
"X was slow.\n"
|
|
"\n"
|
|
"## Decision\n"
|
|
"\n"
|
|
"We use Y.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0003-cache.md"),
|
|
"# Cache reads\n"
|
|
"\n"
|
|
"## Status\n"
|
|
"\n"
|
|
"Proposed\n"
|
|
"\n"
|
|
"- Replaces ADR-2\n"
|
|
"\n"
|
|
"## Decision\n"
|
|
"\n"
|
|
"The cache replaces the reader described in [ADR-1](0001-use-x.md).\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/README.md"), "# Decisions\n\n## Status\n\nAccepted\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/template.md"),
|
|
"# Title\n\n## Status\n\n{proposed | accepted}\n\n## Decision\n\nTBD\n");
|
|
}
|
|
|
|
/* Properties of the ADR node of `file`; "" when there is none. */
|
|
static void md_adr_props(const char *db, const char *file, char *out, size_t cap) {
|
|
out[0] = '\0';
|
|
sqlite3 *h = NULL;
|
|
if (sqlite3_open_v2(db, &h, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK) {
|
|
sqlite3_stmt *st = NULL;
|
|
if (sqlite3_prepare_v2(h,
|
|
"SELECT name || ' ' || properties FROM nodes WHERE label = 'ADR' "
|
|
"AND file_path = ?1",
|
|
-1, &st, NULL) == SQLITE_OK) {
|
|
sqlite3_bind_text(st, 1, file, -1, SQLITE_TRANSIENT);
|
|
if (sqlite3_step(st) == SQLITE_ROW) {
|
|
snprintf(out, cap, "%s", (const char *)sqlite3_column_text(st, 0));
|
|
}
|
|
}
|
|
sqlite3_finalize(st);
|
|
}
|
|
sqlite3_close(h);
|
|
}
|
|
|
|
TEST(doc_links_md_adr) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmd_adr_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
md_write_fixture(tmp);
|
|
md_write_adrs(tmp, "superseded");
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/adr.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
char props[2048];
|
|
|
|
/* three records; README and template are none */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'ADR'"), 3);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes t ON t.id = e.target_id "
|
|
"WHERE e.type = 'DEFINES' AND t.label = 'ADR'"),
|
|
3);
|
|
/* facts: front matter */
|
|
md_adr_props(db, "docs/adr/0001-use-x.md", props, sizeof(props));
|
|
ASSERT_TRUE(strncmp(props, "ADR-1 ", strlen("ADR-1 ")) == 0);
|
|
ASSERT_NOT_NULL(strstr(props, "\"status\":\"superseded\""));
|
|
ASSERT_NOT_NULL(strstr(props, "\"date\":\"2024-03-05\""));
|
|
ASSERT_NOT_NULL(strstr(props, "\"deciders\":\"Ana, Bo\""));
|
|
ASSERT_NOT_NULL(strstr(props, "\"title\":\"Use X for storage\""));
|
|
ASSERT_NOT_NULL(strstr(props, "\"superseded_by\":\"0002-use-y.md\""));
|
|
ASSERT_NOT_NULL(strstr(props, "We use X")); /* the decision, searchable */
|
|
/* facts: header field date, Status section */
|
|
md_adr_props(db, "docs/adr/0002-use-y.md", props, sizeof(props));
|
|
ASSERT_TRUE(strncmp(props, "ADR-2 ", strlen("ADR-2 ")) == 0);
|
|
ASSERT_NOT_NULL(strstr(props, "\"status\":\"accepted\""));
|
|
ASSERT_NOT_NULL(strstr(props, "\"date\":\"2024-05-12\""));
|
|
md_adr_props(db, "docs/adr/README.md", props, sizeof(props));
|
|
ASSERT_STR_EQ(props, "");
|
|
|
|
/* SUPERSEDES: by link, and by id at a line head; never from prose that
|
|
* uses "replaces" as a verb, never from the superseded record */
|
|
const char *sup = "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'SUPERSEDES' "
|
|
"AND s.file_path = '%s' AND t.file_path = '%s'";
|
|
char q[512];
|
|
snprintf(q, sizeof(q), sup, "docs/adr/0002-use-y.md", "docs/adr/0001-use-x.md");
|
|
ASSERT_EQ(dm_count(db, q), 1);
|
|
snprintf(q, sizeof(q), sup, "docs/adr/0003-cache.md", "docs/adr/0002-use-y.md");
|
|
ASSERT_EQ(dm_count(db, q), 1);
|
|
snprintf(q, sizeof(q), sup, "docs/adr/0003-cache.md", "docs/adr/0001-use-x.md");
|
|
ASSERT_EQ(dm_count(db, q), 0);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges WHERE type = 'SUPERSEDES'"), 2);
|
|
/* the record's sections still link to code */
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'MENTIONS' "
|
|
"AND s.file_path = 'docs/adr/0001-use-x.md' "
|
|
"AND t.file_path = 'app/routing.py'"),
|
|
1);
|
|
|
|
/* incremental == full: a status edit, then a new record that a statement
|
|
* names by id */
|
|
char full_db[512];
|
|
snprintf(full_db, sizeof(full_db), "%s/adr-full.db", tmp);
|
|
md_write_adrs(tmp, "deprecated");
|
|
ASSERT_EQ(dm_step(tmp, db, full_db, "ADR status edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR),
|
|
0);
|
|
md_adr_props(db, "docs/adr/0001-use-x.md", props, sizeof(props));
|
|
ASSERT_NOT_NULL(strstr(props, "\"status\":\"deprecated\""));
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0004-later.md"), "# Later\n\n## Status\n\nAccepted\n\n"
|
|
"Supersedes ADR-3.\n\n## Decision\n\n"
|
|
"Later.\n");
|
|
ASSERT_EQ(dm_step(tmp, db, full_db, "ADR added", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0);
|
|
snprintf(q, sizeof(q), sup, "docs/adr/0004-later.md", "docs/adr/0003-cache.md");
|
|
ASSERT_EQ(dm_count(db, q), 1);
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* The production gate (doclink.h): families held back by the held-out audit
|
|
* write rows, not edges; the families that passed still write edges. */
|
|
static int md_supersedes_from(const char *db, const char *file, const char *target);
|
|
|
|
TEST(doc_links_md_ship_gate) {
|
|
for (size_t i = 0; i < sizeof(DM_HELD_FAMILIES) / sizeof(DM_HELD_FAMILIES[0]); i++) {
|
|
ASSERT_FALSE(cbm_doclink_syntax_ships(DM_HELD_FAMILIES[i]));
|
|
}
|
|
ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_MD_LINK));
|
|
/* the doc-comment families whose held-out tiers passed (2026-10-11) */
|
|
static const int passed[] = {
|
|
CBM_DOCLINK_GO_DOC_LINK, CBM_DOCLINK_JAVA_LINK, CBM_DOCLINK_JAVA_LINKPLAIN,
|
|
CBM_DOCLINK_JAVA_SEE, CBM_DOCLINK_JAVA_THROWS, CBM_DOCLINK_JAVA_VALUE,
|
|
CBM_DOCLINK_KT_KDOC_LINK, CBM_DOCLINK_KT_SEE, CBM_DOCLINK_KT_THROWS,
|
|
CBM_DOCLINK_KT_SAMPLE, CBM_DOCLINK_RS_INTRA_DOC, CBM_DOCLINK_RS_LINK_DEF,
|
|
CBM_DOCLINK_PERL_POD_L, CBM_DOCLINK_TS_LINK, CBM_DOCLINK_PHP_SEE,
|
|
CBM_DOCLINK_C_REF, CBM_DOCLINK_C_SEE,
|
|
};
|
|
for (size_t i = 0; i < sizeof(passed) / sizeof(passed[0]); i++) {
|
|
ASSERT_TRUE(cbm_doclink_syntax_ships(passed[i]));
|
|
}
|
|
ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_RST_ROLE));
|
|
ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_ADOC_INCLUDE));
|
|
ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_RST_LITERALINCLUDE));
|
|
ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_RST_CODE_PATH));
|
|
ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_MD_CODE_PATH));
|
|
ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_MD_SUPERSEDES));
|
|
ASSERT_TRUE(cbm_doclink_syntax_ships(CBM_DOCLINK_RST_OBJECT));
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmdgate_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
md_write_fixture(tmp);
|
|
/* a file name alone, next to its file: it resolves, and its tier holds it */
|
|
th_write_file(TH_PATH(tmp, "pkg/README.md"), "# Pkg\n\nSee `config.go`.\n");
|
|
/* an ADR's statement ships as SUPERSEDES; the same words in prose are held */
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0001-base.md"),
|
|
"# Base\n\n## Status\n\nSuperseded\n\n## Decision\n\nWe use A.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0002-next.md"),
|
|
"# Next\n\n## Status\n\nAccepted\n\nSupersedes [ADR-0001](0001-base.md)\n\n"
|
|
"## Decision\n\nWe use B.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0003-last.md"),
|
|
"# Last\n\n## Status\n\nAccepted\n\n## Decision\n\n"
|
|
"This record also supersedes ADR-0001 for the cache.\n");
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/md.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
char props[512];
|
|
ASSERT_EQ(md_edge(db, "Guide", "config.go.__file__", props, sizeof(props)), 1);
|
|
ASSERT_EQ(md_edge(db, "Paths", "APIRouter.add", props, sizeof(props)), 1);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE "
|
|
"reason = 'below_bar_tier' AND syntax = 'code_path'"),
|
|
0);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE "
|
|
"reason = 'below_bar_tier' AND syntax = 'bare_path' "
|
|
"AND rel_path = 'pkg/README.md'"),
|
|
1);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges WHERE type = 'MENTIONS' AND "
|
|
"properties LIKE '%\"syntax\":\"bare_path\"%'"),
|
|
0);
|
|
ASSERT_EQ(md_supersedes_from(db, "0002-next.md", "0001-base.md"), 1);
|
|
ASSERT_EQ(md_supersedes_from(db, "0003-last.md", "%"), 0);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE "
|
|
"reason = 'below_bar_tier' AND syntax = 'supersedes_prose' "
|
|
"AND rel_path = 'docs/adr/0003-last.md'"),
|
|
1);
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* A held-out finding: a spec template's combined field `Supersedes / Depends
|
|
* on: [Spec 2](...)` states no supersession (the record depended on the one it
|
|
* named); the plain statement next to it still does. */
|
|
TEST(doc_links_md_adr_label_choice) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dladrl_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0001-base.md"),
|
|
"# Base\n\n## Status\n\nAccepted\n\n## Decision\n\nWe use A.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0002-next.md"),
|
|
"# Next\n\n## Status\n\nAccepted\n\n"
|
|
"Supersedes / Depends on: [ADR-0001](0001-base.md)\n\n"
|
|
"## Decision\n\nWe add B.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0003-last.md"), "# Last\n\n## Status\n\nAccepted\n\n"
|
|
"Supersedes [ADR-0001](0001-base.md).\n\n"
|
|
"## Decision\n\nWe use C.\n");
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/adr.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"WHERE e.type = 'SUPERSEDES' AND s.file_path = 'docs/adr/0002-next.md'"),
|
|
0);
|
|
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"WHERE e.type = 'SUPERSEDES' AND s.file_path = 'docs/adr/0003-last.md'"),
|
|
1);
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
static int md_supersedes_from(const char *db, const char *file, const char *target) {
|
|
char sql[512];
|
|
snprintf(sql, sizeof(sql),
|
|
"SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"JOIN nodes t ON t.id = e.target_id WHERE e.type = 'SUPERSEDES' "
|
|
"AND s.file_path = 'docs/adr/%s' AND t.file_path LIKE 'docs/adr/%s'",
|
|
file, target);
|
|
return dm_count(db, sql);
|
|
}
|
|
|
|
static int md_supersedes_syntax(const char *db, const char *file, const char *syntax) {
|
|
char sql[512];
|
|
snprintf(sql, sizeof(sql),
|
|
"SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id = e.source_id "
|
|
"WHERE e.type = 'SUPERSEDES' AND s.file_path = 'docs/adr/%s' "
|
|
"AND e.properties LIKE '%%\"syntax\":\"%s\"%%'",
|
|
file, syntax);
|
|
return dm_count(db, sql);
|
|
}
|
|
|
|
/* Held-out findings: a field whose value is "none" goes on to name records it
|
|
* extends; a record reports another record's supersession (`ADR 0002
|
|
* supersedes ...`, a table cell `0002 (supersedes ...)`); the record after a
|
|
* statement's full stop is no target. The record's own number as the subject
|
|
* is its own statement. */
|
|
TEST(doc_links_md_adr_reported) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dladrr_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0001-base.md"),
|
|
"# Base\n\n## Status\n\nAccepted\n\n## Decision\n\nWe use A.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0002-next.md"),
|
|
"# Next\n\n## Status\n\nAccepted\n\n## Decision\n\nWe use B.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0003-none.md"),
|
|
"# None\n\n- Status: Accepted\n"
|
|
"- Supersedes: none (it extends [ADR 0001](0001-base.md))\n\n"
|
|
"## Decision\n\nWe add C.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0004-na.md"),
|
|
"# NA\n\n- Status: Accepted\n- Supersedes: **N/A** -- refines ADR-0002\n\n"
|
|
"## Decision\n\nWe add D.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0005-report.md"),
|
|
"# Report\n\n## Status\n\nAccepted\n\n## Context\n\n"
|
|
"ADR 0002 supersedes ADR 0001's first decision.\n\n"
|
|
"| Row | Owner |\n|---|---|\n| retries | 0002 (supersedes ADR 0001's rule) |\n\n"
|
|
"[ADR 0002](0002-next.md) supersedes ADR 0001 too.\n\n"
|
|
"## Decision\n\nWe audit.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0006-own.md"),
|
|
"# Own\n\n## Status\n\nAccepted\n\n## Decision\n\n"
|
|
"ADR 0006 supersedes ADR 0001.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0007-stop.md"),
|
|
"# Stop\n\n## Status\n\nAccepted\n\n## Decision\n\n"
|
|
"> This record supersedes ADR-0001. ADR 0002 is an orthogonal decision.\n");
|
|
/* adr-tools' own form: the link text holds the record's number and title */
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0008-tools.md"),
|
|
"# 8. Tools\n\n## Status\n\nAccepted\n\n"
|
|
"Supersedes [2. Next](0002-next.md)\n\n## Decision\n\nWe use E.\n");
|
|
/* statements in a field table and a bold field */
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0009-table.md"),
|
|
"# Table\n\n| Field | Value |\n|---|---|\n| Status | Accepted |\n"
|
|
"| Supersedes | [ADR 0001](0001-base.md) |\n\n## Decision\n\nWe use F.\n");
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0010-field.md"),
|
|
"# Field\n\n- Status: Accepted\n- **Supersedes:** ADR-0002\n\n"
|
|
"## Decision\n\nWe use G.\n");
|
|
/* a paragraph wrapped before the phrase: its subject is the line before */
|
|
th_write_file(
|
|
TH_PATH(tmp, "docs/adr/0011-wrap.md"),
|
|
"# Wrap\n\n## Status\n\nAccepted\n\n## Context\n\nThe sealed store of\n"
|
|
"[ADR 0002](0002-next.md)\nsupersedes ADR-0001 and leaves this record standing.\n\n"
|
|
"## Decision\n\nWe use I.\n");
|
|
/* field lines one under the other: each its own statement */
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0012-fields.md"),
|
|
"# Fields\n\nStatus: Accepted\nSupersedes [ADR-0001](0001-base.md)\n\n"
|
|
"## Decision\n\nWe use J.\n");
|
|
/* a `Supersedes:` field right under running text is still a field */
|
|
th_write_file(TH_PATH(tmp, "docs/adr/0013-colon.md"),
|
|
"# Colon\n\n## Status\n\nAccepted after a long review of the store\n"
|
|
"Supersedes: [ADR 0002](0002-next.md)\n\n## Decision\n\nWe use K.\n");
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/adr.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
ASSERT_EQ(md_supersedes_from(db, "0003-none.md", "%"), 0);
|
|
ASSERT_EQ(md_supersedes_from(db, "0004-na.md", "%"), 0);
|
|
ASSERT_EQ(md_supersedes_from(db, "0005-report.md", "%"), 0);
|
|
ASSERT_EQ(md_supersedes_from(db, "0006-own.md", "0001-base.md"), 1);
|
|
ASSERT_EQ(md_supersedes_from(db, "0007-stop.md", "0001-base.md"), 1);
|
|
ASSERT_EQ(md_supersedes_from(db, "0007-stop.md", "0002-next.md"), 0);
|
|
ASSERT_EQ(md_supersedes_from(db, "0008-tools.md", "0002-next.md"), 1);
|
|
ASSERT_EQ(md_supersedes_from(db, "0009-table.md", "0001-base.md"), 1);
|
|
ASSERT_EQ(md_supersedes_from(db, "0010-field.md", "0002-next.md"), 1);
|
|
/* a statement opens its line or field and names a record directly; the
|
|
* same words in running prose are the held family supersedes_prose */
|
|
ASSERT_EQ(md_supersedes_syntax(db, "0006-own.md", "supersedes_prose"), 1);
|
|
ASSERT_EQ(md_supersedes_syntax(db, "0007-stop.md", "supersedes_prose"), 1);
|
|
ASSERT_EQ(md_supersedes_syntax(db, "0008-tools.md", "supersedes"), 1);
|
|
ASSERT_EQ(md_supersedes_syntax(db, "0009-table.md", "supersedes"), 1);
|
|
ASSERT_EQ(md_supersedes_syntax(db, "0010-field.md", "supersedes"), 1);
|
|
ASSERT_EQ(md_supersedes_syntax(db, "0011-wrap.md", "supersedes"), 0);
|
|
ASSERT_EQ(md_supersedes_syntax(db, "0011-wrap.md", "supersedes_prose"), 1);
|
|
ASSERT_EQ(md_supersedes_syntax(db, "0012-fields.md", "supersedes"), 1);
|
|
ASSERT_EQ(md_supersedes_syntax(db, "0013-colon.md", "supersedes"), 1);
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* Held-out findings (AsciiDoc code names, which resolve like Markdown's): a
|
|
* `std::` name is the standard library's, not a specialization or header of
|
|
* this repository; a Java name written from its root package is absolute, not
|
|
* a relocated copy's tail; a type is named in its own case (`debug.log` is a
|
|
* log file, not Debug.log); a definition of another copy of the document's
|
|
* project (snapshots side by side) never answers the document. Each with a
|
|
* control that keeps its link. */
|
|
TEST(doc_links_md_name_scope_rules) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlmdscope_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
/* a compatibility header named like the standard one (Boost.Typeof's) */
|
|
th_write_file(TH_PATH(tmp, "lib/include/mylib/std/complex.hpp"),
|
|
"#include <complex>\nnamespace mylib { using cplx = int; }\n");
|
|
th_write_file(TH_PATH(tmp, "lib/include/mylib/widget.hpp"),
|
|
"namespace mylib {\nclass Widget {\n public:\n int size() const;\n};\n}\n");
|
|
th_write_file(
|
|
TH_PATH(tmp, "shaded/src/main/java/org/acme/shaded/com/google/protobuf/Message.java"),
|
|
"package org.acme.shaded.com.google.protobuf;\npublic class Message {}\n");
|
|
th_write_file(TH_PATH(tmp, "core/src/main/java/com/acme/Widget.java"),
|
|
"package com.acme;\npublic class Widget {}\n");
|
|
th_write_file(TH_PATH(tmp, "core/src/main/java/com/acme/Ref.java"),
|
|
"package com.acme;\npublic class Ref {\n public static class Debug {\n"
|
|
" public static void log(String s) {}\n }\n}\n");
|
|
th_write_file(
|
|
TH_PATH(tmp, "v1/src/main/java/org/acme/Store.java"),
|
|
"package org.acme;\npublic class Store {\n public static class Reader {}\n}\n");
|
|
th_write_file(TH_PATH(tmp, "v2/src/main/java/org/acme/Store.java"),
|
|
"package org.acme;\npublic interface Store {}\n");
|
|
th_write_file(
|
|
TH_PATH(tmp, "docs/guide.md"),
|
|
"# Keys\n\nKeys use `std::complex`, and `mylib::Widget` holds them.\n\n"
|
|
"# Wire\n\nProtobuf's `com.google.protobuf.Message`; ours is `com.acme.Widget`.\n\n"
|
|
"# Logs\n\nRead `debug.log`.\n\n"
|
|
"# Debug\n\nCall `Debug.log`.\n");
|
|
th_write_file(TH_PATH(tmp, "v2/docs/design.md"), "# Design\n\nThe `Store.Reader` reads.\n");
|
|
th_write_file(TH_PATH(tmp, "v1/docs/notes.md"), "# Notes\n\nThe `Store.Reader` reads.\n");
|
|
/* a configuration key that spells a Java package's tail; a Python package */
|
|
th_write_file(TH_PATH(tmp, "core/src/main/java/io/acme/snapshot/mode/SnapshotMode.java"),
|
|
"package io.acme.snapshot.mode;\npublic enum SnapshotMode { INITIAL }\n");
|
|
th_write_file(TH_PATH(tmp, "pyapp/__init__.py"), "");
|
|
th_write_file(TH_PATH(tmp, "pyapp/sub/__init__.py"), "from .core import run\n");
|
|
th_write_file(TH_PATH(tmp, "pyapp/sub/core.py"), "def run():\n return 1\n");
|
|
th_write_file(TH_PATH(tmp, "docs/config.md"),
|
|
"# Config\n\nSet `snapshot.mode` to `initial`.\n\n"
|
|
"# Py\n\nThe `pyapp.sub` package runs it.\n");
|
|
char db[512];
|
|
snprintf(db, sizeof(db), "%s/md.db", tmp);
|
|
ASSERT_EQ(dm_index(tmp, db, NULL), 0);
|
|
char props[512];
|
|
ASSERT_EQ(md_edge(db, "guide.Keys", "complex.hpp.__file__", props, sizeof(props)), 0);
|
|
ASSERT_EQ(md_edge(db, "guide.Keys", "mylib.Widget", props, sizeof(props)), 1);
|
|
ASSERT_EQ(md_edge(db, "guide.Wire", "protobuf.Message", props, sizeof(props)), 0);
|
|
ASSERT_EQ(md_edge(db, "guide.Wire", "com.acme.Widget", props, sizeof(props)), 1);
|
|
ASSERT_EQ(md_edge(db, "guide.Logs", "Debug.log", props, sizeof(props)), 0);
|
|
ASSERT_EQ(md_edge(db, "guide.Debug", "Debug.log", props, sizeof(props)), 1);
|
|
ASSERT_EQ(md_edge(db, "design.Design", "Store.Reader", props, sizeof(props)), 0);
|
|
ASSERT_EQ(md_edge(db, "notes.Notes", "Store.Reader", props, sizeof(props)), 1);
|
|
ASSERT_EQ(md_edge(db, "config.Config", "snapshot.mode", props, sizeof(props)), 0);
|
|
ASSERT_EQ(md_edge(db, "config.Py", "sub.__init__.py.__file__", props, sizeof(props)), 1);
|
|
th_cleanup(tmp);
|
|
PASS();
|
|
}
|
|
|
|
SUITE(doc_links_md) {
|
|
dm_ship_held_families();
|
|
RUN_TEST(doc_links_md_extract_tokens);
|
|
RUN_TEST(doc_links_md_repeated_headings);
|
|
RUN_TEST(doc_links_md_repeated_bare_name_priority);
|
|
RUN_TEST(doc_links_md_heading_suffix_reservations);
|
|
RUN_TEST(doc_links_md_generated_name_reservations);
|
|
RUN_TEST(doc_links_md_distinct_chains_same_dotted_name);
|
|
RUN_TEST(doc_links_md_setext_parent_names);
|
|
RUN_TEST(doc_links_md_quoted_setext_heading_text);
|
|
RUN_TEST(doc_links_md_mixed_heading_container_scope);
|
|
RUN_TEST(doc_links_md_empty_heading_resets_parent);
|
|
RUN_TEST(doc_links_md_flat_collection_work);
|
|
RUN_TEST(doc_links_md_independent_collision_work);
|
|
RUN_TEST(doc_links_md_shared_collision_work);
|
|
RUN_TEST(doc_links_md_repeated_headings_by_parent_issue2586);
|
|
RUN_TEST(doc_links_md_repeated_heading_name_collision_issue2586);
|
|
RUN_TEST(doc_links_md_classify_span);
|
|
RUN_TEST(doc_links_md_resolve);
|
|
RUN_TEST(doc_links_md_member_not_defined_issue2587);
|
|
RUN_TEST(doc_links_md_member_written_issue2587);
|
|
RUN_TEST(doc_links_md_incremental);
|
|
RUN_TEST(doc_links_md_member_missing_incremental);
|
|
RUN_TEST(doc_links_md_moved_directory_issue2588);
|
|
RUN_TEST(doc_links_md_adr);
|
|
RUN_TEST(doc_links_md_adr_label_choice);
|
|
RUN_TEST(doc_links_md_adr_reported);
|
|
RUN_TEST(doc_links_md_name_scope_rules);
|
|
cbm_doclink_test_reset_ships();
|
|
RUN_TEST(doc_links_md_ship_gate);
|
|
}
|