Skip to content

Commit f325875

Browse files
tzhouamclaude
andcommitted
Validate the adapter's knowledge_lifecycle.init block for kb init
kb init (design kb-init v3 §3) reads its per-repository settings from a new, strictly validated `init` sub-mapping of `knowledge_lifecycle`. Parsing produces an `InitConfig` on `RepoLifecycle.init` (None when absent): - seeds: knowledge-relative paths under general/ or repos/<other repo>/; a seed naming the repository being initialised, an absolute path, empty, `.` or `..` segments, or duplicates are refused at parse time - doc_globs default to README* + docs/**/*.md (what build_doc_corpus reads) plus CONTRIBUTING.md and AGENTS.md - source_roots default to the manifest's ut_coverage.source_roots - exclude, module_depth (>=1, 2), min_module_loc (>=0, 300), pr_window {count, max_age_days} (>0, 200/180), coverage_target ((0,1], 0.85), budget_usd (>0, 30), judge_call_usd (>=0, 0.50); non-finite numbers (.nan, .inf) are refused `validate_seeds(init, knowledge_root)` is the run-time check that every seed exists in the knowledge tree and does not resolve outside it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fd79058 commit f325875

3 files changed

Lines changed: 270 additions & 1 deletion

File tree

‎doc/architecture/SPEC/kb_service.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,3 +311,14 @@ x 必须为正。因阈值停下的调用(`stop_reason="max_budget"`)→ `Mo
311311
不传上限的现有调用方行为不变:该参数根本不会发给 transport。
312312
`runtime.trace_recorder` 把阈值与花费持久化到 `model_call` 记录的 `result.max_budget_usd` / `result.cost_usd`
313313
(未设或未知为 null),包括 transport 抛错的失败记录。
314+
315+
## 2026-09-30 `kb init` 的 adapter 配置
316+
`knowledge_lifecycle.init`(可选子映射,严格校验,未知键抛 `LifecycleConfigError`)解析为 `InitConfig`,挂在
317+
`RepoLifecycle.init`(缺省为 `None`):`seeds`(知识库相对路径,只能是 `general/...` 或 `repos/<其他仓库>/...`,
318+
不得指向本仓库、不得为绝对路径或含空段/`.`/`..`,不得重复)、`doc_globs`(缺省 `DEFAULT_DOC_GLOBS` =
319+
`README*`、`docs/**/*.md`(与 `profiles/establish.build_doc_corpus` 一致)加 `CONTRIBUTING.md`、`AGENTS.md`)、
320+
`source_roots`(缺省取同一 manifest 的 `ut_coverage.source_roots`)、`exclude`、`module_depth`(≥1,缺省 2)、
321+
`min_module_loc`(≥0,缺省 300)、`pr_window: {count, max_age_days}`(>0,缺省 200 / 180)、`coverage_target`
322+
((0, 1],缺省 0.85)、`budget_usd`(>0,缺省 30)、`judge_call_usd`(≥0,缺省 0.50)。`validate_seeds(init,
323+
knowledge_root)` 是运行时检查:种子必须是知识树中已存在的文件或目录,解析后不得逃出知识根目录,逐条返回问题。
324+
解析只做静态校验,不读知识树。测试:`test_kb_init_config.py`。

‎src/infermatrix_copilot/kb_service/config.py‎

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
import math
1112
import re
1213
from dataclasses import dataclass, field
1314
from pathlib import Path
@@ -23,8 +24,15 @@
2324
_KEYS = {
2425
"enabled", "mode", "knowledge_dir", "intake", "release", "sweep",
2526
"upstream_visibility", "circuit_breaker", "protected_rules",
26-
"calibration_set", "model_dir",
27+
"calibration_set", "model_dir", "init",
2728
}
29+
_INIT_KEYS = {
30+
"seeds", "doc_globs", "source_roots", "exclude", "module_depth", "min_module_loc",
31+
"pr_window", "coverage_target", "budget_usd", "judge_call_usd",
32+
}
33+
# README* + docs/**/*.md is what profiles/establish.build_doc_corpus reads;
34+
# the contribution and agent guides carry most cross-doc invariants.
35+
DEFAULT_DOC_GLOBS = ("README*", "docs/**/*.md", "CONTRIBUTING.md", "AGENTS.md")
2836

2937

3038
class LifecycleConfigError(ValueError):
@@ -46,6 +54,22 @@ class ReleaseConfig:
4654
auditor: str = "" # plugin path relative to the adapter directory
4755

4856

57+
@dataclass(frozen=True)
58+
class InitConfig:
59+
"""How ``kb init`` bootstraps the repository's knowledge (design kb-init §3)."""
60+
seeds: tuple[str, ...] = () # knowledge-relative: general/... or repos/<other>/...
61+
doc_globs: tuple[str, ...] = DEFAULT_DOC_GLOBS
62+
source_roots: tuple[str, ...] = ()
63+
exclude: tuple[str, ...] = ()
64+
module_depth: int = 2
65+
min_module_loc: int = 300
66+
pr_window_count: int = 200
67+
pr_window_max_age_days: int = 180
68+
coverage_target: float = 0.85
69+
budget_usd: float = 30.0
70+
judge_call_usd: float = 0.50
71+
72+
4973
@dataclass(frozen=True)
5074
class RepoLifecycle:
5175
repo: str # knowledge repo name, or "general"
@@ -63,6 +87,7 @@ class RepoLifecycle:
6387
calibration_set: str = "" # directory relative to the adapter (ships in the wheel)
6488
model_dir: str = ""
6589
adapter_dir: Path | None = None
90+
init: InitConfig | None = None
6691

6792
@property
6893
def auto_merge(self) -> bool:
@@ -92,6 +117,96 @@ def _mapping(value: object, where: str, allowed: set[str]) -> dict:
92117
return value
93118

94119

120+
def _strings(value: object, where: str) -> tuple[str, ...]:
121+
if value is None:
122+
return ()
123+
if not isinstance(value, list) or any(not isinstance(v, str) or not v.strip() for v in value):
124+
raise LifecycleConfigError(f"{where} must be a list of non-empty strings")
125+
return tuple(v.strip() for v in value)
126+
127+
128+
def _int(value: object, where: str, *, minimum: int) -> int:
129+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
130+
raise LifecycleConfigError(f"{where} must be an integer >= {minimum}")
131+
return value
132+
133+
134+
def _number(value: object, where: str) -> float:
135+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
136+
raise LifecycleConfigError(f"{where} must be a finite number")
137+
return float(value)
138+
139+
140+
def _seed_problem(seed: str, repo: str) -> str:
141+
parts = seed.split("/")
142+
if seed.startswith("/") or "\\" in seed or any(p in ("", ".", "..") for p in parts):
143+
return "must be a relative knowledge path without empty, . or .. segments"
144+
if parts[0] == GENERAL:
145+
return ""
146+
if parts[0] == "repos" and len(parts) >= 2 and _NAME.fullmatch(parts[1]):
147+
return "names the repository being initialised" if parts[1] == repo else ""
148+
return "must start with general/ or repos/<other repo>/"
149+
150+
151+
def parse_init(section: object, where: str, *, repo: str, manifest: dict) -> InitConfig | None:
152+
"""The ``init`` sub-mapping, strictly validated; None when absent."""
153+
if section is None:
154+
return None
155+
data = _mapping(section, where, _INIT_KEYS)
156+
seeds = _strings(data.get("seeds"), f"{where}.seeds")
157+
for seed in seeds:
158+
problem = _seed_problem(seed, repo)
159+
if problem:
160+
raise LifecycleConfigError(f"{where}.seeds: {seed!r} {problem}")
161+
if len(set(seeds)) != len(seeds):
162+
raise LifecycleConfigError(f"{where}.seeds has duplicates")
163+
if "source_roots" in data:
164+
source_roots = _strings(data["source_roots"], f"{where}.source_roots")
165+
else:
166+
ut = manifest.get("ut_coverage") if isinstance(manifest.get("ut_coverage"), dict) else {}
167+
roots = ut.get("source_roots") if isinstance(ut.get("source_roots"), list) else []
168+
source_roots = tuple(str(r).strip() for r in roots if isinstance(r, str) and r.strip())
169+
window = _mapping(data.get("pr_window"), f"{where}.pr_window", {"count", "max_age_days"})
170+
coverage = _number(data.get("coverage_target", 0.85), f"{where}.coverage_target")
171+
if not 0 < coverage <= 1:
172+
raise LifecycleConfigError(f"{where}.coverage_target must be in (0, 1]")
173+
budget = _number(data.get("budget_usd", 30.0), f"{where}.budget_usd")
174+
if budget <= 0:
175+
raise LifecycleConfigError(f"{where}.budget_usd must be > 0")
176+
judge_call = _number(data.get("judge_call_usd", 0.50), f"{where}.judge_call_usd")
177+
if judge_call < 0:
178+
raise LifecycleConfigError(f"{where}.judge_call_usd must be >= 0")
179+
return InitConfig(
180+
seeds=seeds,
181+
doc_globs=(_strings(data["doc_globs"], f"{where}.doc_globs") if "doc_globs" in data
182+
else DEFAULT_DOC_GLOBS),
183+
source_roots=source_roots,
184+
exclude=_strings(data.get("exclude"), f"{where}.exclude"),
185+
module_depth=_int(data.get("module_depth", 2), f"{where}.module_depth", minimum=1),
186+
min_module_loc=_int(data.get("min_module_loc", 300), f"{where}.min_module_loc", minimum=0),
187+
pr_window_count=_int(window.get("count", 200), f"{where}.pr_window.count", minimum=1),
188+
pr_window_max_age_days=_int(window.get("max_age_days", 180),
189+
f"{where}.pr_window.max_age_days", minimum=1),
190+
coverage_target=coverage,
191+
budget_usd=budget,
192+
judge_call_usd=judge_call,
193+
)
194+
195+
196+
def validate_seeds(init: InitConfig, knowledge_root: Path) -> list[str]:
197+
"""Run-time check: every seed names an existing file or directory under
198+
the knowledge root. Returns one problem per missing seed."""
199+
root = Path(knowledge_root).resolve()
200+
problems = []
201+
for seed in init.seeds:
202+
target = (root / seed).resolve()
203+
if not target.is_relative_to(root):
204+
problems.append(f"seed {seed} escapes the knowledge root")
205+
elif not target.exists():
206+
problems.append(f"seed {seed} does not exist in the knowledge tree")
207+
return problems
208+
209+
95210
def parse_lifecycle(adapter: RepoAdapter) -> RepoLifecycle | None:
96211
"""The adapter's lifecycle, or None when it has no knowledge_lifecycle."""
97212
section = adapter.manifest.get("knowledge_lifecycle")
@@ -171,6 +286,7 @@ def parse_lifecycle(adapter: RepoAdapter) -> RepoLifecycle | None:
171286
calibration_set=str(data.get("calibration_set") or ""),
172287
model_dir=str(data.get("model_dir") or ""),
173288
adapter_dir=Path(adapter.root),
289+
init=parse_init(data.get("init"), f"{where}.init", repo=repo, manifest=adapter.manifest),
174290
)
175291

176292

‎test/test_kb_init_config.py‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""kb init: the adapter's knowledge_lifecycle.init block (design kb-init §3)."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import pytest
8+
import yaml
9+
10+
from infermatrix_copilot.adapters.base import RepoAdapter
11+
from infermatrix_copilot.kb_service.config import (
12+
DEFAULT_DOC_GLOBS, InitConfig, LifecycleConfigError, parse_lifecycle, validate_seeds,
13+
)
14+
15+
16+
def _adapter(tmp_path: Path, init: object = None, *, extra: dict | None = None,
17+
with_init: bool = True) -> RepoAdapter:
18+
root = tmp_path / "demo_adapter"
19+
root.mkdir(exist_ok=True)
20+
section: dict = {"enabled": False, "mode": "shadow"}
21+
if with_init:
22+
section["init"] = init
23+
manifest = {
24+
"name": "demo_adapter",
25+
"repo": {"full_name": "org/demo", "path": "/x"},
26+
"knowledge": {"repo_subdir": "repos/demo"},
27+
"knowledge_lifecycle": section,
28+
**(extra or {}),
29+
}
30+
(root / "manifest.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8")
31+
return RepoAdapter(name="demo_adapter", root=root, manifest=manifest)
32+
33+
34+
def test_absent_init_is_none(tmp_path):
35+
assert parse_lifecycle(_adapter(tmp_path, with_init=False)).init is None
36+
37+
38+
def test_empty_init_gets_defaults(tmp_path):
39+
init = parse_lifecycle(_adapter(tmp_path, {})).init
40+
assert init == InitConfig()
41+
assert init.doc_globs == DEFAULT_DOC_GLOBS
42+
assert (init.module_depth, init.min_module_loc) == (2, 300)
43+
assert (init.pr_window_count, init.pr_window_max_age_days) == (200, 180)
44+
assert (init.coverage_target, init.budget_usd, init.judge_call_usd) == (0.85, 30.0, 0.50)
45+
assert init.seeds == init.source_roots == init.exclude == ()
46+
47+
48+
def test_full_init_parses(tmp_path):
49+
init = parse_lifecycle(_adapter(tmp_path, {
50+
"seeds": ["general/review/x.md", "repos/other/ci"],
51+
"doc_globs": ["README.md"],
52+
"source_roots": ["pkg/"],
53+
"exclude": ["*/tests/*"],
54+
"module_depth": 3,
55+
"min_module_loc": 0,
56+
"pr_window": {"count": 50, "max_age_days": 30},
57+
"coverage_target": 1,
58+
"budget_usd": 5,
59+
"judge_call_usd": 0,
60+
})).init
61+
assert init.seeds == ("general/review/x.md", "repos/other/ci")
62+
assert init.doc_globs == ("README.md",)
63+
assert init.source_roots == ("pkg/",)
64+
assert init.exclude == ("*/tests/*",)
65+
assert (init.module_depth, init.min_module_loc) == (3, 0)
66+
assert (init.pr_window_count, init.pr_window_max_age_days) == (50, 30)
67+
assert (init.coverage_target, init.budget_usd, init.judge_call_usd) == (1.0, 5.0, 0.0)
68+
69+
70+
def test_source_roots_default_to_ut_coverage(tmp_path):
71+
adapter = _adapter(tmp_path, {}, extra={"ut_coverage": {"source_roots": ["pkg/", "csrc/"]}})
72+
assert parse_lifecycle(adapter).init.source_roots == ("pkg/", "csrc/")
73+
adapter = _adapter(tmp_path, {"source_roots": ["only/"]},
74+
extra={"ut_coverage": {"source_roots": ["pkg/"]}})
75+
assert parse_lifecycle(adapter).init.source_roots == ("only/",)
76+
77+
78+
@pytest.mark.parametrize("init, message", [
79+
("nope", "must be a mapping"),
80+
({"surprise": 1}, "unknown keys"),
81+
({"pr_window": {"count": 1, "days": 2}}, "unknown keys"),
82+
({"seeds": "general/x.md"}, "list of non-empty strings"),
83+
({"seeds": [""]}, "list of non-empty strings"),
84+
({"seeds": ["general/x.md", "general/x.md"]}, "duplicates"),
85+
({"seeds": ["repos/demo/rules.md"]}, "names the repository being initialised"),
86+
({"seeds": ["/abs/general/x.md"]}, "relative knowledge path"),
87+
({"seeds": ["general/../repos/demo/x.md"]}, "relative knowledge path"),
88+
({"seeds": ["general//x.md"]}, "relative knowledge path"),
89+
({"seeds": ["skills/x.md"]}, "general/ or repos/"),
90+
({"seeds": ["repos"]}, "general/ or repos/"),
91+
({"seeds": ["repos/Bad Name/x.md"]}, "general/ or repos/"),
92+
({"doc_globs": [1]}, "list of non-empty strings"),
93+
({"exclude": "x"}, "list of non-empty strings"),
94+
({"module_depth": 0}, "module_depth must be an integer >= 1"),
95+
({"module_depth": True}, "module_depth must be an integer"),
96+
({"min_module_loc": -1}, "min_module_loc must be an integer >= 0"),
97+
({"min_module_loc": 1.5}, "min_module_loc must be an integer"),
98+
({"pr_window": {"count": 0}}, "pr_window.count"),
99+
({"pr_window": {"max_age_days": 0}}, "pr_window.max_age_days"),
100+
({"coverage_target": 0}, "coverage_target must be in"),
101+
({"coverage_target": 1.01}, "coverage_target must be in"),
102+
({"coverage_target": "high"}, "coverage_target must be a finite number"),
103+
({"budget_usd": 0}, "budget_usd must be > 0"),
104+
({"budget_usd": True}, "budget_usd must be a finite number"),
105+
({"judge_call_usd": -0.1}, "judge_call_usd must be >= 0"),
106+
({"budget_usd": float("nan")}, "budget_usd must be a finite number"),
107+
({"budget_usd": float("inf")}, "budget_usd must be a finite number"),
108+
({"judge_call_usd": float("nan")}, "judge_call_usd must be a finite number"),
109+
({"judge_call_usd": float("inf")}, "judge_call_usd must be a finite number"),
110+
({"coverage_target": float("nan")}, "coverage_target must be a finite number"),
111+
])
112+
def test_invalid_init_is_rejected(tmp_path, init, message):
113+
with pytest.raises(LifecycleConfigError, match=message):
114+
parse_lifecycle(_adapter(tmp_path, init))
115+
116+
117+
def test_non_finite_yaml_values_are_rejected(tmp_path):
118+
adapter = _adapter(tmp_path, {})
119+
adapter.manifest["knowledge_lifecycle"]["init"] = yaml.safe_load("budget_usd: .inf")
120+
with pytest.raises(LifecycleConfigError, match="finite"):
121+
parse_lifecycle(adapter)
122+
123+
124+
def test_validate_seeds_reports_missing_paths(tmp_path):
125+
root = tmp_path / "knowledge"
126+
(root / "general" / "review").mkdir(parents=True)
127+
(root / "general" / "review" / "x.md").write_text("x", encoding="utf-8")
128+
(root / "repos" / "other" / "ci").mkdir(parents=True)
129+
ok = InitConfig(seeds=("general/review/x.md", "repos/other/ci"))
130+
assert validate_seeds(ok, root) == []
131+
missing = InitConfig(seeds=("general/review/x.md", "repos/other/gone.md"))
132+
assert validate_seeds(missing, root) == ["seed repos/other/gone.md does not exist in the knowledge tree"]
133+
134+
135+
def test_validate_seeds_refuses_escape(tmp_path):
136+
root = tmp_path / "knowledge"
137+
root.mkdir()
138+
(tmp_path / "outside.md").write_text("x", encoding="utf-8")
139+
(root / "general").mkdir()
140+
(root / "general" / "link.md").symlink_to(tmp_path / "outside.md")
141+
assert validate_seeds(InitConfig(seeds=("general/link.md",)), root) == [
142+
"seed general/link.md escapes the knowledge root"]

0 commit comments

Comments
 (0)