@antoneeo/kb-agentic-skill 1.0.0
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.
- package/CHANGELOG.md +332 -0
- package/README.md +85 -0
- package/gemini-extension.json +6 -0
- package/package.json +50 -0
- package/scripts/init.js +216 -0
- package/scripts/lib.js +152 -0
- package/scripts/postinstall.js +42 -0
- package/scripts/preuninstall.js +17 -0
- package/skills/kb-agentic-skill/ENFORCEMENT.md +123 -0
- package/skills/kb-agentic-skill/SKILL.md +134 -0
- package/skills/kb-agentic-skill/dispatch.md +87 -0
- package/skills/kb-agentic-skill/distillation.md +79 -0
- package/skills/kb-agentic-skill/elicitation.md +131 -0
- package/skills/kb-agentic-skill/guides.md +287 -0
- package/skills/kb-agentic-skill/reconciliation.md +79 -0
- package/skills/kb-agentic-skill/review.md +168 -0
- package/skills/kb-agentic-skill/routing.md +100 -0
- package/skills/kb-agentic-skill/scripts/sdlc_check.py +846 -0
- package/skills/kb-agentic-skill/scripts/sdlc_core.py +1996 -0
- package/skills/kb-agentic-skill/taxonomy.md +80 -0
- package/skills/kb-agentic-skill/templates.md +579 -0
- package/skills/kb-agentic-skill/vision.md +245 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""KB Agentic — the KNOWLEDGE domain entry point, and its overlay.
|
|
4
|
+
|
|
5
|
+
The family's shared spine lives in `sdlc_core.py`, byte-identical in every
|
|
6
|
+
distribution; this file is the knowledge OVERLAY on top of it. Since F-024/F-025
|
|
7
|
+
it is deliberately not thin: the claim ledger (assertions with provenance, held
|
|
8
|
+
conflicts) and the topic graph (placement, edges, integrity) are genuinely this
|
|
9
|
+
domain's own and stay here. What converges is the spine, not the knowledge.
|
|
10
|
+
|
|
11
|
+
From the overlay:
|
|
12
|
+
claim-id <path> <locator> [--qty "..."] compute a claim id
|
|
13
|
+
claim-id --fill <file> fill missing ids in a claim table
|
|
14
|
+
graph [--root R] topic-graph integrity checks
|
|
15
|
+
corpus [--root R] corpus checks (supersession, digests)
|
|
16
|
+
index / validate / check spine behaviour PLUS the kb surface
|
|
17
|
+
(byte-identical output on a tree
|
|
18
|
+
with no topics/ or corpus/)
|
|
19
|
+
|
|
20
|
+
Every other spine subcommand (stale, mark, gate, orient, plan, migrate, and any
|
|
21
|
+
future one) is FORWARDED to the spine untouched: dispatch is "not intercepted ->
|
|
22
|
+
forward", never a hand-copied command tuple, so a new spine command cannot be
|
|
23
|
+
silently dropped here.
|
|
24
|
+
|
|
25
|
+
Both files must sit in the same directory. Copying this one alone fails at
|
|
26
|
+
import, loudly, which is the intended failure. The core alone stays runnable,
|
|
27
|
+
but for kb it no longer behaves identically: it runs none of the claim or graph
|
|
28
|
+
checks (ENFORCEMENT.md says so).
|
|
29
|
+
|
|
30
|
+
Pure stdlib. ASCII output only (Windows-console safe).
|
|
31
|
+
"""
|
|
32
|
+
import argparse
|
|
33
|
+
import hashlib
|
|
34
|
+
import re
|
|
35
|
+
import sys
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
import sdlc_core
|
|
42
|
+
except ImportError as exc: # pragma: no cover - exercised by TS12, not by unit tests
|
|
43
|
+
sys.stderr.write(
|
|
44
|
+
"[ERROR] sdlc_check.py cannot find sdlc_core.py next to it: " + str(exc) + "\n"
|
|
45
|
+
" The validator ships as TWO files since the multi-domain core.\n"
|
|
46
|
+
" Copy both, or run sdlc_core.py directly.\n")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
# Re-export the core's surface: existing importers (`import sdlc_check as sc`)
|
|
50
|
+
# and the test batteries reach for these names on this module. The shared
|
|
51
|
+
# batteries bind `sdlc_core` directly, so nothing defined below can shadow what
|
|
52
|
+
# they test; overlay names carry a kb_ prefix for readability, not as a guard.
|
|
53
|
+
from sdlc_core import * # noqa: F401,F403
|
|
54
|
+
from sdlc_core import _map_refs # noqa: F401 underscore helper used by the batteries
|
|
55
|
+
|
|
56
|
+
DOMAIN = "knowledge"
|
|
57
|
+
|
|
58
|
+
sdlc_core.set_entry_point(DOMAIN, provides=("code", "knowledge"))
|
|
59
|
+
|
|
60
|
+
sdlc_core.set_profile(
|
|
61
|
+
skill_name="kb-agentic",
|
|
62
|
+
unit_noun="topic",
|
|
63
|
+
support_files=("templates.md", "taxonomy.md", "guides.md", "vision.md",
|
|
64
|
+
"distillation.md", "reconciliation.md", "elicitation.md",
|
|
65
|
+
"review.md", "dispatch.md", "routing.md", "ENFORCEMENT.md"),
|
|
66
|
+
capabilities=(
|
|
67
|
+
# spine
|
|
68
|
+
"triage", "write_triggers", "workstream_registry", "vision_gate",
|
|
69
|
+
"design_review_gate", "guide_router", "worktree_hygiene",
|
|
70
|
+
# knowledge overlay
|
|
71
|
+
"taxonomy_pass", "subagent_dispatch", "question_discipline",
|
|
72
|
+
),
|
|
73
|
+
design_gate_between=("### 3. Request Analysis & Taxonomy Pass",
|
|
74
|
+
"### 4. Knowledge Processing & Distillation"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# --------------------------------------------------------------- claim ledger
|
|
78
|
+
# F-025. The machine detects and holds; it never decides. Every function below
|
|
79
|
+
# is pure and stdlib — the battery calls them directly.
|
|
80
|
+
|
|
81
|
+
CLAIM_COLUMNS = ("id", "claim", "valid", "qty", "about", "source", "prov", "state")
|
|
82
|
+
CLAIM_HEADING = "## Claims"
|
|
83
|
+
PROVENANCES = ("GIVEN", "ELICITED", "DERIVED", "RULING")
|
|
84
|
+
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
85
|
+
OWNS_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}/[a-z0-9][a-z0-9-]{0,63}$")
|
|
86
|
+
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
87
|
+
LOC_PAGE_RE = re.compile(r"^p=(\d+)@(\d+)-(\d+)$")
|
|
88
|
+
LOC_LINE_RE = re.compile(r"^L(\d+)-(\d+)$")
|
|
89
|
+
LOC_CELL_RE = re.compile(r"^Sheet[^!]+![A-Z]+\d+$")
|
|
90
|
+
|
|
91
|
+
# Unit conventions, documented in templates.md. effort in person-days
|
|
92
|
+
# (8h day, 5d week, 21d month); duration in calendar days; cost within ONE
|
|
93
|
+
# currency (no exchange rates offline); count unit-matched.
|
|
94
|
+
QTY_UNITS = {
|
|
95
|
+
"effort": {"h": 0.125, "d": 1.0, "w": 5.0, "mo": 21.0, "fte-mo": 21.0},
|
|
96
|
+
"duration": {"h": 1.0 / 24, "d": 1.0, "w": 7.0, "mo": 30.0},
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def kb_qty_norm(text):
|
|
101
|
+
"""'12000 EUR cost' -> ('cost', 12000.0, 'EUR') or None for '-'.
|
|
102
|
+
|
|
103
|
+
Raises ValueError on anything else: a malformed quantity must be a finding,
|
|
104
|
+
never a silently-ignored cell.
|
|
105
|
+
"""
|
|
106
|
+
text = (text or "").strip()
|
|
107
|
+
if text in ("", "-"):
|
|
108
|
+
return None
|
|
109
|
+
parts = text.split()
|
|
110
|
+
if len(parts) != 3:
|
|
111
|
+
raise ValueError("qty must be '<value> <unit> <kind>' or '-': %r" % text)
|
|
112
|
+
value, unit, kind = parts
|
|
113
|
+
try:
|
|
114
|
+
value = float(value)
|
|
115
|
+
except ValueError:
|
|
116
|
+
raise ValueError("qty value is not a number: %r" % text)
|
|
117
|
+
if kind not in ("effort", "cost", "duration", "count"):
|
|
118
|
+
raise ValueError("qty kind must be effort/cost/duration/count: %r" % text)
|
|
119
|
+
if kind in QTY_UNITS and unit not in QTY_UNITS[kind]:
|
|
120
|
+
raise ValueError("unknown %s unit %r (known: %s)"
|
|
121
|
+
% (kind, unit, "/".join(sorted(QTY_UNITS[kind]))))
|
|
122
|
+
return kind, value, unit
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def kb_qty_key(text):
|
|
126
|
+
"""The id component: 'cost:12000:EUR', or '' when the row carries no qty."""
|
|
127
|
+
q = kb_qty_norm(text)
|
|
128
|
+
if q is None:
|
|
129
|
+
return ""
|
|
130
|
+
kind, value, unit = q
|
|
131
|
+
return "%s:%s:%s" % (kind, ("%g" % value), unit)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def kb_qty_sum(rows_qty):
|
|
135
|
+
"""Sum ('kind', value, unit) triples of ONE kind. Returns (total, unit).
|
|
136
|
+
|
|
137
|
+
effort/duration normalise to days; cost sums within one currency and
|
|
138
|
+
REFUSES a mixed-currency set; count requires one unit. Mixed kinds refuse.
|
|
139
|
+
"""
|
|
140
|
+
triples = [kb_qty_norm(q) for q in rows_qty]
|
|
141
|
+
triples = [t for t in triples if t]
|
|
142
|
+
if not triples:
|
|
143
|
+
return None
|
|
144
|
+
kinds = {t[0] for t in triples}
|
|
145
|
+
if len(kinds) > 1:
|
|
146
|
+
raise ValueError("mixed qty kinds cannot sum: %s" % "/".join(sorted(kinds)))
|
|
147
|
+
kind = triples[0][0]
|
|
148
|
+
if kind in QTY_UNITS:
|
|
149
|
+
total = sum(v * QTY_UNITS[kind][u] for _, v, u in triples)
|
|
150
|
+
return total, "d"
|
|
151
|
+
units = {t[2] for t in triples}
|
|
152
|
+
if len(units) > 1:
|
|
153
|
+
raise ValueError("mixed %s units cannot sum offline: %s"
|
|
154
|
+
% (kind, "/".join(sorted(units))))
|
|
155
|
+
return sum(v for _, v, _ in triples), triples[0][2]
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def kb_claim_id(source_path, locator, qty_key=""):
|
|
159
|
+
"""First 12 hex of sha256(path#locator#qty). Text excluded on purpose:
|
|
160
|
+
an LLM re-extraction paraphrases; the location and the figure do not move."""
|
|
161
|
+
payload = "%s#%s#%s" % (source_path, locator, qty_key)
|
|
162
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def kb_parse_scope(text):
|
|
166
|
+
"""'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
|
|
167
|
+
('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
|
|
168
|
+
text = (text or "").strip()
|
|
169
|
+
if text in ("", "-"):
|
|
170
|
+
return ("always",)
|
|
171
|
+
if text.startswith("if "):
|
|
172
|
+
return ("if", text[3:].strip())
|
|
173
|
+
m = re.match(r"^(?:from (\S+))?\s*(?:until (\S+))?$", text)
|
|
174
|
+
if not m or (m.group(1) is None and m.group(2) is None):
|
|
175
|
+
raise ValueError("valid must be '-', 'from X', 'until X', "
|
|
176
|
+
"'from X until Y' or 'if <cond>': %r" % text)
|
|
177
|
+
lo, hi = m.group(1), m.group(2)
|
|
178
|
+
for d in (lo, hi):
|
|
179
|
+
if d is not None and not DATE_RE.match(d):
|
|
180
|
+
raise ValueError("scope dates are YYYY-MM-DD: %r" % text)
|
|
181
|
+
return ("window", lo, hi)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def kb_scopes_overlap(a, b):
|
|
185
|
+
"""Half-open windows: `from` inclusive, `until` exclusive — so
|
|
186
|
+
'until 2026-03-01' and 'from 2026-03-01' are DISJOINT. 'if' conditions are
|
|
187
|
+
free text, undecidable, therefore treated as overlapping everything."""
|
|
188
|
+
sa, sb = kb_parse_scope(a), kb_parse_scope(b)
|
|
189
|
+
if sa[0] == "if" or sb[0] == "if" or sa[0] == "always" or sb[0] == "always":
|
|
190
|
+
return True
|
|
191
|
+
_, alo, ahi = sa
|
|
192
|
+
_, blo, bhi = sb
|
|
193
|
+
lo = max(alo or "0000-00-00", blo or "0000-00-00")
|
|
194
|
+
hi = min(ahi or "9999-99-99", bhi or "9999-99-99")
|
|
195
|
+
return lo < hi
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def kb_parse_claims(text):
|
|
199
|
+
"""Rows of the `## Claims` table -> (rows, errors).
|
|
200
|
+
|
|
201
|
+
Exact arity in BOTH directions: a short row and a long row both error —
|
|
202
|
+
never padded, never truncated. (The mkt find_table pattern this copies
|
|
203
|
+
tolerates a ragged tail; here a stray `|` in a hostile cell must be loud.)
|
|
204
|
+
Each row: dict with CLAIM_COLUMNS keys + '_line' (1-based, for findings).
|
|
205
|
+
"""
|
|
206
|
+
rows, errors = [], []
|
|
207
|
+
lines = text.split("\n")
|
|
208
|
+
try:
|
|
209
|
+
start = next(i for i, ln in enumerate(lines)
|
|
210
|
+
if ln.strip() == CLAIM_HEADING)
|
|
211
|
+
except StopIteration:
|
|
212
|
+
return [], []
|
|
213
|
+
header_seen = False
|
|
214
|
+
for i in range(start + 1, len(lines)):
|
|
215
|
+
ln = lines[i].strip()
|
|
216
|
+
if ln.startswith("## "):
|
|
217
|
+
break
|
|
218
|
+
if not ln.startswith("|"):
|
|
219
|
+
continue
|
|
220
|
+
cells = [c.strip() for c in ln.strip("|").split("|")]
|
|
221
|
+
if not header_seen:
|
|
222
|
+
header_seen = True # header row
|
|
223
|
+
if [c.lower() for c in cells] != list(CLAIM_COLUMNS):
|
|
224
|
+
errors.append((i + 1, "claim table header must be exactly: "
|
|
225
|
+
+ " | ".join(CLAIM_COLUMNS)))
|
|
226
|
+
return [], errors
|
|
227
|
+
continue
|
|
228
|
+
if set(ln) <= {"|", "-", " ", ":"}:
|
|
229
|
+
continue # separator row
|
|
230
|
+
if len(cells) != len(CLAIM_COLUMNS):
|
|
231
|
+
errors.append((i + 1, "claim row has %d cells, expected %d "
|
|
232
|
+
"(a '|' inside a cell must be escaped)"
|
|
233
|
+
% (len(cells), len(CLAIM_COLUMNS))))
|
|
234
|
+
continue
|
|
235
|
+
row = dict(zip(CLAIM_COLUMNS, cells))
|
|
236
|
+
row["_line"] = i + 1
|
|
237
|
+
rows.append(row)
|
|
238
|
+
return rows, errors
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def kb_fill_ids(text):
|
|
242
|
+
"""Fill empty id cells from source#locator#qty. The byte diff is confined
|
|
243
|
+
to id cells: everything else comes back verbatim (TL-T6)."""
|
|
244
|
+
lines = text.split("\n")
|
|
245
|
+
rows, _ = kb_parse_claims(text)
|
|
246
|
+
for row in rows:
|
|
247
|
+
if row["id"]:
|
|
248
|
+
continue
|
|
249
|
+
src = row["source"].split(";")[0].strip()
|
|
250
|
+
if "#" not in src:
|
|
251
|
+
continue
|
|
252
|
+
path, loc = src.rsplit("#", 1)
|
|
253
|
+
try:
|
|
254
|
+
qk = kb_qty_key(row["qty"])
|
|
255
|
+
except ValueError:
|
|
256
|
+
continue
|
|
257
|
+
new_id = kb_claim_id(path, loc, qk)
|
|
258
|
+
ln = lines[row["_line"] - 1]
|
|
259
|
+
head, rest = ln.split("|", 2)[0], ln.split("|", 2)[2]
|
|
260
|
+
first_cell = ln.split("|", 2)[1]
|
|
261
|
+
lines[row["_line"] - 1] = "%s|%s|%s" % (
|
|
262
|
+
head, first_cell.replace(first_cell.strip() or "\x00", new_id)
|
|
263
|
+
if first_cell.strip() else " " + new_id + " ", rest)
|
|
264
|
+
return "\n".join(lines)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _note_frontmatter(root, rel):
|
|
268
|
+
p = root / rel
|
|
269
|
+
if not p.is_file():
|
|
270
|
+
return None
|
|
271
|
+
return sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def kb_check_claims(root):
|
|
275
|
+
"""All mechanical claim checks over topics/*.md. Returns (errors, warnings,
|
|
276
|
+
notes) as message lists. Findings only — never per-node status lines."""
|
|
277
|
+
errors, warnings, notes = [], [], []
|
|
278
|
+
topics = root / "topics"
|
|
279
|
+
if not topics.is_dir():
|
|
280
|
+
return errors, warnings, notes
|
|
281
|
+
all_ids = {} # id -> "file:line"
|
|
282
|
+
all_rows = {} # id -> (row, rel)
|
|
283
|
+
per_file_rows = []
|
|
284
|
+
for p in sorted(topics.glob("*.md")):
|
|
285
|
+
rel = "topics/" + p.name
|
|
286
|
+
text = sdlc_core.read_text(p)
|
|
287
|
+
rows, perrs = kb_parse_claims(text)
|
|
288
|
+
for line, msg in perrs:
|
|
289
|
+
errors.append("%s:%d: %s" % (rel, line, msg))
|
|
290
|
+
per_file_rows.append((rel, rows))
|
|
291
|
+
for row in rows:
|
|
292
|
+
where = "%s:%d" % (rel, row["_line"])
|
|
293
|
+
# --- source: resolve, confine, span-check ---
|
|
294
|
+
sources = [s.strip() for s in row["source"].split(";") if s.strip()]
|
|
295
|
+
if not sources:
|
|
296
|
+
errors.append("%s: claim has no source" % where)
|
|
297
|
+
continue
|
|
298
|
+
first = sources[0]
|
|
299
|
+
for src in sources:
|
|
300
|
+
if "#" not in src:
|
|
301
|
+
errors.append("%s: source %r has no locator" % (where, src))
|
|
302
|
+
continue
|
|
303
|
+
path_s, loc = src.rsplit("#", 1)
|
|
304
|
+
target = sdlc_core.confine_under(root, path_s)
|
|
305
|
+
if target is None:
|
|
306
|
+
errors.append("%s: source %r escapes the docs root" % (where, src))
|
|
307
|
+
continue
|
|
308
|
+
if not target.is_file():
|
|
309
|
+
errors.append("%s: source %r does not resolve — a claim whose "
|
|
310
|
+
"origin cannot be reopened is model knowledge"
|
|
311
|
+
% (where, src))
|
|
312
|
+
continue
|
|
313
|
+
kb_check_locator(target, loc, where, errors)
|
|
314
|
+
# --- provenance ---
|
|
315
|
+
prov = row["prov"]
|
|
316
|
+
if prov not in PROVENANCES:
|
|
317
|
+
errors.append("%s: prov %r not in %s" % (where, prov, "/".join(PROVENANCES)))
|
|
318
|
+
elif prov in ("DERIVED", "RULING", "ELICITED"):
|
|
319
|
+
meta = _note_frontmatter(root, first.rsplit("#", 1)[0])
|
|
320
|
+
if meta is None:
|
|
321
|
+
pass # unresolvable source already reported
|
|
322
|
+
elif prov == "DERIVED" and not meta.get("derived_from"):
|
|
323
|
+
errors.append("%s: DERIVED claim's note carries no 'derived_from:' "
|
|
324
|
+
"— model knowledge disguised as a source" % where)
|
|
325
|
+
elif prov == "RULING" and not meta.get("basis"):
|
|
326
|
+
errors.append("%s: RULING note carries no 'basis:' — a preference "
|
|
327
|
+
"is not a fact; no basis, no ruling" % where)
|
|
328
|
+
# --- grammar cells ---
|
|
329
|
+
try:
|
|
330
|
+
kb_parse_scope(row["valid"])
|
|
331
|
+
except ValueError as e:
|
|
332
|
+
errors.append("%s: %s" % (where, e))
|
|
333
|
+
try:
|
|
334
|
+
qk = kb_qty_key(row["qty"])
|
|
335
|
+
except ValueError as e:
|
|
336
|
+
errors.append("%s: %s" % (where, e))
|
|
337
|
+
qk = None
|
|
338
|
+
if row["about"] not in ("", "-"):
|
|
339
|
+
m = re.match(r"^([a-z0-9-]+) -> ([a-z0-9-]+)$", row["about"])
|
|
340
|
+
if not m:
|
|
341
|
+
errors.append("%s: about must be '<predicate> -> <slug>' or '-'"
|
|
342
|
+
% where)
|
|
343
|
+
# --- id: recompute, or note fill-pending ---
|
|
344
|
+
if not row["id"]:
|
|
345
|
+
notes.append("%s: id missing — fill-pending, run "
|
|
346
|
+
"'sdlc_check.py claim-id --fill %s'" % (where, rel))
|
|
347
|
+
elif qk is not None and "#" in first:
|
|
348
|
+
path_s, loc = first.rsplit("#", 1)
|
|
349
|
+
expect = kb_claim_id(path_s, loc, qk)
|
|
350
|
+
if row["id"] != expect:
|
|
351
|
+
errors.append("%s: id %s does not recompute from its first "
|
|
352
|
+
"source (+qty) — expected %s; the text may be "
|
|
353
|
+
"corrected freely, the provenance may not be "
|
|
354
|
+
"moved silently" % (where, row["id"], expect))
|
|
355
|
+
if row["id"]:
|
|
356
|
+
if row["id"] in all_ids:
|
|
357
|
+
errors.append("%s: duplicate id %s (also at %s) — uniqueness "
|
|
358
|
+
"is global across topics/" % (where, row["id"],
|
|
359
|
+
all_ids[row["id"]]))
|
|
360
|
+
else:
|
|
361
|
+
all_ids[row["id"]] = where
|
|
362
|
+
all_rows[row["id"]] = (row, rel)
|
|
363
|
+
# --- state machine integrity, global ---
|
|
364
|
+
for _, rows in per_file_rows:
|
|
365
|
+
for row in rows:
|
|
366
|
+
if not row["id"]:
|
|
367
|
+
continue
|
|
368
|
+
where = all_ids.get(row["id"], row["id"])
|
|
369
|
+
state = row["state"]
|
|
370
|
+
if state == "OK":
|
|
371
|
+
continue
|
|
372
|
+
m = re.match(r"^(CONTESTED|SUPERSEDED) ([0-9a-f, ]+)$", state)
|
|
373
|
+
if not m:
|
|
374
|
+
errors.append("%s: state must be OK, 'CONTESTED <id>[,..]' or "
|
|
375
|
+
"'SUPERSEDED <id>': %r" % (where, state))
|
|
376
|
+
continue
|
|
377
|
+
kind = m.group(1)
|
|
378
|
+
targets = [t.strip() for t in m.group(2).split(",") if t.strip()]
|
|
379
|
+
for t in targets:
|
|
380
|
+
other = all_rows.get(t)
|
|
381
|
+
if other is None:
|
|
382
|
+
errors.append("%s: %s points at id %s which resolves to no row "
|
|
383
|
+
"— deleting one side of a disagreement breaks "
|
|
384
|
+
"the check, it does not clean up" % (where, kind, t))
|
|
385
|
+
continue
|
|
386
|
+
orow, _ = other
|
|
387
|
+
if kind == "CONTESTED":
|
|
388
|
+
if orow["state"].startswith("SUPERSEDED"):
|
|
389
|
+
errors.append("%s: CONTESTED points at SUPERSEDED row %s — "
|
|
390
|
+
"the set must be rewritten when a member is "
|
|
391
|
+
"superseded" % (where, t))
|
|
392
|
+
elif not (orow["state"].startswith("CONTESTED")
|
|
393
|
+
and row["id"] in orow["state"]):
|
|
394
|
+
errors.append("%s: CONTESTED is not symmetric — %s does not "
|
|
395
|
+
"name %s back; one flipped cell must never "
|
|
396
|
+
"silently end a disagreement"
|
|
397
|
+
% (where, t, row["id"]))
|
|
398
|
+
return errors, warnings, notes
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def kb_check_locator(target, loc, where, errors):
|
|
402
|
+
"""A locator must address an existing span of the stored bytes."""
|
|
403
|
+
m = LOC_PAGE_RE.match(loc)
|
|
404
|
+
if m:
|
|
405
|
+
page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
406
|
+
if a >= b:
|
|
407
|
+
errors.append("%s: locator %r has an empty span" % (where, loc))
|
|
408
|
+
return
|
|
409
|
+
# offsets address the stored extraction (<stem>.txt beside the original)
|
|
410
|
+
ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
|
|
411
|
+
if not ext.is_file():
|
|
412
|
+
errors.append("%s: no stored extraction %s for locator %r — offsets "
|
|
413
|
+
"must address kept bytes" % (where, ext.name, loc))
|
|
414
|
+
return
|
|
415
|
+
pages = sdlc_core.read_text(ext).split("\f")
|
|
416
|
+
if page < 1 or page > len(pages):
|
|
417
|
+
errors.append("%s: locator %r addresses page %d of %d" %
|
|
418
|
+
(where, loc, page, len(pages)))
|
|
419
|
+
elif b > len(pages[page - 1]):
|
|
420
|
+
errors.append("%s: locator %r spans past the end of page %d "
|
|
421
|
+
"(%d chars)" % (where, loc, page, len(pages[page - 1])))
|
|
422
|
+
return
|
|
423
|
+
m = LOC_LINE_RE.match(loc)
|
|
424
|
+
if m:
|
|
425
|
+
a, b = int(m.group(1)), int(m.group(2))
|
|
426
|
+
if a > b or a < 1:
|
|
427
|
+
errors.append("%s: locator %r has an invalid line range" % (where, loc))
|
|
428
|
+
return
|
|
429
|
+
n = sdlc_core.read_text(target).count("\n") + 1
|
|
430
|
+
if b > n:
|
|
431
|
+
errors.append("%s: locator %r spans past line %d" % (where, loc, n))
|
|
432
|
+
return
|
|
433
|
+
if not LOC_CELL_RE.match(loc):
|
|
434
|
+
errors.append("%s: locator %r matches no known form "
|
|
435
|
+
"(p=<n>@<a>-<b> / L<a>-<b> / Sheet<s>!<cell>)" % (where, loc))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------- topic graph
|
|
439
|
+
# F-024. Findings only; the graph is held in memory, rebuilt per run.
|
|
440
|
+
|
|
441
|
+
def kb_load_topics(root):
|
|
442
|
+
"""topics/*.md -> {slug: meta+body info}. The files ARE the state."""
|
|
443
|
+
nodes = {}
|
|
444
|
+
topics = root / "topics"
|
|
445
|
+
if not topics.is_dir():
|
|
446
|
+
return nodes
|
|
447
|
+
for p in sorted(topics.glob("*.md")):
|
|
448
|
+
if p.name == "INDEX.md":
|
|
449
|
+
continue
|
|
450
|
+
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
|
|
451
|
+
slug = (meta.get("topic") or p.stem).strip()
|
|
452
|
+
nodes[slug] = {"meta": meta, "file": "topics/" + p.name}
|
|
453
|
+
return nodes
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _as_list(v):
|
|
457
|
+
if v is None:
|
|
458
|
+
return []
|
|
459
|
+
if isinstance(v, str):
|
|
460
|
+
v = v.strip()
|
|
461
|
+
if v.startswith("[") and v.endswith("]"):
|
|
462
|
+
return [x.strip() for x in v[1:-1].split(",") if x.strip()]
|
|
463
|
+
return [v] if v else []
|
|
464
|
+
return list(v)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def kb_graph_check(root):
|
|
468
|
+
"""Integrity of the topic graph. Errors/warnings only — never a per-node
|
|
469
|
+
status line (the work-management Non-Goal forbids the collected surface)."""
|
|
470
|
+
errors, warnings = [], []
|
|
471
|
+
nodes = kb_load_topics(root)
|
|
472
|
+
if not nodes:
|
|
473
|
+
return errors, warnings
|
|
474
|
+
import difflib
|
|
475
|
+
live = {s: n for s, n in nodes.items()
|
|
476
|
+
if (n["meta"].get("status") or "").strip() != "SUPERSEDED"}
|
|
477
|
+
|
|
478
|
+
def resolve(slug):
|
|
479
|
+
"""Follow tombstone redirects to a live slug, or None."""
|
|
480
|
+
seen = set()
|
|
481
|
+
while slug in nodes and slug not in seen:
|
|
482
|
+
seen.add(slug)
|
|
483
|
+
n = nodes[slug]
|
|
484
|
+
if (n["meta"].get("status") or "").strip() == "SUPERSEDED":
|
|
485
|
+
slug = (n["meta"].get("redirect_to") or "").strip()
|
|
486
|
+
continue
|
|
487
|
+
return slug
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
owns_seen = {}
|
|
491
|
+
for slug, n in nodes.items():
|
|
492
|
+
rel = n["file"]
|
|
493
|
+
if not SLUG_RE.match(slug):
|
|
494
|
+
errors.append("%s: slug %r fails the grammar ^[a-z0-9][a-z0-9-]{0,63}$"
|
|
495
|
+
% (rel, slug))
|
|
496
|
+
if (n["meta"].get("status") or "").strip() == "SUPERSEDED":
|
|
497
|
+
tgt = (n["meta"].get("redirect_to") or "").strip()
|
|
498
|
+
if not tgt or resolve(tgt) is None:
|
|
499
|
+
errors.append("%s: tombstone redirect_to %r resolves to no live node"
|
|
500
|
+
% (rel, tgt))
|
|
501
|
+
continue
|
|
502
|
+
for parent in _as_list(n["meta"].get("parents")):
|
|
503
|
+
if not SLUG_RE.match(parent):
|
|
504
|
+
errors.append("%s: parent %r fails the slug grammar" % (rel, parent))
|
|
505
|
+
elif resolve(parent) is None:
|
|
506
|
+
errors.append("%s: parent %r resolves to no live node" % (rel, parent))
|
|
507
|
+
for c in _as_list(n["meta"].get("owns")):
|
|
508
|
+
if not OWNS_RE.match(c):
|
|
509
|
+
errors.append("%s: owns entry %r fails the grammar "
|
|
510
|
+
"<slug>/<concept>" % (rel, c))
|
|
511
|
+
elif c in owns_seen:
|
|
512
|
+
errors.append("%s: concept %r owned twice (also by %s) — one owner "
|
|
513
|
+
"per concept" % (rel, c, owns_seen[c]))
|
|
514
|
+
else:
|
|
515
|
+
owns_seen[c] = rel
|
|
516
|
+
rl = (n["meta"].get("related") or "").strip()
|
|
517
|
+
if rl and resolve(rl) is None:
|
|
518
|
+
errors.append("%s: related %r resolves to no live node" % (rel, rl))
|
|
519
|
+
# cycles + reachability on primary parents, live nodes only
|
|
520
|
+
roots = [s for s, n in live.items() if not _as_list(n["meta"].get("parents"))]
|
|
521
|
+
for slug, n in live.items():
|
|
522
|
+
seen = set()
|
|
523
|
+
cur = slug
|
|
524
|
+
while cur is not None:
|
|
525
|
+
if cur in seen:
|
|
526
|
+
errors.append("%s: cycle through %r — a detached ring is invisible "
|
|
527
|
+
"to descent forever" % (n["file"], cur))
|
|
528
|
+
break
|
|
529
|
+
seen.add(cur)
|
|
530
|
+
parents = _as_list(live.get(cur, {}).get("meta", {}).get("parents")) \
|
|
531
|
+
if cur in live else []
|
|
532
|
+
cur = resolve(parents[0]) if parents else None
|
|
533
|
+
if roots:
|
|
534
|
+
reachable = set()
|
|
535
|
+
frontier = list(roots)
|
|
536
|
+
children = {}
|
|
537
|
+
for s, n in live.items():
|
|
538
|
+
for parent in _as_list(n["meta"].get("parents")):
|
|
539
|
+
rp = resolve(parent)
|
|
540
|
+
if rp:
|
|
541
|
+
children.setdefault(rp, []).append(s)
|
|
542
|
+
while frontier:
|
|
543
|
+
cur = frontier.pop()
|
|
544
|
+
if cur in reachable:
|
|
545
|
+
continue
|
|
546
|
+
reachable.add(cur)
|
|
547
|
+
frontier.extend(children.get(cur, []))
|
|
548
|
+
for slug, n in live.items():
|
|
549
|
+
if slug not in reachable and slug != "unplaced":
|
|
550
|
+
errors.append("%s: unreachable from any root — descent is the only "
|
|
551
|
+
"retrieval path, so this node is lost, not odd"
|
|
552
|
+
% n["file"])
|
|
553
|
+
# near-duplicate warning: catches listino/listini, not listino/pricing
|
|
554
|
+
slugs = sorted(live)
|
|
555
|
+
for i, a in enumerate(slugs):
|
|
556
|
+
for b in slugs[i + 1:]:
|
|
557
|
+
pa = _as_list(live[a]["meta"].get("parents"))
|
|
558
|
+
pb = _as_list(live[b]["meta"].get("parents"))
|
|
559
|
+
if pa and pa == pb and difflib.SequenceMatcher(None, a, b).ratio() > 0.85:
|
|
560
|
+
warnings.append("%s and %s: same parents and >0.85 name similarity — "
|
|
561
|
+
"possible duplicate; the semantic case belongs to "
|
|
562
|
+
"the router evals" % (a, b))
|
|
563
|
+
return errors, warnings
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def kb_build_topic_index(root):
|
|
567
|
+
"""slug | description | parents | synonyms — a router, not a status board."""
|
|
568
|
+
nodes = kb_load_topics(root)
|
|
569
|
+
lines = ["# Topic Index", "",
|
|
570
|
+
"<!-- GENERATED by sdlc_check.py index - do not edit by hand -->", "",
|
|
571
|
+
"| topic | description | parents | synonyms |",
|
|
572
|
+
"|---|---|---|---|"]
|
|
573
|
+
for slug in sorted(nodes):
|
|
574
|
+
n = nodes[slug]
|
|
575
|
+
if (n["meta"].get("status") or "").strip() == "SUPERSEDED":
|
|
576
|
+
continue
|
|
577
|
+
lines.append("| %s | %s | %s | %s |" % (
|
|
578
|
+
slug, (n["meta"].get("description") or "").strip(),
|
|
579
|
+
", ".join(_as_list(n["meta"].get("parents"))),
|
|
580
|
+
", ".join(_as_list(n["meta"].get("synonyms")))))
|
|
581
|
+
return "\n".join(lines) + "\n"
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def kb_build_corpus_index(root):
|
|
585
|
+
"""One row per corpus artifact, from sidecars and note frontmatter."""
|
|
586
|
+
corpus = root / "corpus"
|
|
587
|
+
lines = ["# Corpus Index", "",
|
|
588
|
+
"<!-- GENERATED by sdlc_check.py index - do not edit by hand -->", ""]
|
|
589
|
+
given = corpus / "given"
|
|
590
|
+
if given.is_dir():
|
|
591
|
+
lines.append("## given/")
|
|
592
|
+
for meta_p in sorted(given.glob("*.meta.md")):
|
|
593
|
+
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
|
|
594
|
+
orig = meta_p.name[:-len(".meta.md")]
|
|
595
|
+
sup = (meta.get("supersedes") or "").strip()
|
|
596
|
+
lines.append("- `%s` — %s%s" % (
|
|
597
|
+
orig, (meta.get("date") or "undated"),
|
|
598
|
+
(" — supersedes `%s`" % sup) if sup else ""))
|
|
599
|
+
notes = corpus / "notes"
|
|
600
|
+
if notes.is_dir():
|
|
601
|
+
lines.append("")
|
|
602
|
+
lines.append("## notes/")
|
|
603
|
+
for p in sorted(notes.glob("*.md")):
|
|
604
|
+
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
|
|
605
|
+
origin = (meta.get("origin") or
|
|
606
|
+
("derived" if meta.get("derived_from") else
|
|
607
|
+
("ruling" if meta.get("basis") else "unknown")))
|
|
608
|
+
lines.append("- `%s` — %s" % (p.name, origin))
|
|
609
|
+
return "\n".join(lines) + "\n"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def kb_corpus_check(root):
|
|
613
|
+
"""Corpus integrity: digests, supersession, laundered notes. Findings only."""
|
|
614
|
+
errors, warnings = [], []
|
|
615
|
+
corpus = root / "corpus"
|
|
616
|
+
if not corpus.is_dir():
|
|
617
|
+
return errors, warnings
|
|
618
|
+
superseded = set()
|
|
619
|
+
given = corpus / "given"
|
|
620
|
+
if given.is_dir():
|
|
621
|
+
for meta_p in sorted(given.glob("*.meta.md")):
|
|
622
|
+
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
|
|
623
|
+
orig = given / meta_p.name[:-len(".meta.md")]
|
|
624
|
+
rel = "corpus/given/" + orig.name
|
|
625
|
+
if not orig.is_file():
|
|
626
|
+
errors.append("%s: sidecar exists but the original is gone" % rel)
|
|
627
|
+
continue
|
|
628
|
+
recorded = (meta.get("sha256") or "").strip()
|
|
629
|
+
if recorded:
|
|
630
|
+
actual = kb_sha256_bytes(orig)
|
|
631
|
+
if actual != recorded:
|
|
632
|
+
errors.append("%s: raw-byte digest changed since ingest — "
|
|
633
|
+
"given/ is never edited; this is the check, "
|
|
634
|
+
"not a convention" % rel)
|
|
635
|
+
sup = (meta.get("supersedes") or "").strip()
|
|
636
|
+
if sup:
|
|
637
|
+
superseded.add(sup)
|
|
638
|
+
if not (given / sup).is_file():
|
|
639
|
+
warnings.append("%s: supersedes %r which is not in given/"
|
|
640
|
+
% (rel, sup))
|
|
641
|
+
notes = corpus / "notes"
|
|
642
|
+
if notes.is_dir():
|
|
643
|
+
for p in sorted(notes.glob("*.md")):
|
|
644
|
+
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(p).splitlines()) or {}
|
|
645
|
+
if not (meta.get("derived_from") or meta.get("origin")
|
|
646
|
+
or meta.get("basis")):
|
|
647
|
+
errors.append("corpus/notes/%s: neither 'derived_from:' nor "
|
|
648
|
+
"'origin:' nor 'basis:' — model knowledge disguised "
|
|
649
|
+
"as a source" % p.name)
|
|
650
|
+
# claims resting on superseded originals (UC4)
|
|
651
|
+
if superseded:
|
|
652
|
+
topics = root / "topics"
|
|
653
|
+
if topics.is_dir():
|
|
654
|
+
for p in sorted(topics.glob("*.md")):
|
|
655
|
+
rows, _ = kb_parse_claims(sdlc_core.read_text(p))
|
|
656
|
+
for row in rows:
|
|
657
|
+
for src in row["source"].split(";"):
|
|
658
|
+
name = Path(src.split("#")[0].strip()).name
|
|
659
|
+
if name in superseded:
|
|
660
|
+
warnings.append(
|
|
661
|
+
"topics/%s:%d: claim rests on %s, which a newer "
|
|
662
|
+
"version supersedes — re-verify or re-place"
|
|
663
|
+
% (p.name, row["_line"], name))
|
|
664
|
+
return errors, warnings
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def kb_sha256_bytes(path):
|
|
668
|
+
"""RAW-byte digest for binaries. The spine's sha256_file normalizes CRLF->LF
|
|
669
|
+
(right for text snapshots, wrong for binaries, where a hostile 0D0A/0A pair
|
|
670
|
+
would collide)."""
|
|
671
|
+
h = hashlib.sha256()
|
|
672
|
+
with open(path, "rb") as f:
|
|
673
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
674
|
+
h.update(chunk)
|
|
675
|
+
return h.hexdigest()
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# ------------------------------------------------------------------ commands
|
|
679
|
+
|
|
680
|
+
INTERCEPTED = {"index", "validate", "check", "graph", "corpus", "claim-id"}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _kb_root(args):
|
|
684
|
+
"""Resolve the PROJECT root exactly as the spine's main does, so --docs-dir,
|
|
685
|
+
the env seam and the two-roots-refuse behaviour hold on intercepted commands.
|
|
686
|
+
Same call, same order: resolve_docs_dir(args, root) -> (discovered, name).
|
|
687
|
+
Spine cmd_* take the project root; kb helpers take the docs root under it."""
|
|
688
|
+
discovered, name = sdlc_core.resolve_docs_dir(args, getattr(args, "root", None))
|
|
689
|
+
sdlc_core.set_docs_dir(name)
|
|
690
|
+
root = (Path(args.root).resolve() if args.root
|
|
691
|
+
else (discovered or sdlc_core.find_project_root()))
|
|
692
|
+
return root, root / name
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def kb_cmd_graph(docs):
|
|
696
|
+
errors, warnings = kb_graph_check(docs)
|
|
697
|
+
ce, cw, cn = kb_check_claims(docs)
|
|
698
|
+
for w in warnings + cw:
|
|
699
|
+
print("[warn] %s" % w)
|
|
700
|
+
for n in cn:
|
|
701
|
+
print("[note] %s" % n)
|
|
702
|
+
for e in errors + ce:
|
|
703
|
+
print("[ERROR] %s" % e)
|
|
704
|
+
total_e = len(errors) + len(ce)
|
|
705
|
+
if total_e:
|
|
706
|
+
print("Graph: %d errors, %d warnings." % (total_e, len(warnings) + len(cw)))
|
|
707
|
+
return 1
|
|
708
|
+
if not (docs / "topics").is_dir():
|
|
709
|
+
print("[ok] no topics/ - nothing to check")
|
|
710
|
+
else:
|
|
711
|
+
print("[ok] topic graph and claim ledger consistent "
|
|
712
|
+
"(%d warnings)" % (len(warnings) + len(cw)))
|
|
713
|
+
return 0
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def kb_cmd_corpus(docs):
|
|
717
|
+
errors, warnings = kb_corpus_check(docs)
|
|
718
|
+
for w in warnings:
|
|
719
|
+
print("[warn] %s" % w)
|
|
720
|
+
for e in errors:
|
|
721
|
+
print("[ERROR] %s" % e)
|
|
722
|
+
if errors:
|
|
723
|
+
print("Corpus: %d errors, %d warnings." % (len(errors), len(warnings)))
|
|
724
|
+
return 1
|
|
725
|
+
if not (docs / "corpus").is_dir():
|
|
726
|
+
print("[ok] no corpus/ - nothing to check")
|
|
727
|
+
else:
|
|
728
|
+
print("[ok] corpus consistent (%d warnings)" % len(warnings))
|
|
729
|
+
return 0
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def kb_cmd_index(root, docs):
|
|
733
|
+
rc = sdlc_core.cmd_index(root)
|
|
734
|
+
if (docs / "topics").is_dir():
|
|
735
|
+
out = docs / "topics" / "INDEX.md"
|
|
736
|
+
out.write_text(kb_build_topic_index(docs), encoding="utf-8")
|
|
737
|
+
print("[ok] topic index regenerated: %s" % out)
|
|
738
|
+
if (docs / "corpus").is_dir():
|
|
739
|
+
out = docs / "corpus" / "INDEX.md"
|
|
740
|
+
out.write_text(kb_build_corpus_index(docs), encoding="utf-8")
|
|
741
|
+
print("[ok] corpus index regenerated: %s" % out)
|
|
742
|
+
return rc
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _kb_extra_validate(docs):
|
|
746
|
+
"""The kb additions to validate: generated-index freshness for the overlay's
|
|
747
|
+
two indexes. Prints nothing on a tree without them (TS-K7)."""
|
|
748
|
+
rc = 0
|
|
749
|
+
checks = (("topics", kb_build_topic_index), ("corpus", kb_build_corpus_index))
|
|
750
|
+
for dirname, builder in checks:
|
|
751
|
+
idx = docs / dirname / "INDEX.md"
|
|
752
|
+
if (docs / dirname).is_dir() and idx.is_file():
|
|
753
|
+
if sdlc_core.norm_text(sdlc_core.read_text(idx)) != \
|
|
754
|
+
sdlc_core.norm_text(builder(docs)):
|
|
755
|
+
print("[ERROR] %s/INDEX.md not aligned: run 'sdlc_check.py index'"
|
|
756
|
+
% dirname)
|
|
757
|
+
rc = 1
|
|
758
|
+
return rc
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def kb_cmd_validate(root, docs, strict=False, hybrid=False):
|
|
762
|
+
rc = sdlc_core.cmd_validate(root, strict=strict, hybrid=hybrid)
|
|
763
|
+
return max(rc, _kb_extra_validate(docs))
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def kb_cmd_check(root, docs, strict=False, hybrid=False):
|
|
767
|
+
# The spine's check owns its banners and summary line: reuse it whole, so a
|
|
768
|
+
# tree with no kb surface gets byte-identical output. The kb checks run
|
|
769
|
+
# after, and only when the surface exists.
|
|
770
|
+
rc = sdlc_core.cmd_check(root, strict=strict, hybrid=hybrid)
|
|
771
|
+
rc = max(rc, _kb_extra_validate(docs))
|
|
772
|
+
if (docs / "topics").is_dir() or (docs / "corpus").is_dir():
|
|
773
|
+
print("===== graph =====")
|
|
774
|
+
rc = max(rc, kb_cmd_graph(docs))
|
|
775
|
+
print("===== corpus =====")
|
|
776
|
+
rc = max(rc, kb_cmd_corpus(docs))
|
|
777
|
+
return rc
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def kb_cmd_claim_id(args):
|
|
781
|
+
if args.fill:
|
|
782
|
+
p = Path(args.path)
|
|
783
|
+
if not p.is_file():
|
|
784
|
+
print("[ERROR] no such file: %s" % p)
|
|
785
|
+
return 2
|
|
786
|
+
before = sdlc_core.read_text(p)
|
|
787
|
+
after = kb_fill_ids(before)
|
|
788
|
+
if after != before:
|
|
789
|
+
p.write_text(after, encoding="utf-8")
|
|
790
|
+
print("[ok] ids filled in %s" % p)
|
|
791
|
+
else:
|
|
792
|
+
print("[ok] nothing to fill in %s" % p)
|
|
793
|
+
return 0
|
|
794
|
+
if not args.locator:
|
|
795
|
+
print("[ERROR] claim-id needs <path> <locator>, or --fill <file>")
|
|
796
|
+
return 2
|
|
797
|
+
print(kb_claim_id(args.path, args.locator, kb_qty_key(args.qty or "-")))
|
|
798
|
+
return 0
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def main(argv=None):
|
|
802
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
803
|
+
# Forward-by-default: anything not intercepted goes to the spine untouched.
|
|
804
|
+
# Never a hand-copied command tuple - that is how a spine command gets
|
|
805
|
+
# silently dropped (mkt_check.py ships that exact defect with `migrate`).
|
|
806
|
+
if not argv or argv[0] not in INTERCEPTED:
|
|
807
|
+
return sdlc_core.main(argv)
|
|
808
|
+
ap = argparse.ArgumentParser(prog="sdlc_check.py (kb overlay)")
|
|
809
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
810
|
+
for name in ("index", "graph", "corpus"):
|
|
811
|
+
p = sub.add_parser(name)
|
|
812
|
+
p.add_argument("--root")
|
|
813
|
+
p.add_argument("--docs-dir")
|
|
814
|
+
for name in ("validate", "check"):
|
|
815
|
+
p = sub.add_parser(name)
|
|
816
|
+
p.add_argument("--root")
|
|
817
|
+
p.add_argument("--docs-dir")
|
|
818
|
+
p.add_argument("--strict", action="store_true")
|
|
819
|
+
p.add_argument("--hybrid", action="store_true")
|
|
820
|
+
p = sub.add_parser("claim-id")
|
|
821
|
+
p.add_argument("path")
|
|
822
|
+
p.add_argument("locator", nargs="?")
|
|
823
|
+
p.add_argument("--qty")
|
|
824
|
+
p.add_argument("--fill", action="store_true")
|
|
825
|
+
args = ap.parse_args(argv)
|
|
826
|
+
if args.cmd == "claim-id":
|
|
827
|
+
return kb_cmd_claim_id(args)
|
|
828
|
+
try:
|
|
829
|
+
root, docs = _kb_root(args)
|
|
830
|
+
except sdlc_core.AmbiguousDocsRoot as e:
|
|
831
|
+
# same behaviour as the spine for non-exempt commands
|
|
832
|
+
print("[ERROR] %s" % e)
|
|
833
|
+
return 1
|
|
834
|
+
if args.cmd == "index":
|
|
835
|
+
return kb_cmd_index(root, docs)
|
|
836
|
+
if args.cmd == "graph":
|
|
837
|
+
return kb_cmd_graph(docs)
|
|
838
|
+
if args.cmd == "corpus":
|
|
839
|
+
return kb_cmd_corpus(docs)
|
|
840
|
+
if args.cmd == "validate":
|
|
841
|
+
return kb_cmd_validate(root, docs, strict=args.strict, hybrid=args.hybrid)
|
|
842
|
+
return kb_cmd_check(root, docs, strict=args.strict, hybrid=args.hybrid)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
if __name__ == "__main__":
|
|
846
|
+
sys.exit(main())
|