Skip to content

Commit 438c754

Browse files
committed
Merge remote-tracking branch 'origin/kb/init-l1-bootstrap' into kb/init-integration
2 parents 4aeb457 + d70574e commit 438c754

5 files changed

Lines changed: 503 additions & 10 deletions

File tree

‎doc/architecture/SPEC/knowledge_service.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ library only.
4848
## 2026-09-28 T1 索引修复
4949
L1 的索引检查允许为目录中已存在但未列入索引的页面补链接(巡检 T1 修复),新页面仍必须被链接。
5050

51+
## 2026-09-30 kb init 的 L1 bootstrap 模式
52+
`l1.check_changeset(..., bootstrap=)` 只供 `kb init`(`kb_service/init_stages.py`):允许在新目录新建
53+
`_index.md`、修改共享的 `repos/_index.md`;`l1.check_index_links(base, head)` 检查 init 改动的索引链接。
54+
细节见 [l1](knowledge_service/l1.md)。服务门禁与发布器从不传 `bootstrap`。
55+
5156
## 2026-09-28 知识验证器(v8:发布器本地门禁)
5257
`gate_verifier.verify_change(ctx, pr=, head_sha=, pre=, post=, final=, effective_base=, verdict=)` 是发布器本地门禁
5358
(`kb_service.local_gate`)在合并前与合并后运行的检查;v7 的仓库端 `kb-gate` 工作流、`.github/kb-gate/` 验证包、

‎doc/architecture/SPEC/knowledge_service/l1.md‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# knowledge_service/l1.py —— 规范
22

3-
<!-- verified-against: 2026-09-29 -->
3+
<!-- verified-against: 2026-09-30 -->
44

5-
`LOC ~420 · 知识质量门的确定性一层(L1) · refactor-status: stable`
5+
`LOC ~600 · 知识质量门的确定性一层(L1) · refactor-status: stable`
66

77
## 职责
88
- `check_tree(files)`:尾注合法、supersede 双向一致、tombstone ID 未被复用、
@@ -24,11 +24,36 @@
2424
同目录 `_index.md` 出现。
2525
- purge 时序:tombstone 的 `purged_at` 不得等于该规则的 `retired_at`;传入可信的
2626
`release` 时 `purged_at` 必须等于它。
27+
- `check_changeset(..., bootstrap=)`:仅供 `kb init`(唯一允许的调用方是
28+
`kb_service/init_stages.py`,测试扫描源码钉住)。只放宽两条:
29+
- 新目录(base 中该目录下没有任何文件)可以**新建** `_index.md`,产出一个 prose 块;新索引必须
30+
链接本目录的每个页面和每个子目录的 `_index.md`,已存在的父目录索引必须链接它
31+
(父索引也是本次新建时由父索引自己报告)。在已有目录新建索引、删除索引仍然拒绝。
32+
- 共享的 `knowledge/repos/_index.md` 可以被**修改**(登记新仓库),新增/删除仍拒绝。
33+
其余规则(尾注、frontmatter、`sources`、白名单其余部分、保护规则)不变;默认 `False`
34+
时行为与原来逐字节一致。
35+
- `check_index_links(base, head)`:init 侧的索引链接检查(索引改成表格或跨目录链接时
36+
L1 只把它当 prose 块)。**链接 = 渲染器实际生成的链接**:用 markdown-it-py 的 CommonMark 解析器
37+
(开启 GitHub 管道表格)取所有 `link_open` 的 href(图片及其 alt 文本里的不算);先按 URL 拆分(编码的 `#`、`:` 属于文件名),
38+
再只对路径做百分号解码;引用定义、标题、转义、代码段/代码块、
39+
HTML 块/注释都由解析器按渲染器的方式处理,不手写匹配。URL、纯锚点不算目标。
40+
对每个新增或改动的 `_index.md`:
41+
- 本次新增的链接必须解析到 head 中的文件或目录(知识树根目录本身,如 `../..`,算已存在的目录)、不得逃出知识树(`index_link_broken` /
42+
`index_link_escapes`,绝对路径保留各自的 `/…` 身份且一律算逃逸;base 已有的链接不重新判定——
43+
有些指向 `doc/`)。
44+
- 保留:base 的链接(按解析后的路径)必须仍在 head 的链接里,否则 `index_link_dropped`——把链接挪进
45+
代码、注释或 HTML 块 = 丢链接。
46+
- 覆盖:head 中每个新页面、新子目录索引都必须被父索引链接(`index_unlinked`,知识树根没有索引);
47+
bootstrap 新建索引的 `index_missing` 用同一套链接。
48+
在真实知识树的 142 个索引上逐一追加一行、逐一新增一个被链接的页面:0 误报、0 漏判。
2749

2850
## 不变量
29-
纯函数;标准库 + PyYAML + `lifecycle`/`ops`,验证包原样携带。段比较忽略尾部空行
51+
纯函数;标准库 + PyYAML + `lifecycle`/`ops`(索引链接检查另用 markdown-it-py,延迟导入,
52+
`pyproject` 显式声明),验证包原样携带。段比较忽略尾部空行
3053
(追加段落时前一段多出的分隔空行不算改动)。
3154

3255
## 测试
56+
`test_kb_init_l1.py`(bootstrap 新目录索引、父子链接、已有目录仍拒绝、删除拒绝、`repos/_index.md`
57+
只允许修改、链接检查各类 issue、仅 `init_stages.py` 可传 `bootstrap=True`);
3358
`test_knowledge_ops_l1.py`(伪造的 sources/frontmatter/删除 active 规则/重复 ID、白名单与
3459
模式、prose 分块、外部引用、真实知识树上执行各类操作后两个既有校验器均通过)。

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ dependencies = [
1010
"anthropic>=0.34",
1111
"openai>=1.50",
1212
"rich>=13.0",
13+
"markdown-it-py>=3",
1314
]
1415

1516
[project.optional-dependencies]

‎src/infermatrix_copilot/knowledge_service/l1.py‎

Lines changed: 152 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,28 @@
1818
pages, tombstones. Recomputed here; a mismatch is an issue,
1919
never a block a verdict could cover.
2020
21-
Standard library + PyYAML + ``lifecycle``/``ops`` only.
21+
``check_changeset`` with ``bootstrap`` set is for ``kb init`` only (the one
22+
allowed caller is ``kb_service/init_stages.py``; a test pins that). It relaxes
23+
exactly two rules so a repository's knowledge can be created: an ``_index.md``
24+
may be created in a directory that had no files before, and the shared
25+
``repos/_index.md`` may be edited to list a new repository.
26+
``check_index_links(base, head)`` is the init-side link check for the index
27+
edits that bootstrap produces.
28+
29+
Standard library + PyYAML + ``lifecycle``/``ops``; ``check_index_links`` (and the
30+
bootstrap index check) also uses markdown-it-py, imported lazily.
2231
"""
2332

2433
from __future__ import annotations
2534

2635
import hashlib
2736
import json
37+
import posixpath
2838
import re
2939
from dataclasses import dataclass
3040
from pathlib import PurePosixPath
3141
from typing import Iterable, Mapping
42+
from urllib.parse import unquote, urlsplit
3243

3344
import yaml
3445

@@ -48,6 +59,8 @@
4859
_DENY = re.compile(r"knowledge/(?:skills|tools|\.claude)/|knowledge/[^/]+$")
4960
_REGULAR_MODE = "100644"
5061
_INDEX_LINE = re.compile(r"- \[[^\]\n]+\]\((?P<file>[^)/\s]+\.md)\)")
62+
# bootstrap only: the shared list of repositories, edited to link a new one
63+
_REPOS_INDEX = "knowledge/repos/_index.md"
5164

5265

5366
@dataclass(frozen=True)
@@ -188,11 +201,13 @@ def _check_routes(path: str, text: str, files: Mapping[str, str]) -> list[Issue]
188201

189202
# -- change-set level ----------------------------------------------------------
190203

191-
def _whitelist_issues(changes: Iterable[Change]) -> list[Issue]:
204+
def _whitelist_issues(changes: Iterable[Change], bootstrap: bool = False) -> list[Issue]:
192205
issues = []
193206
for change in changes:
194207
path = change.path
195-
if not path.startswith(KNOWLEDGE_PREFIX) or _DENY.match(path) \
208+
if bootstrap and path == _REPOS_INDEX and change.status == "M":
209+
pass # a new repository is listed in the shared index
210+
elif not path.startswith(KNOWLEDGE_PREFIX) or _DENY.match(path) \
196211
or not any(p.fullmatch(path) for p in _WHITELIST):
197212
issues.append(Issue("path_not_whitelisted", path, "only knowledge rule/guide pages, _routes.yaml and _tombstones.yaml auto-merge"))
198213
continue
@@ -327,7 +342,11 @@ def _classify_page(path: str, base_text: str | None, head_text: str | None,
327342

328343
def _index_mechanical(path: str, base_text: str | None, head_text: str | None,
329344
new_pages: set[str], issues: list[Issue], blocks: list[Block],
330-
head: Mapping[str, str] | None = None) -> None:
345+
head: Mapping[str, str] | None = None, *,
346+
base: Mapping[str, str] | None = None, bootstrap: bool = False) -> None:
347+
if bootstrap and base_text is None and head_text is not None and head is not None:
348+
_index_created(path, base or {}, head, issues, blocks)
349+
return
331350
if base_text is None or head_text is None:
332351
issues.append(Issue("index_added_or_deleted", path, "index files are never created or deleted by a change set"))
333352
return
@@ -361,23 +380,148 @@ def _index_mechanical(path: str, base_text: str | None, head_text: str | None,
361380
f"index lines must cover new pages {expected_new} and only link unlisted pages; got {files}"))
362381

363382

383+
_MARKDOWN = None
384+
385+
386+
def _links(text: str) -> list[str]:
387+
"""The link destinations a CommonMark renderer (with GitHub's pipe tables)
388+
makes of ``text``, as written (still percent-encoded). The parser settles
389+
what a hand-written matcher cannot: reference definitions, titles, escapes,
390+
code spans and blocks, HTML blocks and comments. Images are not links, and
391+
neither is anything in an image's alt text."""
392+
global _MARKDOWN
393+
if _MARKDOWN is None:
394+
from markdown_it import MarkdownIt # lazy: only index checks need it
395+
396+
_MARKDOWN = MarkdownIt("commonmark").enable("table")
397+
out: list[str] = []
398+
399+
def walk(tokens) -> None:
400+
for token in tokens:
401+
if token.type == "link_open":
402+
out.append(str(token.attrGet("href") or ""))
403+
if token.children and token.type != "image":
404+
walk(token.children)
405+
406+
walk(_MARKDOWN.parse(text))
407+
return out
408+
409+
410+
def _link_targets(directory: str, text: str) -> dict[str, str]:
411+
"""Relative links of an index: {destination without anchor: resolved
412+
knowledge-relative path}. URLs and anchor-only links are not targets; a
413+
link that leaves the knowledge tree resolves to a path starting ``..``,
414+
and an absolute one keeps its own normalised ``/...`` path (both escape)."""
415+
out: dict[str, str] = {}
416+
for href in _links(text):
417+
parts = urlsplit(href) # split first: an encoded "#" or ":" is part of the name
418+
if parts.scheme or parts.netloc or not parts.path:
419+
continue
420+
target = unquote(parts.path)
421+
out[target] = posixpath.normpath(target) if target.startswith("/") else \
422+
posixpath.normpath(posixpath.join(directory, target))
423+
return out
424+
425+
426+
def _dir_entries(head: Mapping[str, str], directory: str) -> list[str]:
427+
"""Pages directly in ``directory`` and the indexes of its child directories."""
428+
prefix = f"{directory}/" if directory else ""
429+
out = []
430+
for p in sorted(head):
431+
if not p.startswith(prefix) or not p.endswith(".md"):
432+
continue
433+
rest = p[len(prefix):].split("/")
434+
if (len(rest) == 1 and rest[0] != INDEX_NAME) or (len(rest) == 2 and rest[1] == INDEX_NAME):
435+
out.append(p)
436+
return out
437+
438+
439+
def _index_created(path: str, base: Mapping[str, str], head: Mapping[str, str],
440+
issues: list[Issue], blocks: list[Block]) -> None:
441+
"""Bootstrap: an index created for a directory that is new in this change set."""
442+
directory = posixpath.dirname(path)
443+
if any(p.startswith(f"{directory}/") for p in base):
444+
issues.append(Issue("index_added_or_deleted", path,
445+
"an index may be created only for a new directory"))
446+
return
447+
linked = set(_link_targets(directory, head[path]).values())
448+
for entry in _dir_entries(head, directory):
449+
if entry not in linked:
450+
issues.append(Issue("index_missing", path, f"{entry} is not linked"))
451+
if directory:
452+
parent = posixpath.join(posixpath.dirname(directory), INDEX_NAME)
453+
# a parent index created in the same change set reports its own children
454+
if parent in base and parent in head and path not in set(
455+
_link_targets(posixpath.dirname(parent), head[parent]).values()):
456+
issues.append(Issue("index_missing", parent, f"the new {path} is not linked"))
457+
blocks.append(Block("prose", path, "", "prose", _sha(head[path])))
458+
459+
460+
def check_index_links(base: Mapping[str, str], head: Mapping[str, str]) -> list[Issue]:
461+
"""Links of every index added or changed from ``base`` to ``head``: each
462+
link the change adds resolves inside the knowledge tree to a file (or a
463+
directory) of ``head``; a changed index keeps every link it had; every
464+
page or child index new in ``head`` is linked from its parent index.
465+
Links are what a CommonMark renderer makes (``_links``): text in code, a
466+
comment or an HTML block is not a link, so moving a link there drops it.
467+
Links an index already had are not re-judged (some point into doc/)."""
468+
issues: list[Issue] = []
469+
head_dirs = {posixpath.dirname(p) for p in head}
470+
for path in sorted(p for p in head if posixpath.basename(p) == INDEX_NAME):
471+
if base.get(path) == head[path]:
472+
continue
473+
directory = posixpath.dirname(path)
474+
before = _link_targets(directory, base.get(path) or "")
475+
after = _link_targets(directory, head[path])
476+
kept = set(after.values())
477+
for written, resolved in sorted(before.items()):
478+
if resolved not in kept:
479+
issues.append(Issue("index_link_dropped", path, written))
480+
had = set(before.values())
481+
for written, resolved in sorted(after.items()):
482+
if resolved in had:
483+
continue
484+
if resolved == ".." or resolved.startswith(("../", "/")):
485+
issues.append(Issue("index_link_escapes", path, written))
486+
elif resolved in (".", ""):
487+
continue # the knowledge root itself: a directory every tree has
488+
elif resolved not in head and not any(d == resolved or d.startswith(resolved + "/")
489+
for d in head_dirs):
490+
issues.append(Issue("index_link_broken", path, written))
491+
for page in sorted(p for p in head if p not in base and p.endswith(".md")):
492+
directory = posixpath.dirname(page)
493+
if posixpath.basename(page) == INDEX_NAME:
494+
if not directory:
495+
continue
496+
directory = posixpath.dirname(directory)
497+
parent = posixpath.join(directory, INDEX_NAME)
498+
if parent not in head:
499+
if directory: # the knowledge root has no index
500+
issues.append(Issue("index_unlinked", page, f"{parent} does not exist"))
501+
continue
502+
if page not in set(_link_targets(directory, head[parent]).values()):
503+
issues.append(Issue("index_unlinked", page, f"{parent} does not link it"))
504+
return issues
505+
506+
364507
def check_changeset(
365508
base: Mapping[str, str],
366509
head: Mapping[str, str],
367510
changes: Iterable[Change],
368511
*,
369512
external_texts: Mapping[str, str] | None = None,
370513
release: str = "",
514+
bootstrap: bool = False,
371515
) -> ChangesetResult:
372516
"""Gate one change set. ``base``/``head`` map knowledge-relative paths
373517
(``repos/...``) to text; ``changes`` are repository-relative git changes.
374518
``external_texts`` (repo path -> text, outside ``knowledge/``) is scanned
375519
for references to rules this change retires or purges. ``release`` is the
376520
trusted current release (from the signed verdict); when given, purges must
377521
carry it as ``purged_at``. A purge is always rejected in the release that
378-
retired the rule."""
522+
retired the rule. ``bootstrap`` is for ``kb init`` only (module docstring)."""
379523
changes = list(changes)
380-
issues = _whitelist_issues(changes)
524+
issues = _whitelist_issues(changes, bootstrap)
381525
blocks: list[Block] = []
382526
retired: list[str] = []
383527
purged: list[tuple[str, str]] = [] # (rule id, page)
@@ -400,7 +544,8 @@ def check_changeset(
400544
for path in sorted(knowledge_paths):
401545
name = PurePosixPath(path).name
402546
if name == INDEX_NAME:
403-
_index_mechanical(path, base.get(path), head.get(path), new_pages, issues, blocks, head)
547+
_index_mechanical(path, base.get(path), head.get(path), new_pages, issues, blocks, head,
548+
base=base, bootstrap=bootstrap)
404549
elif name == TOMBSTONES_NAME:
405550
tombstone_paths.append(path)
406551
elif name == ROUTES_NAME:

0 commit comments

Comments
 (0)