skbrain 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
skbrain/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """skbrain - the operations-brain framework (engine, SB1.x).
2
+
3
+ The ops instance of the brain architecture: canon lives in the estate ``skbrain-ops-<estate>``
4
+ git repo (Karpathy layout, ``[[wikilinks]]``); this package projects it into the
5
+ dedicated ``ops`` schema in skmem-pg (ops.wiki_nodes / ops.wiki_chunks / ops.links,
6
+ + the ops_brain AGE graph) for hybrid RAG.
7
+
8
+ SB1.1 (this deliverable) is the PURE foundation, IO isolated behind interfaces:
9
+ - parser : ops-page markdown -> OpsPage (slug, kind, edges, wikilinks) [pure]
10
+ - chunker : OpsPage body -> OpsChunk list (512-token mxbai discipline) [pure]
11
+ - embed : Embedder protocol + mxbai HTTP client [IO, stubbable]
12
+ - writer : content-hash-diffed UpsertPlan + WriterBackend protocol
13
+
14
+ Live sync, retrieval, secret lint and health checks are wired through the
15
+ ``skbrain`` CLI while keeping database and embedding I/O injectable.
16
+ """
17
+
18
+ from skbrain.chunker import chunk_body, chunk_page
19
+ from skbrain.embed import Embedder, MxbaiEmbedder, embed_chunks
20
+ from skbrain.models import (
21
+ ALL_KINDS,
22
+ CANON_KINDS,
23
+ STATE_KINDS,
24
+ OpsChunk,
25
+ OpsEdge,
26
+ OpsPage,
27
+ )
28
+ from skbrain.parser import (
29
+ OpsParseError,
30
+ extract_wikilinks,
31
+ parse_file,
32
+ parse_page,
33
+ slug_from_path,
34
+ walk_pages,
35
+ )
36
+ from skbrain.writer import (
37
+ NodeState,
38
+ UpsertPlan,
39
+ WriterBackend,
40
+ apply_plan,
41
+ build_plan,
42
+ )
43
+
44
+ __all__ = [
45
+ # models
46
+ "OpsPage",
47
+ "OpsChunk",
48
+ "OpsEdge",
49
+ "CANON_KINDS",
50
+ "STATE_KINDS",
51
+ "ALL_KINDS",
52
+ # parser
53
+ "parse_page",
54
+ "parse_file",
55
+ "walk_pages",
56
+ "extract_wikilinks",
57
+ "slug_from_path",
58
+ "OpsParseError",
59
+ # chunker
60
+ "chunk_body",
61
+ "chunk_page",
62
+ # embed
63
+ "Embedder",
64
+ "MxbaiEmbedder",
65
+ "embed_chunks",
66
+ # writer
67
+ "build_plan",
68
+ "apply_plan",
69
+ "UpsertPlan",
70
+ "NodeState",
71
+ "WriterBackend",
72
+ ]
@@ -0,0 +1,86 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://example.org/skbrain/schemas/page-frontmatter-v1.schema.json",
4
+ "title": "skbrain page front matter v1",
5
+ "description": "Front matter of a canon page (the EntityNode). Applies to every type except lesson, whose front matter is the vendored skrsi lesson-v1 schema. Consumers normalize YAML dates to YYYY-MM-DD strings before validating.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["id", "type", "namespace", "lifecycle", "title", "created", "updated"],
9
+ "properties": {
10
+ "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,127}$"},
11
+ "type": {"enum": ["runbook", "ci", "service", "node", "known-error", "postmortem", "synthesis"]},
12
+ "namespace": {"const": "ops"},
13
+ "lifecycle": {"enum": ["draft", "reviewed", "canon"]},
14
+ "title": {"type": "string", "minLength": 1, "maxLength": 200},
15
+ "tags": {
16
+ "description": "Free topic tags.",
17
+ "type": "array",
18
+ "uniqueItems": true,
19
+ "items": {"type": "string", "minLength": 1, "maxLength": 64}
20
+ },
21
+ "lesson_tags": {
22
+ "description": "Keys of the vendored skrsi lesson-tags-v1 vocabulary only. Empty is allowed.",
23
+ "type": "array",
24
+ "uniqueItems": true,
25
+ "items": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"}
26
+ },
27
+ "edges": {
28
+ "type": "array",
29
+ "maxItems": 100,
30
+ "items": {
31
+ "type": "object",
32
+ "additionalProperties": false,
33
+ "required": ["target", "type"],
34
+ "properties": {
35
+ "target": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,127}$"},
36
+ "type": {"enum": ["remediates", "touches", "depends_on", "runs_on", "hosts", "connects_to", "documents", "about", "promoted"]}
37
+ }
38
+ }
39
+ },
40
+ "state_refs": {
41
+ "description": "Pointers into operational stores, never state itself.",
42
+ "type": "object",
43
+ "additionalProperties": false,
44
+ "properties": {
45
+ "kedb": {"type": "string", "pattern": "^ke-[a-z0-9]+(?:-[a-z0-9]+)*$"},
46
+ "actions": {
47
+ "type": "array",
48
+ "maxItems": 10,
49
+ "uniqueItems": true,
50
+ "items": {"type": "string", "pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"}
51
+ }
52
+ }
53
+ },
54
+ "evidence": {
55
+ "type": "array",
56
+ "maxItems": 20,
57
+ "items": {
58
+ "type": "object",
59
+ "additionalProperties": false,
60
+ "required": ["kind", "ref"],
61
+ "properties": {
62
+ "kind": {"enum": ["skrsi-evidence", "skrsi-evaluation", "pr", "incident", "card"]},
63
+ "ref": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:/#@-]{0,199}$"}
64
+ }
65
+ }
66
+ },
67
+ "skrsi_metrics": {
68
+ "type": "array",
69
+ "maxItems": 20,
70
+ "items": {
71
+ "type": "object",
72
+ "additionalProperties": false,
73
+ "required": ["target", "metric"],
74
+ "properties": {
75
+ "target": {"type": "string", "minLength": 1, "maxLength": 100},
76
+ "metric": {"type": "string", "minLength": 1, "maxLength": 100}
77
+ }
78
+ }
79
+ },
80
+ "origin": {"enum": ["git", "repo", "itil", "cmdb"]},
81
+ "source_repo": {"type": "string", "maxLength": 200},
82
+ "source_path": {"type": "string", "maxLength": 300},
83
+ "created": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"},
84
+ "updated": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"}
85
+ }
86
+ }
@@ -0,0 +1,68 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://example.org/skbrain/schemas/skbrain-instance-v1.schema.json",
4
+ "title": "skbrain instance config v1",
5
+ "description": "The skbrain.yaml at the root of an estate canon repo (skbrain-ops-<estate>). Unknown fields fail. src/skbrain/config.py is the normative validator and must agree with this file (tests/test_config.py checks they do).",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schema", "estate", "namespace", "framework", "forge", "projector_nodes", "skrsi", "writers", "reviewers"],
9
+ "properties": {
10
+ "schema": {"const": "skbrain.instance.v1"},
11
+ "estate": {
12
+ "description": "Short lowercase estate name. The repo must be named skbrain-ops-<estate>.",
13
+ "type": "string",
14
+ "pattern": "^[a-z][a-z0-9]{0,31}$"
15
+ },
16
+ "namespace": {
17
+ "description": "The projection namespace (the Postgres schema). Always ops in v1.",
18
+ "const": "ops"
19
+ },
20
+ "framework": {
21
+ "description": "The pinned framework release tag. Must equal the framework/ submodule tag and the installed package version.",
22
+ "type": "string",
23
+ "pattern": "^skbrain-v[0-9]+\\.[0-9]+\\.[0-9]+$"
24
+ },
25
+ "forge": {
26
+ "description": "Host (optionally host:port) of the forge that holds this canon. No scheme, no path.",
27
+ "type": "string",
28
+ "pattern": "^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$"
29
+ },
30
+ "projector_nodes": {
31
+ "description": "Short hostnames allowed to run skbrain sync --commit for this canon.",
32
+ "type": "array",
33
+ "minItems": 1,
34
+ "uniqueItems": true,
35
+ "items": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9-]*$"}
36
+ },
37
+ "skrsi": {
38
+ "type": "object",
39
+ "additionalProperties": false,
40
+ "required": ["instance"],
41
+ "properties": {
42
+ "instance": {
43
+ "description": "The one skrsi instance (owner/name) allowed to open lesson PRs here and whose evidence refs pages may cite.",
44
+ "type": "string",
45
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$"
46
+ }
47
+ }
48
+ },
49
+ "writers": {
50
+ "description": "Identities allowed to author PRs. CI warns on any other author.",
51
+ "type": "array",
52
+ "uniqueItems": true,
53
+ "items": {"type": "string", "minLength": 1, "maxLength": 64}
54
+ },
55
+ "reviewers": {
56
+ "description": "Identities allowed to review and merge. Should not overlap writers.",
57
+ "type": "array",
58
+ "uniqueItems": true,
59
+ "items": {"type": "string", "minLength": 1, "maxLength": 64}
60
+ },
61
+ "nodes": {
62
+ "description": "Optional. The node page slugs this estate may name. When present, a page naming a node-<x> slug outside this list is flagged (warning in v1).",
63
+ "type": "array",
64
+ "uniqueItems": true,
65
+ "items": {"type": "string", "pattern": "^node-[a-z0-9][a-z0-9-]*$"}
66
+ }
67
+ }
68
+ }
skbrain/check.py ADDED
@@ -0,0 +1,390 @@
1
+ """skbrain.check - the hermetic CI check for an estate canon (design 3.4).
2
+
3
+ ``skbrain check --canon PATH`` is what an instance repo's CI runs against its
4
+ own pinned framework. It reads files only: no database, no DSN, no network.
5
+
6
+ Checks, in order:
7
+
8
+ 1. secret lint (the same rules as ``skbrain lint``);
9
+ 2. every page under ``pages/`` (and only there) parses;
10
+ 3. front matter validates against ``page-frontmatter-v1``; ``lesson`` pages
11
+ validate against the vendored skrsi ``lesson-v1`` schema; ``lesson_tags``
12
+ and lesson ``tags`` are keys of the vendored skrsi ``lesson-tags-v1``;
13
+ 4. the page ``id`` equals the filename stem; wikilinks are bare slugs;
14
+ dangling links are REPORTED (a warning), not failed;
15
+ 5. no em or en dashes in any ``*.md``;
16
+ 6. the estate guard: ``skbrain.yaml`` names the estate the repo is named for,
17
+ evidence refs that cite skrsi name the declared ``skrsi.instance``, and (when
18
+ the config lists ``nodes``) no page names a ``node-<x>`` slug outside it;
19
+ 7. ``index.md`` lists every page (a warning for now).
20
+
21
+ Findings are ``error`` (the check fails) or ``warning`` (reported only).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import datetime as _dt
27
+ import json
28
+ import re
29
+ from dataclasses import asdict, dataclass, field
30
+ from functools import lru_cache
31
+ from importlib import resources
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ import yaml
36
+ from jsonschema import Draft202012Validator
37
+
38
+ from skbrain.config import (
39
+ ConfigError,
40
+ InstanceConfig,
41
+ has_config,
42
+ load_config,
43
+ )
44
+ from skbrain.parser import PAGES_DIR, OpsParseError, extract_wikilinks, parse_page
45
+ from skbrain.secrets import lint_tree
46
+
47
+ ERROR = "error"
48
+ WARNING = "warning"
49
+
50
+ #: Top-level directories that are not canon content.
51
+ _SKIP_TOP_DIRS = frozenset({"framework", ".git", "node_modules", ".venv"})
52
+
53
+ _DASHES = (chr(0x2013), chr(0x2014)) # en dash, em dash
54
+ _RAW_WIKILINK_RE = re.compile(r"\[\[\s*([^\]|]+?)\s*(?:\|[^\]]*)?\]\]")
55
+ _NODE_TOKEN_RE = re.compile(r"(?<![a-z0-9-])node-[a-z0-9][a-z0-9-]*[a-z0-9](?![a-z0-9-])")
56
+ _PINNED_REF_RE = re.compile(r"^(?P<repo>[^@:]+)@(?P<sha>[0-9a-f]{7,40}):")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Finding:
61
+ """One result of the check."""
62
+
63
+ severity: str
64
+ code: str
65
+ path: str
66
+ message: str
67
+
68
+
69
+ @dataclass
70
+ class CheckReport:
71
+ """All findings of one run."""
72
+
73
+ findings: list[Finding] = field(default_factory=list)
74
+ pages: int = 0
75
+
76
+ def add(self, severity: str, code: str, path: str, message: str) -> None:
77
+ self.findings.append(Finding(severity, code, path, message))
78
+
79
+ @property
80
+ def errors(self) -> list[Finding]:
81
+ return [f for f in self.findings if f.severity == ERROR]
82
+
83
+ @property
84
+ def warnings(self) -> list[Finding]:
85
+ return [f for f in self.findings if f.severity == WARNING]
86
+
87
+ @property
88
+ def ok(self) -> bool:
89
+ return not self.errors
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ return {
93
+ "ok": self.ok,
94
+ "pages": self.pages,
95
+ "errors": len(self.errors),
96
+ "warnings": len(self.warnings),
97
+ "findings": [asdict(f) for f in self.findings],
98
+ }
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Schema and vendored data loading
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ def _schema_dir() -> Path:
107
+ """Where ``schemas/`` lives: the repo checkout, else the installed package."""
108
+ repo = Path(__file__).resolve().parents[2] / "schemas"
109
+ if repo.is_dir():
110
+ return repo
111
+ return Path(str(resources.files("skbrain") / "_data" / "schemas"))
112
+
113
+
114
+ def _vendor_dir() -> Path:
115
+ return Path(str(resources.files("skbrain") / "vendor" / "skrsi"))
116
+
117
+
118
+ @lru_cache(maxsize=None)
119
+ def _load_json(path: str) -> Any:
120
+ return json.loads(Path(path).read_text(encoding="utf-8"))
121
+
122
+
123
+ def page_schema() -> dict[str, Any]:
124
+ """The ``page-frontmatter-v1`` schema."""
125
+ return _load_json(str(_schema_dir() / "page-frontmatter-v1.schema.json"))
126
+
127
+
128
+ def lesson_schema() -> dict[str, Any]:
129
+ """The vendored skrsi ``lesson-v1`` schema."""
130
+ return _load_json(str(_vendor_dir() / "lesson-v1.schema.json"))
131
+
132
+
133
+ def lesson_tag_keys() -> frozenset[str]:
134
+ """The keys of the vendored skrsi ``lesson-tags-v1`` vocabulary."""
135
+ return frozenset(_load_json(str(_vendor_dir() / "lesson-tags-v1.json"))["tags"])
136
+
137
+
138
+ def skrsi_tag() -> str:
139
+ """The skrsi release the vendored vocabulary was copied from."""
140
+ return (_vendor_dir() / "SKRSI_TAG").read_text(encoding="utf-8").strip()
141
+
142
+
143
+ def _normalize(value: Any) -> Any:
144
+ """Turn YAML dates into ``YYYY-MM-DD`` strings so schemas can check them."""
145
+ if isinstance(value, _dt.datetime):
146
+ return value.date().isoformat()
147
+ if isinstance(value, _dt.date):
148
+ return value.isoformat()
149
+ if isinstance(value, dict):
150
+ return {k: _normalize(v) for k, v in value.items()}
151
+ if isinstance(value, list):
152
+ return [_normalize(v) for v in value]
153
+ return value
154
+
155
+
156
+ def _schema_errors(schema: dict[str, Any], instance: Any) -> list[str]:
157
+ out = []
158
+ for err in sorted(Draft202012Validator(schema).iter_errors(instance), key=lambda e: list(map(str, e.path))):
159
+ where = ".".join(str(p) for p in err.path) or "(front matter)"
160
+ out.append(f"{where}: {err.message[:200]}")
161
+ return out
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # The check
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def _canon_markdown(canon: Path) -> list[Path]:
170
+ return [
171
+ p
172
+ for p in sorted(canon.rglob("*.md"))
173
+ if p.relative_to(canon).parts[0] not in _SKIP_TOP_DIRS
174
+ ]
175
+
176
+
177
+ def _check_dashes(canon: Path, report: CheckReport) -> None:
178
+ for md in _canon_markdown(canon):
179
+ try:
180
+ text = md.read_text(encoding="utf-8")
181
+ except (OSError, UnicodeDecodeError):
182
+ continue
183
+ lines = [i for i, line in enumerate(text.splitlines(), 1) if any(d in line for d in _DASHES)]
184
+ if lines:
185
+ shown = ", ".join(str(n) for n in lines[:5])
186
+ more = f" (+{len(lines) - 5} more)" if len(lines) > 5 else ""
187
+ report.add(
188
+ ERROR,
189
+ "dash",
190
+ str(md.relative_to(canon)),
191
+ f"em or en dash on line(s) {shown}{more}; use a comma, colon, period or hyphen",
192
+ )
193
+
194
+
195
+ def _check_evidence_refs(
196
+ rel: str, fm: dict[str, Any], cfg: InstanceConfig | None, report: CheckReport
197
+ ) -> None:
198
+ evidence = fm.get("evidence")
199
+ if not isinstance(evidence, list):
200
+ return
201
+ instance_name = cfg.skrsi_instance.split("/", 1)[1] if cfg else None
202
+ for item in evidence:
203
+ if not isinstance(item, dict):
204
+ continue
205
+ kind, ref = str(item.get("kind", "")), str(item.get("ref", ""))
206
+ if not (kind.startswith("skrsi-") or ref.startswith("skrsi-config-")):
207
+ continue
208
+ m = _PINNED_REF_RE.match(ref)
209
+ if not m:
210
+ report.add(
211
+ ERROR,
212
+ "evidence-not-pinned",
213
+ rel,
214
+ f"evidence ref {ref!r} must be commit-pinned: <repo>@<sha>:<path>",
215
+ )
216
+ continue
217
+ if instance_name is not None and m.group("repo") not in (instance_name, cfg.skrsi_instance):
218
+ report.add(
219
+ ERROR,
220
+ "evidence-wrong-instance",
221
+ rel,
222
+ f"evidence ref names {m.group('repo')!r} but this canon's skrsi.instance is "
223
+ f"{cfg.skrsi_instance!r} (an evidence ref from another estate does not belong here)",
224
+ )
225
+
226
+
227
+ def run_check(canon: Path | str, *, repo_name: str | None = None) -> CheckReport:
228
+ """Run every check against *canon* and return the report. Never raises on content.
229
+
230
+ Args:
231
+ canon: the canon repo root.
232
+ repo_name: the repo's name when the directory name is not it (CI
233
+ checkouts); defaults to the canon directory's own name.
234
+ """
235
+ root = Path(canon).expanduser()
236
+ report = CheckReport()
237
+ if not root.is_dir():
238
+ report.add(ERROR, "canon", str(root), "canon directory does not exist")
239
+ return report
240
+ root = root.resolve()
241
+
242
+ # 6a. config + estate wall (loaded first: other checks use it)
243
+ cfg: InstanceConfig | None = None
244
+ if not has_config(root):
245
+ report.add(ERROR, "config", "skbrain.yaml", "skbrain.yaml is missing")
246
+ else:
247
+ try:
248
+ cfg = load_config(root)
249
+ except ConfigError as exc:
250
+ report.add(ERROR, "config", "skbrain.yaml", str(exc))
251
+ if cfg is not None:
252
+ name = repo_name if repo_name else root.name
253
+ if not name.endswith(f"-{cfg.estate}"):
254
+ report.add(
255
+ ERROR,
256
+ "estate-repo-name",
257
+ "skbrain.yaml",
258
+ f"estate is {cfg.estate!r} but the repo is named {name!r}; "
259
+ f"an estate canon is named {cfg.repo_name}",
260
+ )
261
+ overlap = sorted(set(cfg.writers) & set(cfg.reviewers))
262
+ if overlap:
263
+ report.add(
264
+ WARNING,
265
+ "writer-reviewer-overlap",
266
+ "skbrain.yaml",
267
+ f"identities are both writer and reviewer: {', '.join(overlap)}",
268
+ )
269
+
270
+ # 1. secret lint
271
+ for f in lint_tree(root):
272
+ report.add(ERROR, "secret", f"{Path(f.path).name}:{f.line}", f"secret-shaped material ({f.rule})")
273
+
274
+ # 5. dashes
275
+ _check_dashes(root, report)
276
+
277
+ # 2. pages
278
+ pages_root = root / PAGES_DIR
279
+ if not pages_root.is_dir():
280
+ report.add(ERROR, "pages", PAGES_DIR, "canon has no pages/ directory")
281
+ return report
282
+
283
+ known_tags = lesson_tag_keys()
284
+ slugs: dict[str, str] = {}
285
+ parsed: list[tuple[str, Any]] = []
286
+ for md in sorted(pages_root.rglob("*.md")):
287
+ if md.stem.startswith("_"):
288
+ continue
289
+ rel = str(md.relative_to(root))
290
+ report.pages += 1
291
+ slug = md.stem
292
+ if slug != slug.lower():
293
+ report.add(ERROR, "slug", rel, "filename stem must be lowercase (it is the page id)")
294
+ slug = slug.lower()
295
+ if slug in slugs:
296
+ report.add(ERROR, "duplicate-slug", rel, f"slug {slug!r} is also used by {slugs[slug]}")
297
+ slugs[slug] = rel
298
+ try:
299
+ page = parse_page(md.read_text(encoding="utf-8"), slug=slug)
300
+ except OpsParseError as exc:
301
+ report.add(ERROR, "parse", rel, str(exc))
302
+ continue
303
+ except UnicodeDecodeError:
304
+ report.add(ERROR, "parse", rel, "page is not valid UTF-8")
305
+ continue
306
+ fm = _normalize(page.frontmatter)
307
+ if "id" not in fm:
308
+ report.add(ERROR, "slug", rel, "front matter has no id (id must equal the filename stem)")
309
+ parsed.append((rel, page))
310
+
311
+ # 3. front matter
312
+ if page.kind == "lesson":
313
+ for msg in _schema_errors(lesson_schema(), fm):
314
+ report.add(ERROR, "frontmatter", rel, f"lesson-v1: {msg}")
315
+ bad = sorted(set(fm.get("tags") or []) - known_tags) if isinstance(fm.get("tags"), list) else []
316
+ if bad:
317
+ report.add(
318
+ ERROR,
319
+ "lesson-tag",
320
+ rel,
321
+ f"lesson tags not in skrsi lesson-tags-v1 ({skrsi_tag()}): {', '.join(map(str, bad))}",
322
+ )
323
+ else:
324
+ for msg in _schema_errors(page_schema(), fm):
325
+ report.add(ERROR, "frontmatter", rel, msg)
326
+ lt = fm.get("lesson_tags")
327
+ if isinstance(lt, list):
328
+ bad = sorted(set(map(str, lt)) - known_tags)
329
+ if bad:
330
+ report.add(
331
+ ERROR,
332
+ "lesson-tag",
333
+ rel,
334
+ f"lesson_tags not in skrsi lesson-tags-v1 ({skrsi_tag()}): {', '.join(bad)}",
335
+ )
336
+
337
+ # 4. bare-slug wikilinks
338
+ for raw in _RAW_WIKILINK_RE.findall(page.body_md):
339
+ t = raw.strip()
340
+ if "/" in t or "\\" in t or " " in t or t.lower().endswith(".md"):
341
+ report.add(
342
+ ERROR,
343
+ "wikilink-not-bare",
344
+ rel,
345
+ f"wikilink [[{t}]] must be a bare slug, not a path or title",
346
+ )
347
+
348
+ # 6b. evidence refs
349
+ _check_evidence_refs(rel, fm, cfg, report)
350
+
351
+ # 6c. node-name guard (warning in v1)
352
+ if cfg is not None and cfg.nodes is not None:
353
+ haystack = yaml.safe_dump(page.frontmatter, default_flow_style=True) + "\n" + page.body_md
354
+ outside = sorted({t for t in _NODE_TOKEN_RE.findall(haystack) if t not in cfg.nodes})
355
+ if outside:
356
+ report.add(
357
+ WARNING,
358
+ "node-outside-estate",
359
+ rel,
360
+ f"names node slug(s) not in this estate's nodes list: {', '.join(outside)}",
361
+ )
362
+
363
+ # 4b. dangling links (reported, not failed)
364
+ for rel, page in parsed:
365
+ targets = set(page.wikilinks) | {e.dst for e in page.edges}
366
+ dangling = sorted(t for t in targets if t not in slugs)
367
+ if dangling:
368
+ report.add(
369
+ WARNING,
370
+ "dangling-link",
371
+ rel,
372
+ f"links to slug(s) with no page yet: {', '.join(dangling)}",
373
+ )
374
+
375
+ # 7. index.md lists every page (warning for now)
376
+ index = root / "index.md"
377
+ if not index.is_file():
378
+ report.add(WARNING, "index-missing", "index.md", "index.md is missing")
379
+ else:
380
+ listed = set(extract_wikilinks(index.read_text(encoding="utf-8")))
381
+ unlisted = sorted(set(slugs) - listed)
382
+ if unlisted:
383
+ report.add(
384
+ WARNING,
385
+ "index-unlisted",
386
+ "index.md",
387
+ f"{len(unlisted)} page(s) not listed in index.md: {', '.join(unlisted[:8])}"
388
+ + (", ..." if len(unlisted) > 8 else ""),
389
+ )
390
+ return report