diff --git a/doc/architecture/SPEC/knowledge_service.md b/doc/architecture/SPEC/knowledge_service.md index bf4c3261..b0fd0262 100644 --- a/doc/architecture/SPEC/knowledge_service.md +++ b/doc/architecture/SPEC/knowledge_service.md @@ -48,6 +48,11 @@ library only. ## 2026-09-28 T1 索引修复 L1 的索引检查允许为目录中已存在但未列入索引的页面补链接(巡检 T1 修复),新页面仍必须被链接。 +## 2026-09-30 kb init 的 L1 bootstrap 模式 +`l1.check_changeset(..., bootstrap=)` 只供 `kb init`(`kb_service/init_stages.py`):允许在新目录新建 +`_index.md`、修改共享的 `repos/_index.md`;`l1.check_index_links(base, head)` 检查 init 改动的索引链接。 +细节见 [l1](knowledge_service/l1.md)。服务门禁与发布器从不传 `bootstrap`。 + ## 2026-09-28 知识验证器(v8:发布器本地门禁) `gate_verifier.verify_change(ctx, pr=, head_sha=, pre=, post=, final=, effective_base=, verdict=)` 是发布器本地门禁 (`kb_service.local_gate`)在合并前与合并后运行的检查;v7 的仓库端 `kb-gate` 工作流、`.github/kb-gate/` 验证包、 diff --git a/doc/architecture/SPEC/knowledge_service/l1.md b/doc/architecture/SPEC/knowledge_service/l1.md index 3fe1f133..711a0617 100644 --- a/doc/architecture/SPEC/knowledge_service/l1.md +++ b/doc/architecture/SPEC/knowledge_service/l1.md @@ -1,8 +1,8 @@ # knowledge_service/l1.py —— 规范 - + -`LOC ~420 · 知识质量门的确定性一层(L1) · refactor-status: stable` +`LOC ~600 · 知识质量门的确定性一层(L1) · refactor-status: stable` ## 职责 - `check_tree(files)`:尾注合法、supersede 双向一致、tombstone ID 未被复用、 @@ -24,11 +24,36 @@ 同目录 `_index.md` 出现。 - purge 时序:tombstone 的 `purged_at` 不得等于该规则的 `retired_at`;传入可信的 `release` 时 `purged_at` 必须等于它。 +- `check_changeset(..., bootstrap=)`:仅供 `kb init`(唯一允许的调用方是 + `kb_service/init_stages.py`,测试扫描源码钉住)。只放宽两条: + - 新目录(base 中该目录下没有任何文件)可以**新建** `_index.md`,产出一个 prose 块;新索引必须 + 链接本目录的每个页面和每个子目录的 `_index.md`,已存在的父目录索引必须链接它 + (父索引也是本次新建时由父索引自己报告)。在已有目录新建索引、删除索引仍然拒绝。 + - 共享的 `knowledge/repos/_index.md` 可以被**修改**(登记新仓库),新增/删除仍拒绝。 + 其余规则(尾注、frontmatter、`sources`、白名单其余部分、保护规则)不变;默认 `False` + 时行为与原来逐字节一致。 +- `check_index_links(base, head)`:init 侧的索引链接检查(索引改成表格或跨目录链接时 + L1 只把它当 prose 块)。**链接 = 渲染器实际生成的链接**:用 markdown-it-py 的 CommonMark 解析器 + (开启 GitHub 管道表格)取所有 `link_open` 的 href(图片及其 alt 文本里的不算);先按 URL 拆分(编码的 `#`、`:` 属于文件名), + 再只对路径做百分号解码;引用定义、标题、转义、代码段/代码块、 + HTML 块/注释都由解析器按渲染器的方式处理,不手写匹配。URL、纯锚点不算目标。 + 对每个新增或改动的 `_index.md`: + - 本次新增的链接必须解析到 head 中的文件或目录(知识树根目录本身,如 `../..`,算已存在的目录)、不得逃出知识树(`index_link_broken` / + `index_link_escapes`,绝对路径保留各自的 `/…` 身份且一律算逃逸;base 已有的链接不重新判定—— + 有些指向 `doc/`)。 + - 保留:base 的链接(按解析后的路径)必须仍在 head 的链接里,否则 `index_link_dropped`——把链接挪进 + 代码、注释或 HTML 块 = 丢链接。 + - 覆盖:head 中每个新页面、新子目录索引都必须被父索引链接(`index_unlinked`,知识树根没有索引); + bootstrap 新建索引的 `index_missing` 用同一套链接。 + 在真实知识树的 142 个索引上逐一追加一行、逐一新增一个被链接的页面:0 误报、0 漏判。 ## 不变量 -纯函数;标准库 + PyYAML + `lifecycle`/`ops`,验证包原样携带。段比较忽略尾部空行 +纯函数;标准库 + PyYAML + `lifecycle`/`ops`(索引链接检查另用 markdown-it-py,延迟导入, +`pyproject` 显式声明),验证包原样携带。段比较忽略尾部空行 (追加段落时前一段多出的分隔空行不算改动)。 ## 测试 +`test_kb_init_l1.py`(bootstrap 新目录索引、父子链接、已有目录仍拒绝、删除拒绝、`repos/_index.md` +只允许修改、链接检查各类 issue、仅 `init_stages.py` 可传 `bootstrap=True`); `test_knowledge_ops_l1.py`(伪造的 sources/frontmatter/删除 active 规则/重复 ID、白名单与 模式、prose 分块、外部引用、真实知识树上执行各类操作后两个既有校验器均通过)。 diff --git a/pyproject.toml b/pyproject.toml index 45f4e49f..c0d4a807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "anthropic>=0.34", "openai>=1.50", "rich>=13.0", + "markdown-it-py>=3", ] [project.optional-dependencies] diff --git a/src/infermatrix_copilot/knowledge_service/l1.py b/src/infermatrix_copilot/knowledge_service/l1.py index bb0bd49a..66d0d49c 100644 --- a/src/infermatrix_copilot/knowledge_service/l1.py +++ b/src/infermatrix_copilot/knowledge_service/l1.py @@ -18,17 +18,28 @@ pages, tombstones. Recomputed here; a mismatch is an issue, never a block a verdict could cover. -Standard library + PyYAML + ``lifecycle``/``ops`` only. +``check_changeset`` with ``bootstrap`` set is for ``kb init`` only (the one +allowed caller is ``kb_service/init_stages.py``; a test pins that). It relaxes +exactly two rules so a repository's knowledge can be created: an ``_index.md`` +may be created in a directory that had no files before, and the shared +``repos/_index.md`` may be edited to list a new repository. +``check_index_links(base, head)`` is the init-side link check for the index +edits that bootstrap produces. + +Standard library + PyYAML + ``lifecycle``/``ops``; ``check_index_links`` (and the +bootstrap index check) also uses markdown-it-py, imported lazily. """ from __future__ import annotations import hashlib import json +import posixpath import re from dataclasses import dataclass from pathlib import PurePosixPath from typing import Iterable, Mapping +from urllib.parse import unquote, urlsplit import yaml @@ -48,6 +59,8 @@ _DENY = re.compile(r"knowledge/(?:skills|tools|\.claude)/|knowledge/[^/]+$") _REGULAR_MODE = "100644" _INDEX_LINE = re.compile(r"- \[[^\]\n]+\]\((?P[^)/\s]+\.md)\)") +# bootstrap only: the shared list of repositories, edited to link a new one +_REPOS_INDEX = "knowledge/repos/_index.md" @dataclass(frozen=True) @@ -188,11 +201,13 @@ def _check_routes(path: str, text: str, files: Mapping[str, str]) -> list[Issue] # -- change-set level ---------------------------------------------------------- -def _whitelist_issues(changes: Iterable[Change]) -> list[Issue]: +def _whitelist_issues(changes: Iterable[Change], bootstrap: bool = False) -> list[Issue]: issues = [] for change in changes: path = change.path - if not path.startswith(KNOWLEDGE_PREFIX) or _DENY.match(path) \ + if bootstrap and path == _REPOS_INDEX and change.status == "M": + pass # a new repository is listed in the shared index + elif not path.startswith(KNOWLEDGE_PREFIX) or _DENY.match(path) \ or not any(p.fullmatch(path) for p in _WHITELIST): issues.append(Issue("path_not_whitelisted", path, "only knowledge rule/guide pages, _routes.yaml and _tombstones.yaml auto-merge")) continue @@ -327,7 +342,11 @@ def _classify_page(path: str, base_text: str | None, head_text: str | None, def _index_mechanical(path: str, base_text: str | None, head_text: str | None, new_pages: set[str], issues: list[Issue], blocks: list[Block], - head: Mapping[str, str] | None = None) -> None: + head: Mapping[str, str] | None = None, *, + base: Mapping[str, str] | None = None, bootstrap: bool = False) -> None: + if bootstrap and base_text is None and head_text is not None and head is not None: + _index_created(path, base or {}, head, issues, blocks) + return if base_text is None or head_text is None: issues.append(Issue("index_added_or_deleted", path, "index files are never created or deleted by a change set")) return @@ -361,6 +380,130 @@ def _index_mechanical(path: str, base_text: str | None, head_text: str | None, f"index lines must cover new pages {expected_new} and only link unlisted pages; got {files}")) +_MARKDOWN = None + + +def _links(text: str) -> list[str]: + """The link destinations a CommonMark renderer (with GitHub's pipe tables) + makes of ``text``, as written (still percent-encoded). The parser settles + what a hand-written matcher cannot: reference definitions, titles, escapes, + code spans and blocks, HTML blocks and comments. Images are not links, and + neither is anything in an image's alt text.""" + global _MARKDOWN + if _MARKDOWN is None: + from markdown_it import MarkdownIt # lazy: only index checks need it + + _MARKDOWN = MarkdownIt("commonmark").enable("table") + out: list[str] = [] + + def walk(tokens) -> None: + for token in tokens: + if token.type == "link_open": + out.append(str(token.attrGet("href") or "")) + if token.children and token.type != "image": + walk(token.children) + + walk(_MARKDOWN.parse(text)) + return out + + +def _link_targets(directory: str, text: str) -> dict[str, str]: + """Relative links of an index: {destination without anchor: resolved + knowledge-relative path}. URLs and anchor-only links are not targets; a + link that leaves the knowledge tree resolves to a path starting ``..``, + and an absolute one keeps its own normalised ``/...`` path (both escape).""" + out: dict[str, str] = {} + for href in _links(text): + parts = urlsplit(href) # split first: an encoded "#" or ":" is part of the name + if parts.scheme or parts.netloc or not parts.path: + continue + target = unquote(parts.path) + out[target] = posixpath.normpath(target) if target.startswith("/") else \ + posixpath.normpath(posixpath.join(directory, target)) + return out + + +def _dir_entries(head: Mapping[str, str], directory: str) -> list[str]: + """Pages directly in ``directory`` and the indexes of its child directories.""" + prefix = f"{directory}/" if directory else "" + out = [] + for p in sorted(head): + if not p.startswith(prefix) or not p.endswith(".md"): + continue + rest = p[len(prefix):].split("/") + if (len(rest) == 1 and rest[0] != INDEX_NAME) or (len(rest) == 2 and rest[1] == INDEX_NAME): + out.append(p) + return out + + +def _index_created(path: str, base: Mapping[str, str], head: Mapping[str, str], + issues: list[Issue], blocks: list[Block]) -> None: + """Bootstrap: an index created for a directory that is new in this change set.""" + directory = posixpath.dirname(path) + if any(p.startswith(f"{directory}/") for p in base): + issues.append(Issue("index_added_or_deleted", path, + "an index may be created only for a new directory")) + return + linked = set(_link_targets(directory, head[path]).values()) + for entry in _dir_entries(head, directory): + if entry not in linked: + issues.append(Issue("index_missing", path, f"{entry} is not linked")) + if directory: + parent = posixpath.join(posixpath.dirname(directory), INDEX_NAME) + # a parent index created in the same change set reports its own children + if parent in base and parent in head and path not in set( + _link_targets(posixpath.dirname(parent), head[parent]).values()): + issues.append(Issue("index_missing", parent, f"the new {path} is not linked")) + blocks.append(Block("prose", path, "", "prose", _sha(head[path]))) + + +def check_index_links(base: Mapping[str, str], head: Mapping[str, str]) -> list[Issue]: + """Links of every index added or changed from ``base`` to ``head``: each + link the change adds resolves inside the knowledge tree to a file (or a + directory) of ``head``; a changed index keeps every link it had; every + page or child index new in ``head`` is linked from its parent index. + Links are what a CommonMark renderer makes (``_links``): text in code, a + comment or an HTML block is not a link, so moving a link there drops it. + Links an index already had are not re-judged (some point into doc/).""" + issues: list[Issue] = [] + head_dirs = {posixpath.dirname(p) for p in head} + for path in sorted(p for p in head if posixpath.basename(p) == INDEX_NAME): + if base.get(path) == head[path]: + continue + directory = posixpath.dirname(path) + before = _link_targets(directory, base.get(path) or "") + after = _link_targets(directory, head[path]) + kept = set(after.values()) + for written, resolved in sorted(before.items()): + if resolved not in kept: + issues.append(Issue("index_link_dropped", path, written)) + had = set(before.values()) + for written, resolved in sorted(after.items()): + if resolved in had: + continue + if resolved == ".." or resolved.startswith(("../", "/")): + issues.append(Issue("index_link_escapes", path, written)) + elif resolved in (".", ""): + continue # the knowledge root itself: a directory every tree has + elif resolved not in head and not any(d == resolved or d.startswith(resolved + "/") + for d in head_dirs): + issues.append(Issue("index_link_broken", path, written)) + for page in sorted(p for p in head if p not in base and p.endswith(".md")): + directory = posixpath.dirname(page) + if posixpath.basename(page) == INDEX_NAME: + if not directory: + continue + directory = posixpath.dirname(directory) + parent = posixpath.join(directory, INDEX_NAME) + if parent not in head: + if directory: # the knowledge root has no index + issues.append(Issue("index_unlinked", page, f"{parent} does not exist")) + continue + if page not in set(_link_targets(directory, head[parent]).values()): + issues.append(Issue("index_unlinked", page, f"{parent} does not link it")) + return issues + + def check_changeset( base: Mapping[str, str], head: Mapping[str, str], @@ -368,6 +511,7 @@ def check_changeset( *, external_texts: Mapping[str, str] | None = None, release: str = "", + bootstrap: bool = False, ) -> ChangesetResult: """Gate one change set. ``base``/``head`` map knowledge-relative paths (``repos/...``) to text; ``changes`` are repository-relative git changes. @@ -375,9 +519,9 @@ def check_changeset( for references to rules this change retires or purges. ``release`` is the trusted current release (from the signed verdict); when given, purges must carry it as ``purged_at``. A purge is always rejected in the release that - retired the rule.""" + retired the rule. ``bootstrap`` is for ``kb init`` only (module docstring).""" changes = list(changes) - issues = _whitelist_issues(changes) + issues = _whitelist_issues(changes, bootstrap) blocks: list[Block] = [] retired: list[str] = [] purged: list[tuple[str, str]] = [] # (rule id, page) @@ -400,7 +544,8 @@ def check_changeset( for path in sorted(knowledge_paths): name = PurePosixPath(path).name if name == INDEX_NAME: - _index_mechanical(path, base.get(path), head.get(path), new_pages, issues, blocks, head) + _index_mechanical(path, base.get(path), head.get(path), new_pages, issues, blocks, head, + base=base, bootstrap=bootstrap) elif name == TOMBSTONES_NAME: tombstone_paths.append(path) elif name == ROUTES_NAME: diff --git a/test/test_kb_init_l1.py b/test/test_kb_init_l1.py new file mode 100644 index 00000000..3f6a0ae8 --- /dev/null +++ b/test/test_kb_init_l1.py @@ -0,0 +1,317 @@ +"""L1 bootstrap mode (kb init creates a repository's indexes) and the init-side +index link check (design kb init v3 §9.2, §9.3).""" + +from __future__ import annotations + +import re +from pathlib import Path + +from infermatrix_copilot.knowledge_service.l1 import ( + Change, check_changeset, check_index_links, +) + +PAGE = ( + "---\n" + 'title: "Demo rules"\n' + "created: 2026-09-30\n" + "updated: 2026-09-30\n" + "type: rule\n" + "tags: [demo]\n" + "sources: []\n" + "---\n\n" + "# Demo rules\n" +) + + +def _base() -> dict[str, str]: + return { + "repos/_index.md": "# repos\n\n| repo | where |\n|---|---|\n| old | [old](old/_index.md) |\n", + "repos/old/_index.md": "# old\n\n- [rules](rules.md)\n", + "repos/old/rules.md": PAGE, + "general/_index.md": "# general\n\n- [method](method.md)\n", + "general/method.md": "# method\n", + } + + +def _new_repo(base: dict[str, str]) -> dict[str, str]: + head = dict(base) + head["repos/_index.md"] = base["repos/_index.md"] + "| new | [new](new/_index.md) |\n" + head["repos/new/_index.md"] = ( + "# new\n\n| page | when |\n|---|---|\n| [rules](rules.md) | always |\n" + "| [core](core/_index.md) | core |\n\n- [method](../../general/method.md)\n" + ) + head["repos/new/rules.md"] = PAGE + head["repos/new/core/_index.md"] = "# core\n\n- [cards](cards.md)\n" + head["repos/new/core/cards.md"] = "# cards\n" + return head + + +def _changes(base: dict, head: dict) -> list[Change]: + out = [] + for path in sorted(set(base) | set(head)): + if base.get(path) == head.get(path): + continue + status = "A" if path not in base else "D" if path not in head else "M" + out.append(Change(f"knowledge/{path}", status, "100644", "" if status == "D" else "100644")) + return out + + +def _codes(result) -> set[str]: + return {i.code for i in result.issues} + + +def test_new_directory_indexes_pass_only_in_bootstrap(): + base = _base() + head = _new_repo(base) + boot = check_changeset(base, head, _changes(base, head), bootstrap=True) + assert boot.ok, boot.issues + created = {b.path for b in boot.blocks if b.kind == "prose"} + assert {"repos/new/_index.md", "repos/new/core/_index.md"} <= created + plain = check_changeset(base, head, _changes(base, head)) + assert {"index_added_or_deleted", "path_not_whitelisted"} <= _codes(plain) + + +def test_default_mode_is_unchanged_for_an_existing_index(): + base = _base() + head = {**base, "repos/old/extra.md": PAGE, + "repos/old/_index.md": base["repos/old/_index.md"] + "- [extra](extra.md)\n"} + assert check_changeset(base, head, _changes(base, head)).ok + assert check_changeset(base, head, _changes(base, head), bootstrap=True).ok + + +def test_index_in_an_existing_directory_is_still_refused(): + base = _base() + base.pop("repos/old/_index.md") + head = {**base, "repos/old/_index.md": "# old\n\n- [rules](rules.md)\n"} + result = check_changeset(base, head, _changes(base, head), bootstrap=True) + assert "index_added_or_deleted" in _codes(result) + + +def test_created_index_must_link_its_pages_and_child_indexes(): + base = _base() + head = _new_repo(base) + head["repos/new/_index.md"] = "# new\n\n- [rules](rules.md)\n" + result = check_changeset(base, head, _changes(base, head), bootstrap=True) + missing = [i for i in result.issues if i.code == "index_missing"] + assert [i.detail for i in missing] == ["repos/new/core/_index.md is not linked"] + + +def test_parent_index_must_link_the_created_index(): + base = _base() + head = _new_repo(base) + head["repos/_index.md"] = base["repos/_index.md"] + result = check_changeset(base, head, _changes(base, head), bootstrap=True) + assert any(i.code == "index_missing" and i.path == "repos/_index.md" for i in result.issues) + + +def test_deleting_an_index_is_refused_in_bootstrap(): + base = _base() + head = dict(base) + head.pop("repos/old/_index.md") + result = check_changeset(base, head, _changes(base, head), bootstrap=True) + assert "index_added_or_deleted" in _codes(result) + + +def test_repos_index_whitelist_is_edit_only(): + base = _base() + head = _new_repo(base) + changes = [c if c.path != "knowledge/repos/_index.md" else Change(c.path, "A", "", "100644") + for c in _changes(base, head)] + result = check_changeset(base, head, changes, bootstrap=True) + assert "path_not_whitelisted" in _codes(result) + + +def test_index_links_accept_a_valid_bootstrap(): + base = _base() + assert check_index_links(base, _new_repo(base)) == [] + + +def test_index_links_report_broken_escaping_dropped_and_unlinked(): + base = _base() + head = _new_repo(base) + head["repos/new/_index.md"] += "- [gone](missing.md)\n- [out](../../../README.md)\n- [abs](/etc/x.md)\n" + head["repos/old/_index.md"] = "# old\n" # dropped a link + head["repos/new/core/orphan.md"] = "# orphan\n" # new page, not linked + issues = {(i.code, i.path, i.detail) for i in check_index_links(base, head)} + assert ("index_link_broken", "repos/new/_index.md", "missing.md") in issues + assert ("index_link_escapes", "repos/new/_index.md", "../../../README.md") in issues + assert ("index_link_escapes", "repos/new/_index.md", "/etc/x.md") in issues + assert ("index_link_dropped", "repos/old/_index.md", "rules.md") in issues + assert any(code == "index_unlinked" and path == "repos/new/core/orphan.md" + for code, path, _ in issues) + + +def test_index_links_read_titles_angles_and_reference_definitions(): + base = _base() + head = _new_repo(base) + head["repos/new/_index.md"] += ( + '- [out](../../../README.md "title")\n' + "- [gone]( 'title')\n" + "- [ref][r]\n\n[r]: ../../../ref.md \"t\"\n" + "[ok]: rules.md\n" + ) + issues = {(i.code, i.detail) for i in check_index_links(base, head)} + assert issues == {("index_link_escapes", "../../../README.md"), + ("index_link_broken", "missing.md"), + ("index_link_escapes", "../../../ref.md")} + + +def _unlinked(base, head) -> bool: + return ("index_unlinked", "repos/new/core/cards.md") in { + (i.code, i.path) for i in check_index_links(base, head)} + + +def test_text_that_renders_no_link_does_not_link_a_page(): + """Links are what a CommonMark renderer makes, not what looks like one.""" + base = _base() + head = _new_repo(base) + core = "repos/new/core/_index.md" + for text in ( + "# core\n\n [cards](cards.md)\n", # indented code + "# core\n\n\t[cards](cards.md)\n", # tab-indented code + "# core\n\n```\n- [cards](cards.md)\n```\n", # fenced code + "# core\n\n~~~\n- [cards](cards.md)\n", # unclosed fence runs to the end + "# core\n\n- `[cards](cards.md)`\n", # code span + "# core\n\n`` a ```` [cards](cards.md) ``\n", # code span with unequal inner runs + "# core\n\nsee `code\n[cards](cards.md)` here\n", # code span across a line break + "# core\n\n\n", # HTML comment + "# core\n\n\n", # comment swallows the destination + '# core\n\n- x\n', # link text in an attribute + '# core\n\ntext x\n', + "# core\n\n- cards](cards.md)\n", # no opening bracket + "# core\n\n- \\[cards](cards.md)\n", # escaped bracket + "# core\n\n- ![cards](cards.md)\n", # an image, not a link + "# core\n\n- ![alt [cards](cards.md)](image.png)\n", # a link inside alt text + "# core\n\n- [cards](cards.md%23missing)\n", # an encoded "#" is in the name + "# core\n\n[cards]`example`(cards.md)\n", # never a link + "# core\n\n[c]: cards.md\n", # a definition nobody uses + # a definition cannot interrupt a paragraph (gate repro) + "# core\n\nordinary paragraph\n[c]: cards.md\n\n- [cards][c]\n", + # an escaped quote does not close a title: no link (gate repro) + '# core\n\n- [cards](cards.md "bad\\")\n', + ): + head[core] = text + assert _unlinked(base, head), text + + +def test_text_that_renders_a_link_links_the_page(): + base = _base() + head = _new_repo(base) + core = "repos/new/core/_index.md" + head["repos/new/core/it's.md"] = "# it's\n" + head["repos/new/core/中文 页.md"] = "# 中文\n" + head["repos/new/core/a:b.md"] = "# a:b\n" + for text in ( + "# core\n\n- [cards](cards.md)\n", + "# core\n\n- [cards](cards.md \"title\")\n", + "# core\n\n- [cards]()\n", + "# core\n\n- [Cards][c]\n\n[c]: cards.md\n", + "# core\n\n- [c]\n\n[C]: \n", # shortcut, case-folded + "# core\n\n- `rules` [cards](cards.md)\n", # code before the link + "# core\n\n- `open [cards](cards.md)\n", # unmatched backtick is literal + "# core\n\ntext \n", + "# old\n\n```\nx\n\n- [rules](rules.md)\n\n```\n", + "# old\n\n- rules](rules.md)\n", + "# old\n\n- ![alt [rules](rules.md)](image.png)\n", + "# old\n\n- [rules](rules.md%23x)\n", + '# old\n\n- [rules](rules.md "bad\\")\n'): + head = {**base, "repos/old/_index.md": moved} + assert "index_link_dropped" in {i.code for i in check_index_links(base, head)}, moved + + +def test_absolute_links_keep_their_own_identity(): + base = _base() + base["repos/old/_index.md"] += "- [old](/docs/a.md)\n" + head = {**base, "repos/old/_index.md": base["repos/old/_index.md"] + "- [new](/outside/b.md)\n"} + issues = {(i.code, i.detail) for i in check_index_links(base, head)} + assert issues == {("index_link_escapes", "/outside/b.md")} + head["repos/old/_index.md"] = base["repos/old/_index.md"].replace("/docs/a.md", "/outside/b.md") + issues = {(i.code, i.detail) for i in check_index_links(base, head)} + assert issues == {("index_link_dropped", "/docs/a.md"), ("index_link_escapes", "/outside/b.md")} + + +def test_links_in_code_are_not_resolved_either(): + base = _base() + head = _new_repo(base) + head["repos/new/core/_index.md"] = "# core\n\n- [cards](cards.md)\n\n```\n[x](missing.md)\n```\n" + assert check_index_links(base, head) == [] + + +def test_the_first_reference_definition_wins(): + base = _base() + head = _new_repo(base) + head["repos/new/core/_index.md"] = "# core\n\n- [Cards][c]\n\n[c]: missing.md\n[c]: cards.md\n" + issues = {(i.code, i.detail if i.code != "index_unlinked" else i.path) + for i in check_index_links(base, head)} + assert issues == {("index_link_broken", "missing.md"), + ("index_unlinked", "repos/new/core/cards.md")} + + +def test_a_definition_in_code_cannot_shadow_the_real_one(): + base = _base() + head = dict(base) + head["repos/old/_index.md"] = (base["repos/old/_index.md"] + + "[x][r]\n\n```\n[r]: rules.md\n```\n\n[r]: ../../../secret.md\n") + issues = {(i.code, i.detail) for i in check_index_links(base, head)} + assert ("index_link_escapes", "../../../secret.md") in issues + + +def test_removing_a_reference_usage_drops_the_link(): + base = _base() + base["repos/old/_index.md"] = "# old\n\n- [Rules][r]\n\n[r]: rules.md\n" + head = dict(base) + head["repos/old/_index.md"] = "# old\n\n[r]: rules.md\n" + assert {i.code for i in check_index_links(base, head)} == {"index_link_dropped"} + + +def test_bootstrap_coverage_uses_rendered_links(): + base = _base() + head = _new_repo(base) + head["repos/new/core/_index.md"] = "# core\n\n [cards](cards.md)\n" + result = check_changeset(base, head, _changes(base, head), bootstrap=True) + assert any(i.code == "index_missing" and "cards.md" in i.detail for i in result.issues) + + +def test_index_links_ignore_urls_anchors_and_links_the_base_already_had(): + base = _base() + base["general/_index.md"] += "- [schema](../../doc/knowledge/SCHEMA.md)\n" + head = dict(base) + head["general/_index.md"] += "- [site](https://example.com/x)\n- [top](#top)\n- [m](method.md#a)\n" + assert check_index_links(base, head) == [] + + +def test_only_kb_init_may_pass_bootstrap(): + root = Path(__file__).resolve().parents[1] / "src" / "infermatrix_copilot" + allowed = {root / "kb_service" / "init_stages.py"} + offenders = [ + str(path.relative_to(root)) for path in root.rglob("*.py") + if path not in allowed and re.search(r"bootstrap\s*=\s*True", path.read_text(encoding="utf-8")) + ] + assert offenders == [] + + +def test_index_links_accept_the_knowledge_root_and_its_directories(): + base = _base() + head = dict(base) + head["repos/old/_index.md"] += "- [Knowledge](../..)\n- [Knowledge](../../)\n- [Repos](..)\n" + assert check_index_links(base, head) == [] + head["repos/old/_index.md"] += "- [Above](../../..)\n" + assert [(i.code, i.detail) for i in check_index_links(base, head)] == [("index_link_escapes", "../../..")]