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 +72 -0
- skbrain/_data/schemas/page-frontmatter-v1.schema.json +86 -0
- skbrain/_data/schemas/skbrain-instance-v1.schema.json +68 -0
- skbrain/check.py +390 -0
- skbrain/chunker.py +142 -0
- skbrain/cli.py +218 -0
- skbrain/cmdb_drift.py +215 -0
- skbrain/config.py +247 -0
- skbrain/doctor.py +330 -0
- skbrain/embed.py +132 -0
- skbrain/kedb_coverage.py +215 -0
- skbrain/models.py +133 -0
- skbrain/parser.py +204 -0
- skbrain/postgres.py +165 -0
- skbrain/read_api.py +120 -0
- skbrain/secrets.py +58 -0
- skbrain/vendor/__init__.py +0 -0
- skbrain/vendor/skrsi/README.md +13 -0
- skbrain/vendor/skrsi/SKRSI_TAG +1 -0
- skbrain/vendor/skrsi/lesson-tags-v1.json +34 -0
- skbrain/vendor/skrsi/lesson-v1.schema.json +117 -0
- skbrain/writer.py +156 -0
- skbrain-1.0.1.dist-info/METADATA +80 -0
- skbrain-1.0.1.dist-info/RECORD +27 -0
- skbrain-1.0.1.dist-info/WHEEL +4 -0
- skbrain-1.0.1.dist-info/entry_points.txt +2 -0
- skbrain-1.0.1.dist-info/licenses/LICENSE +216 -0
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
|