@antoneeo/mkt-agentic-sdlc-skill 0.26.0 → 0.27.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 +12 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/mkt-agentic-sdlc/ENFORCEMENT.md +3 -3
- package/skills/mkt-agentic-sdlc/SKILL.md +1 -1
- package/skills/mkt-agentic-sdlc/docs_site/app.js +7 -1
- package/skills/mkt-agentic-sdlc/docs_site/strings.js +9 -0
- package/skills/mkt-agentic-sdlc/docs_site/style.css +2 -0
- package/skills/mkt-agentic-sdlc/guides.md +8 -0
- package/skills/mkt-agentic-sdlc/review.md +9 -1
- package/skills/mkt-agentic-sdlc/scripts/docs_site.py +17 -3
- package/skills/mkt-agentic-sdlc/scripts/knowledge.py +925 -33
- package/skills/mkt-agentic-sdlc/scripts/mkt_check.py +2 -1
- package/skills/mkt-agentic-sdlc/scripts/sdlc_core.py +562 -22
|
@@ -5,6 +5,7 @@ Extracted from the KB overlay; the kb_* API and stored formats remain compatible
|
|
|
5
5
|
Every distribution ships this module alongside its entry point and sdlc_core.py.
|
|
6
6
|
"""
|
|
7
7
|
import argparse
|
|
8
|
+
import codecs
|
|
8
9
|
import contextlib
|
|
9
10
|
import io
|
|
10
11
|
import os
|
|
@@ -13,6 +14,7 @@ import ntpath
|
|
|
13
14
|
import posixpath
|
|
14
15
|
import re
|
|
15
16
|
import sys
|
|
17
|
+
import time
|
|
16
18
|
from pathlib import Path
|
|
17
19
|
import sdlc_core
|
|
18
20
|
from sdlc_core import * # compatibility with the existing KB implementation
|
|
@@ -24,7 +26,7 @@ from sdlc_core import _map_refs
|
|
|
24
26
|
|
|
25
27
|
CLAIM_COLUMNS = ("id", "claim", "valid", "qty", "about", "source", "prov", "state")
|
|
26
28
|
CLAIM_HEADING = "## Claims"
|
|
27
|
-
PROVENANCES = ("GIVEN", "ELICITED", "DERIVED", "RULING", "IMPORTED")
|
|
29
|
+
PROVENANCES = ("GIVEN", "ELICITED", "DERIVED", "RULING", "IMPORTED", "DECLARED")
|
|
28
30
|
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
29
31
|
OWNS_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}/[a-z0-9][a-z0-9-]{0,63}$")
|
|
30
32
|
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
@@ -420,6 +422,40 @@ def kb_check_claims(root):
|
|
|
420
422
|
meta, meta_where = (None, "")
|
|
421
423
|
if "#" in first:
|
|
422
424
|
meta, meta_where = _declaring_frontmatter(first.rsplit("#", 1)[0])
|
|
425
|
+
# F-084: a declaration (a document this project SENT) can never pass
|
|
426
|
+
# for evidence, nor evidence for a declaration -- checked on EVERY
|
|
427
|
+
# source position, because corroboration appends sources to a row.
|
|
428
|
+
declared_srcs = []
|
|
429
|
+
for src in sources:
|
|
430
|
+
if "#" not in src:
|
|
431
|
+
continue
|
|
432
|
+
path_s = src.rsplit("#", 1)[0]
|
|
433
|
+
if _resolved(path_s)[1]:
|
|
434
|
+
smeta, swhere = _declaring_frontmatter(path_s)
|
|
435
|
+
if ((smeta or {}).get("provenance") or "").strip().upper() == "DECLARED":
|
|
436
|
+
declared_srcs.append((path_s, smeta, swhere))
|
|
437
|
+
if prov == "DECLARED":
|
|
438
|
+
decl_paths = set(d[0] for d in declared_srcs)
|
|
439
|
+
for src in sources:
|
|
440
|
+
path_s = src.rsplit("#", 1)[0] if "#" in src else src
|
|
441
|
+
if not _resolved(path_s)[1]:
|
|
442
|
+
continue # unresolvable source already reported
|
|
443
|
+
if not path_s.startswith("corpus/given/") or path_s not in decl_paths:
|
|
444
|
+
errors.append("%s: DECLARED row cites %s, which is not a "
|
|
445
|
+
"corpus/given/ artifact whose sidecar says "
|
|
446
|
+
"'provenance: DECLARED' -- a declaration rests "
|
|
447
|
+
"only on the document that was sent" % (where, path_s))
|
|
448
|
+
for path_s, smeta, swhere in declared_srcs:
|
|
449
|
+
if not (smeta.get("recipient") or "").strip():
|
|
450
|
+
errors.append("%s: DECLARED source (%s) carries no "
|
|
451
|
+
"'recipient:' -- a declaration names to whom it "
|
|
452
|
+
"was made" % (where, swhere))
|
|
453
|
+
elif declared_srcs:
|
|
454
|
+
errors.append("%s: prov %s, but %s declares 'provenance: DECLARED' -- "
|
|
455
|
+
"a document this project sent is a declaration, not "
|
|
456
|
+
"evidence: file the row as DECLARED on that artifact "
|
|
457
|
+
"alone (a declaration never corroborates another row)"
|
|
458
|
+
% (where, prov, declared_srcs[0][2]))
|
|
423
459
|
if prov not in PROVENANCES:
|
|
424
460
|
errors.append("%s: prov %r not in %s" % (where, prov, "/".join(PROVENANCES)))
|
|
425
461
|
elif prov in ("DERIVED", "RULING", "ELICITED", "IMPORTED"):
|
|
@@ -429,6 +465,16 @@ def kb_check_claims(root):
|
|
|
429
465
|
errors.append("%s: DERIVED claim's source (%s) carries no "
|
|
430
466
|
"'derived_from:' — model knowledge disguised as "
|
|
431
467
|
"a source" % (where, meta_where))
|
|
468
|
+
elif prov == "RULING" and meta.get("imported_from"):
|
|
469
|
+
# F-083: relabelling an imported row RULING would make another
|
|
470
|
+
# owner's note carry a decision here. Ratifying is deciding:
|
|
471
|
+
# a note of your own, with your own basis.
|
|
472
|
+
errors.append("%s: RULING source (%s) names a foreign origin "
|
|
473
|
+
"(imported_from: %s) — another project's note "
|
|
474
|
+
"cannot carry your ruling. Write your own note "
|
|
475
|
+
"with your own 'basis:' and put the RULING on it "
|
|
476
|
+
"(portability.md §4)"
|
|
477
|
+
% (where, meta_where, meta.get("imported_from")))
|
|
432
478
|
elif prov == "RULING" and not meta.get("basis"):
|
|
433
479
|
errors.append("%s: RULING source (%s) carries no 'basis:' — a "
|
|
434
480
|
"preference is not a fact; no basis, no ruling"
|
|
@@ -448,7 +494,7 @@ def kb_check_claims(root):
|
|
|
448
494
|
# prose, and prose is not a check -- which is how three rows
|
|
449
495
|
# whose evidence was a reading of an image passed as GIVEN.
|
|
450
496
|
declared = ((meta or {}).get("provenance") or "").strip()
|
|
451
|
-
if declared and declared.upper()
|
|
497
|
+
if declared and declared.upper() not in ("GIVEN", "DECLARED"):
|
|
452
498
|
warnings.append(
|
|
453
499
|
"%s: prov GIVEN, but %s declares 'provenance: %s' — the "
|
|
454
500
|
"row reads as first-hand evidence and its artifact does "
|
|
@@ -571,13 +617,18 @@ def kb_check_claims(root):
|
|
|
571
617
|
state = row["state"]
|
|
572
618
|
if state == "OK":
|
|
573
619
|
continue
|
|
574
|
-
|
|
575
|
-
if not
|
|
576
|
-
errors.append("%s: state must be OK, 'CONTESTED <id>[,..]'
|
|
577
|
-
"'SUPERSEDED <id>[,..]': %r"
|
|
620
|
+
kind, targets = _kb_states(state)
|
|
621
|
+
if not kind:
|
|
622
|
+
errors.append("%s: state must be OK, 'CONTESTED <id>[,..]', "
|
|
623
|
+
"'SUPERSEDED <id>[,..]' or 'DIVERGES <id>[,..]': %r"
|
|
624
|
+
% (where, state))
|
|
625
|
+
continue
|
|
626
|
+
row_decl = _kb_is_declared(row)
|
|
627
|
+
if kind == "DIVERGES" and not row_decl:
|
|
628
|
+
errors.append("%s: DIVERGES on a %s row -- only a DECLARED row "
|
|
629
|
+
"diverges; two sources that disagree are CONTESTED"
|
|
630
|
+
% (where, row["prov"].strip() or "?"))
|
|
578
631
|
continue
|
|
579
|
-
kind = m.group(1)
|
|
580
|
-
targets = [t.strip() for t in m.group(2).split(",") if t.strip()]
|
|
581
632
|
for t in targets:
|
|
582
633
|
other = all_rows.get(t)
|
|
583
634
|
if other is None:
|
|
@@ -586,18 +637,38 @@ def kb_check_claims(root):
|
|
|
586
637
|
"the check, it does not clean up" % (where, kind, t))
|
|
587
638
|
continue
|
|
588
639
|
orow, _ = other
|
|
640
|
+
if kind == "DIVERGES":
|
|
641
|
+
if _kb_is_declared(orow):
|
|
642
|
+
errors.append("%s: DIVERGES points at %s, a DECLARED row -- a "
|
|
643
|
+
"declaration diverges from the ledger, never from "
|
|
644
|
+
"another declaration (two sends that disagree "
|
|
645
|
+
"are CONTESTED)" % (where, t))
|
|
646
|
+
elif orow["state"].startswith("SUPERSEDED"):
|
|
647
|
+
errors.append("%s: DIVERGES points at SUPERSEDED row %s -- "
|
|
648
|
+
"re-judge the declaration against its successor"
|
|
649
|
+
" (OK if it now agrees)" % (where, t))
|
|
650
|
+
continue
|
|
651
|
+
if kind in ("SUPERSEDED", "CONTESTED") and row_decl != _kb_is_declared(orow):
|
|
652
|
+
errors.append("%s: %s %s across a declaration -- a declaration is "
|
|
653
|
+
"superseded only by a later declaration and never "
|
|
654
|
+
"settles or contests a fact; a declaration that "
|
|
655
|
+
"contradicts the ledger is DIVERGES"
|
|
656
|
+
% (where, kind, t))
|
|
657
|
+
continue
|
|
589
658
|
if kind == "SUPERSEDED" and orow["prov"] == "IMPORTED":
|
|
590
659
|
# F-030, owner ruling 2026-08-03: knowledge crosses a project
|
|
591
660
|
# boundary, authority does not. An IMPORTED row carries another
|
|
592
661
|
# owner's decision; letting it supersede a local row would make
|
|
593
662
|
# that decision binding here without anyone here granting it.
|
|
594
|
-
#
|
|
595
|
-
#
|
|
663
|
+
# Ratify first (F-083): a note of your own with your own basis,
|
|
664
|
+
# a RULING row on it, and every row contested with the imported
|
|
665
|
+
# one SUPERSEDED by that row.
|
|
596
666
|
errors.append("%s: SUPERSEDED by %s, which is IMPORTED — a "
|
|
597
667
|
"foreign decision cannot settle a local row. "
|
|
598
|
-
"
|
|
599
|
-
"RULING
|
|
600
|
-
%
|
|
668
|
+
"Ratify it: your own note with your own "
|
|
669
|
+
"'basis:', a RULING row on it, and every row "
|
|
670
|
+
"CONTESTED with %s SUPERSEDED by that row"
|
|
671
|
+
% (where, t, t))
|
|
601
672
|
if kind == "CONTESTED":
|
|
602
673
|
if orow["state"].startswith("SUPERSEDED"):
|
|
603
674
|
errors.append("%s: CONTESTED points at SUPERSEDED row %s — "
|
|
@@ -703,6 +774,29 @@ def kb_drop_marks(text):
|
|
|
703
774
|
return "\n".join(lines), dropped
|
|
704
775
|
|
|
705
776
|
|
|
777
|
+
def _kb_is_declared(row):
|
|
778
|
+
return (row.get("prov") or "").strip().upper() == "DECLARED"
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def kb_has_declarations(selected):
|
|
782
|
+
"""Whether any selected topic holds a DECLARED row -- a yes/no for export's
|
|
783
|
+
line, never a count (the same rule as F-080's dropped-marker line)."""
|
|
784
|
+
return any(_kb_is_declared(r) for _p, _t, rows in selected.values() for r in rows)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def kb_drop_declarations(text):
|
|
788
|
+
"""The topic text without its DECLARED rows (F-084: a declaration is this
|
|
789
|
+
project's commitment to its recipient and stays here). Every other line is
|
|
790
|
+
kept byte for byte. Closed by construction: check refuses any pointer from
|
|
791
|
+
a non-DECLARED row at a DECLARED one, so nothing left behind points at a
|
|
792
|
+
dropped row."""
|
|
793
|
+
rows, _ = kb_parse_claims(text)
|
|
794
|
+
gone = set(r["_line"] for r in rows if _kb_is_declared(r))
|
|
795
|
+
if not gone:
|
|
796
|
+
return text
|
|
797
|
+
return "\n".join(ln for i, ln in enumerate(text.split("\n"), 1) if i not in gone)
|
|
798
|
+
|
|
799
|
+
|
|
706
800
|
def kb_check_locator(target, loc, where, errors, cache=None):
|
|
707
801
|
"""A locator must address an existing span of the stored bytes.
|
|
708
802
|
|
|
@@ -1773,21 +1867,29 @@ def kb_export_closure(docs, slugs):
|
|
|
1773
1867
|
selected = dict((s, all_topics[s]) for s in slugs)
|
|
1774
1868
|
# id -> slug, over the WHOLE graph, so a conflict partner is findable
|
|
1775
1869
|
owner_of = {}
|
|
1870
|
+
declared_ids = set()
|
|
1776
1871
|
for slug, (_p, _t, rows) in all_topics.items():
|
|
1777
1872
|
for r in rows:
|
|
1778
1873
|
if r["id"]:
|
|
1779
1874
|
owner_of[r["id"]] = slug
|
|
1875
|
+
if _kb_is_declared(r):
|
|
1876
|
+
declared_ids.add(r["id"])
|
|
1780
1877
|
added = []
|
|
1781
1878
|
pending = list(selected)
|
|
1782
1879
|
while pending:
|
|
1783
1880
|
slug = pending.pop()
|
|
1784
1881
|
for r in selected[slug][2]:
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
for ref in
|
|
1882
|
+
if _kb_is_declared(r):
|
|
1883
|
+
continue # F-084: a declaration stays in this project
|
|
1884
|
+
_kind, refs = _kb_states(r["state"])
|
|
1885
|
+
for ref in refs:
|
|
1789
1886
|
other = owner_of.get(ref)
|
|
1790
|
-
if
|
|
1887
|
+
if ref in declared_ids:
|
|
1888
|
+
errors.append("claim %s in topic '%s' points at %s, a DECLARED row: "
|
|
1889
|
+
"declarations are not exported and a fact never "
|
|
1890
|
+
"points at one -- run check" % (r["id"] or "(no id)",
|
|
1891
|
+
slug, ref))
|
|
1892
|
+
elif other is None:
|
|
1791
1893
|
errors.append("claim %s in topic '%s' points at id %s, which "
|
|
1792
1894
|
"no topic owns: export would carry a broken set"
|
|
1793
1895
|
% (r["id"] or "(no id)", slug, ref))
|
|
@@ -1799,6 +1901,8 @@ def kb_export_closure(docs, slugs):
|
|
|
1799
1901
|
seen = set()
|
|
1800
1902
|
for slug, (_p, _t, rows) in sorted(selected.items()):
|
|
1801
1903
|
for r in rows:
|
|
1904
|
+
if _kb_is_declared(r):
|
|
1905
|
+
continue # its artifact stays with the declaration
|
|
1802
1906
|
for rel in kb_claim_sources(r):
|
|
1803
1907
|
for cand in (rel, rel + ".meta.md",
|
|
1804
1908
|
rel[:-len(Path(rel).suffix)] + ".txt" if Path(rel).suffix else rel):
|
|
@@ -1824,6 +1928,7 @@ def kb_bundle_write(docs, out, selected, artifacts, project):
|
|
|
1824
1928
|
for slug, (p, text, _rows) in sorted(selected.items()):
|
|
1825
1929
|
text, d = kb_drop_marks(text)
|
|
1826
1930
|
dropped = dropped or d
|
|
1931
|
+
text = kb_drop_declarations(text)
|
|
1827
1932
|
(out / "topics" / p.name).write_text(text, encoding="utf-8")
|
|
1828
1933
|
for rel in artifacts:
|
|
1829
1934
|
dst = out / rel
|
|
@@ -1841,10 +1946,338 @@ def kb_bundle_write(docs, out, selected, artifacts, project):
|
|
|
1841
1946
|
return dropped
|
|
1842
1947
|
|
|
1843
1948
|
|
|
1844
|
-
def
|
|
1949
|
+
def _kb_replace_cells(line, updates):
|
|
1950
|
+
"""One claim-table row with the cells at the given column indexes replaced;
|
|
1951
|
+
everything else -- indentation, outer pipes, a CRLF tail -- verbatim."""
|
|
1952
|
+
end = "\r" if line.endswith("\r") else ""
|
|
1953
|
+
body = line[:-1] if end else line
|
|
1954
|
+
lead = body[:len(body) - len(body.lstrip())]
|
|
1955
|
+
s = body.strip()
|
|
1956
|
+
opens, closes = s.startswith("|"), len(s) > 1 and s.endswith("|")
|
|
1957
|
+
cells = s[1 if opens else 0:len(s) - 1 if closes else len(s)].split("|")
|
|
1958
|
+
for i, v in updates.items():
|
|
1959
|
+
cells[i] = " %s " % v
|
|
1960
|
+
return "%s%s%s%s%s" % (lead, "|" if opens else "", "|".join(cells),
|
|
1961
|
+
"|" if closes else "", end)
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
def _kb_states(state):
|
|
1965
|
+
"""'CONTESTED a, b' / 'SUPERSEDED a' / 'DIVERGES a' -> (kind, [ids]);
|
|
1966
|
+
OK/other -> (None, []). The one parser of the full state grammar."""
|
|
1967
|
+
m = re.match(r"^(CONTESTED|SUPERSEDED|DIVERGES) ([0-9a-f, ]+)$", (state or "").strip())
|
|
1968
|
+
if not m:
|
|
1969
|
+
return None, []
|
|
1970
|
+
return m.group(1), [t.strip() for t in m.group(2).split(",") if t.strip()]
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def _kb_bom_text(data):
|
|
1974
|
+
"""(bom, text): a UTF-8 BOM (Windows authoring) is kept apart and given back
|
|
1975
|
+
on write, as sdlc_core.read_text reads past it."""
|
|
1976
|
+
bom = codecs.BOM_UTF8 if data.startswith(codecs.BOM_UTF8) else b""
|
|
1977
|
+
return bom, data[len(bom):].decode("utf-8")
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
def _kb_foreign_note(data, project, rel):
|
|
1981
|
+
"""F-083: the bytes of a ruling note as it lands in another project, and the
|
|
1982
|
+
number of lines inserted. Lines go right after the opening '---' with the
|
|
1983
|
+
note's own line ending; an existing origin line is rewritten in place, so it
|
|
1984
|
+
moves nothing. None when the note opens no frontmatter."""
|
|
1985
|
+
bom, text = _kb_bom_text(data)
|
|
1986
|
+
nl = "\r\n" if "\r\n" in text else "\n"
|
|
1987
|
+
lines = text.split(nl)
|
|
1988
|
+
if not lines or lines[0].strip() != "---":
|
|
1989
|
+
return None
|
|
1990
|
+
end = next((i for i in range(1, min(len(lines), 60)) if lines[i].strip() == "---"),
|
|
1991
|
+
None)
|
|
1992
|
+
if end is None:
|
|
1993
|
+
return None
|
|
1994
|
+
inserted = ["imported_from: %s — %s" % (project, rel)]
|
|
1995
|
+
origin = [i for i in range(1, end) if re.match(r"^origin:", lines[i], re.I)]
|
|
1996
|
+
for i in origin:
|
|
1997
|
+
lines[i] = "origin: imported"
|
|
1998
|
+
if not origin:
|
|
1999
|
+
inserted.append("origin: imported")
|
|
2000
|
+
lines[1:1] = inserted
|
|
2001
|
+
return bom + nl.join(lines).encode("utf-8"), len(inserted)
|
|
2002
|
+
|
|
2003
|
+
|
|
2004
|
+
def _kb_shift_loc(loc, k):
|
|
2005
|
+
a, b = (int(x) for x in LOC_LINE_RE.match(loc).groups())
|
|
2006
|
+
if a >= 2:
|
|
2007
|
+
return "L%d-%d" % (a + k, b + k)
|
|
2008
|
+
return "L1-%d" % (b + k if b >= 2 else 1)
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
def _kb_span(data, loc):
|
|
2012
|
+
"""The lines a line locator addresses, minus the two lines an import may
|
|
2013
|
+
insert or rewrite -- what a moved locator must still cite."""
|
|
2014
|
+
a, b = (int(x) for x in LOC_LINE_RE.match(loc).groups())
|
|
2015
|
+
return [x for x in _kb_bom_text(data)[1].splitlines()[a - 1:b]
|
|
2016
|
+
if not re.match(r"^(imported_from|origin):", x, re.I)]
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def _kb_rewrite_derived_from(data, renames):
|
|
2020
|
+
"""A note whose derived_from names a renamed ruling note names the copy. The
|
|
2021
|
+
edit stays on the derived_from line, so no line moves."""
|
|
2022
|
+
bom, text = _kb_bom_text(data)
|
|
2023
|
+
nl = "\r\n" if "\r\n" in text else "\n"
|
|
2024
|
+
lines = text.split(nl)
|
|
2025
|
+
if not lines or lines[0].strip() != "---":
|
|
2026
|
+
return data
|
|
2027
|
+
for i in range(1, min(len(lines), 60)):
|
|
2028
|
+
if lines[i].strip() == "---":
|
|
2029
|
+
break
|
|
2030
|
+
m = re.match(r"^(derived_from:\s*)(.*)$", lines[i], re.I)
|
|
2031
|
+
if not m:
|
|
2032
|
+
continue
|
|
2033
|
+
val = m.group(2).strip()
|
|
2034
|
+
listed = val.startswith("[") and val.endswith("]")
|
|
2035
|
+
toks = val[1:-1].split(",") if listed else [val]
|
|
2036
|
+
out = []
|
|
2037
|
+
for tok in toks:
|
|
2038
|
+
e = kb_unquote(tok)
|
|
2039
|
+
hit = next((new for old, new in renames.items()
|
|
2040
|
+
if e in (old, old[len("corpus/notes/"):])), None)
|
|
2041
|
+
out.append((" " if listed and out else "") + hit if hit else tok)
|
|
2042
|
+
new = ("[%s]" % ",".join(out)) if listed else out[0]
|
|
2043
|
+
lines[i] = m.group(1) + new
|
|
2044
|
+
return bom + nl.join(lines).encode("utf-8")
|
|
2045
|
+
|
|
2046
|
+
|
|
2047
|
+
def kb_import_foreign_rulings(bundle, docs, topic_rels, written, project):
|
|
2048
|
+
"""F-083 -- knowledge crosses a project boundary, authority does not
|
|
2049
|
+
(portability.md §4, owner rulings 2026-08-03 and R3 of F-083).
|
|
2050
|
+
|
|
2051
|
+
-> (content, drop, report, errors). content: {rel: bytes} to write instead of
|
|
2052
|
+
the bundle's bytes; drop: rels never to write (a ruling note under its own
|
|
2053
|
+
name); report: {"converted": n, "notes": [renamed notes of skipped topics]}.
|
|
2054
|
+
|
|
2055
|
+
A RULING row arrives IMPORTED, resting on a renamed copy of its note that
|
|
2056
|
+
names the origin; the rows it superseded and the ruling form one CONTESTED
|
|
2057
|
+
set again. The note is never edited in place: a line inserted into it moves
|
|
2058
|
+
the line every locator cites, and check verifies line bounds, not text."""
|
|
2059
|
+
errors, content, drop = [], {}, set()
|
|
2060
|
+
report = {"converted": 0, "notes": []}
|
|
2061
|
+
slug = re.sub(r"[^a-z0-9]", "-", (project or "unknown").lower())
|
|
2062
|
+
parsed = {}
|
|
2063
|
+
for rel in topic_rels:
|
|
2064
|
+
text = (bundle / rel).read_bytes().decode("utf-8")
|
|
2065
|
+
parsed[rel] = (text, kb_parse_claims(text)[0])
|
|
2066
|
+
rulings = [(rel, r) for rel in topic_rels for r in parsed[rel][1]
|
|
2067
|
+
if r["prov"].strip().upper() == "RULING"]
|
|
2068
|
+
# Which notes land foreign. A note cited by a RULING row is a ruling note;
|
|
2069
|
+
# so is any bundle note declaring a basis: (reconciliation.md) -- one can
|
|
2070
|
+
# travel without its row, cited by another row, and land first under its
|
|
2071
|
+
# own name, ready to carry a local-looking RULING later (closure review B1).
|
|
2072
|
+
users = {} # note -> [(topic rel, row)] RULING rows on it
|
|
2073
|
+
for rel, r in rulings:
|
|
2074
|
+
src = r["source"].split(";")[0].strip()
|
|
2075
|
+
path, _, loc = src.rpartition("#")
|
|
2076
|
+
if not (re.match(r"^corpus/notes/[^/]+\.md$", path) and LOC_LINE_RE.match(loc)
|
|
2077
|
+
and (bundle / path).is_file()):
|
|
2078
|
+
if rel in written:
|
|
2079
|
+
errors.append("claim %s in %s is a RULING resting on %r, not on a "
|
|
2080
|
+
"note in corpus/notes/ cited by line (a ruling is a "
|
|
2081
|
+
"note, reconciliation.md) -- carry it by hand"
|
|
2082
|
+
% (r["id"] or "(no id)", rel, src))
|
|
2083
|
+
continue
|
|
2084
|
+
users.setdefault(path, []).append((rel, r))
|
|
2085
|
+
ndir = bundle / "corpus" / "notes"
|
|
2086
|
+
for p in (sorted(ndir.glob("*.md")) if ndir.is_dir() else []):
|
|
2087
|
+
meta = sdlc_core.load_frontmatter(_kb_bom_text(p.read_bytes())[1].splitlines()) or {}
|
|
2088
|
+
if meta.get("basis") and not meta.get("imported_from"):
|
|
2089
|
+
users.setdefault("corpus/notes/" + p.name, [])
|
|
2090
|
+
renames = {} # original note -> (copy, bytes, k)
|
|
2091
|
+
for path in sorted(users):
|
|
2092
|
+
data = (bundle / path).read_bytes()
|
|
2093
|
+
meta = sdlc_core.load_frontmatter(_kb_bom_text(data)[1].splitlines()) or {}
|
|
2094
|
+
ruled = [(rel, r) for rel, r in users[path] if rel in written]
|
|
2095
|
+
where = ("claim %s in %s" % (ruled[0][1]["id"] or "(no id)", ruled[0][0])
|
|
2096
|
+
if ruled else path)
|
|
2097
|
+
if meta.get("imported_from"):
|
|
2098
|
+
if ruled:
|
|
2099
|
+
errors.append("%s is a RULING on %s, which already names a foreign "
|
|
2100
|
+
"origin -- the source project relabelled an imported "
|
|
2101
|
+
"decision; fix it there" % (where, path))
|
|
2102
|
+
continue # already foreign: it travels as it is
|
|
2103
|
+
home = docs / path
|
|
2104
|
+
if not ruled and home.is_file() and kb_sha256_bytes(home) == hashlib.sha256(data).hexdigest():
|
|
2105
|
+
continue # this project holds the note, no topic here rules on it
|
|
2106
|
+
foreign = _kb_foreign_note(data, project, path)
|
|
2107
|
+
if foreign is None:
|
|
2108
|
+
if ruled:
|
|
2109
|
+
errors.append("%s rests on %s, which opens no frontmatter"
|
|
2110
|
+
% (where, path))
|
|
2111
|
+
continue
|
|
2112
|
+
copy = "corpus/notes/imported_%s_%s" % (slug, path[len("corpus/notes/"):])
|
|
2113
|
+
if sdlc_core.confine_under(docs, copy) is None:
|
|
2114
|
+
errors.append("renamed note %r escapes the docs root" % copy)
|
|
2115
|
+
continue
|
|
2116
|
+
renames[path] = (copy, foreign[0], foreign[1])
|
|
2117
|
+
if errors:
|
|
2118
|
+
return content, drop, report, errors
|
|
2119
|
+
for old, (copy, data, _k) in renames.items():
|
|
2120
|
+
drop.add(old)
|
|
2121
|
+
content[copy] = data
|
|
2122
|
+
for p in sorted((bundle / "corpus" / "notes").glob("*.md")) \
|
|
2123
|
+
if (bundle / "corpus" / "notes").is_dir() else []:
|
|
2124
|
+
rel = "corpus/notes/" + p.name
|
|
2125
|
+
if rel in drop:
|
|
2126
|
+
continue
|
|
2127
|
+
new = _kb_rewrite_derived_from(p.read_bytes(),
|
|
2128
|
+
dict((o, c) for o, (c, _d, _k) in renames.items()))
|
|
2129
|
+
if new != p.read_bytes():
|
|
2130
|
+
content[rel] = new
|
|
2131
|
+
for copy in [c for c in list(content) if c in [v[0] for v in renames.values()]]:
|
|
2132
|
+
content[copy] = _kb_rewrite_derived_from(
|
|
2133
|
+
content[copy], dict((o, c) for o, (c, _d, _k) in renames.items()))
|
|
2134
|
+
|
|
2135
|
+
# rows of the topics this import writes: move sources, re-mint ids
|
|
2136
|
+
ren, rows_w, prov_of = {}, [], {}
|
|
2137
|
+
for rel in topic_rels:
|
|
2138
|
+
if rel not in written:
|
|
2139
|
+
continue
|
|
2140
|
+
for r in parsed[rel][1]:
|
|
2141
|
+
rows_w.append((rel, r))
|
|
2142
|
+
for rel, r in rows_w:
|
|
2143
|
+
cells = [s.strip() for s in r["source"].split(";") if s.strip()]
|
|
2144
|
+
moved = []
|
|
2145
|
+
for n, s in enumerate(cells):
|
|
2146
|
+
path, _, loc = s.rpartition("#")
|
|
2147
|
+
if path in renames and LOC_LINE_RE.match(loc):
|
|
2148
|
+
copy, data, k = renames[path]
|
|
2149
|
+
nloc = _kb_shift_loc(loc, k)
|
|
2150
|
+
if _kb_span((bundle / path).read_bytes(), loc) != _kb_span(data, nloc):
|
|
2151
|
+
errors.append("claim %s in %s: %s#%s would not cite the same text "
|
|
2152
|
+
"in %s#%s -- nothing was converted"
|
|
2153
|
+
% (r["id"], rel, path, loc, copy, nloc))
|
|
2154
|
+
cells[n] = "%s#%s" % (copy, nloc)
|
|
2155
|
+
moved.append(n)
|
|
2156
|
+
elif path in renames:
|
|
2157
|
+
errors.append("claim %s in %s cites %s by %r, not by line: the "
|
|
2158
|
+
"renamed copy would move it" % (r["id"], rel, path, loc))
|
|
2159
|
+
r["_source"] = "; ".join(cells) if moved else r["source"]
|
|
2160
|
+
r["_id"] = r["id"]
|
|
2161
|
+
if 0 in moved and r["id"]:
|
|
2162
|
+
path, _, loc = cells[0].rpartition("#")
|
|
2163
|
+
try:
|
|
2164
|
+
r["_id"] = kb_claim_id(path, loc, kb_qty_key(r["qty"]))
|
|
2165
|
+
except ValueError:
|
|
2166
|
+
pass
|
|
2167
|
+
ren[r["id"]] = r["_id"]
|
|
2168
|
+
conv = r["prov"].strip().upper() == "RULING" and \
|
|
2169
|
+
r["source"].split(";")[0].strip().rpartition("#")[0] in renames
|
|
2170
|
+
r["_prov"] = "IMPORTED" if conv else r["prov"]
|
|
2171
|
+
r["_conv"] = conv
|
|
2172
|
+
if errors:
|
|
2173
|
+
return content, drop, report, errors
|
|
2174
|
+
converted = set(r["id"] for _rel, r in rows_w if r["_conv"])
|
|
2175
|
+
report["converted"] = len(converted)
|
|
2176
|
+
members = {}
|
|
2177
|
+
for rel, r in rows_w:
|
|
2178
|
+
kind, targets = _kb_states(r["state"])
|
|
2179
|
+
hit = [t for t in targets if t in converted]
|
|
2180
|
+
if kind != "SUPERSEDED" or not hit:
|
|
2181
|
+
continue
|
|
2182
|
+
if len(hit) != len(targets):
|
|
2183
|
+
errors.append("claim %s in %s is SUPERSEDED by the ruling %s and by %s, "
|
|
2184
|
+
"which is not a ruling: its set cannot reopen without "
|
|
2185
|
+
"deciding which successor stands -- re-rule it in the "
|
|
2186
|
+
"source project" % (r["id"], rel, ", ".join(hit),
|
|
2187
|
+
", ".join(t for t in targets if t not in hit)))
|
|
2188
|
+
continue
|
|
2189
|
+
for t in hit:
|
|
2190
|
+
members.setdefault(t, set()).add(r["id"])
|
|
2191
|
+
partners = {}
|
|
2192
|
+
for rel, r in rows_w:
|
|
2193
|
+
if r["_conv"] and members.get(r["id"]):
|
|
2194
|
+
kind, targets = _kb_states(r["state"])
|
|
2195
|
+
if kind == "SUPERSEDED" and any(t not in converted for t in targets):
|
|
2196
|
+
errors.append("ruling %s in %s settled rows but was itself superseded "
|
|
2197
|
+
"by %s, which is not a ruling: its set cannot reopen "
|
|
2198
|
+
"cleanly -- re-rule it in the source project"
|
|
2199
|
+
% (r["id"], rel, ", ".join(targets)))
|
|
2200
|
+
group = members[r["id"]] | {r["id"]}
|
|
2201
|
+
for x in group:
|
|
2202
|
+
partners.setdefault(x, set()).update(group - {x})
|
|
2203
|
+
if errors:
|
|
2204
|
+
return content, drop, report, errors
|
|
2205
|
+
for rel, r in rows_w:
|
|
2206
|
+
kind, targets = _kb_states(r["state"])
|
|
2207
|
+
if r["id"] in partners:
|
|
2208
|
+
own = set(targets) if kind == "CONTESTED" else set()
|
|
2209
|
+
r["_state"] = "CONTESTED " + ", ".join(
|
|
2210
|
+
sorted(ren.get(t, t) for t in own | partners[r["id"]]))
|
|
2211
|
+
elif kind:
|
|
2212
|
+
r["_state"] = "%s %s" % (kind, ", ".join(ren.get(t, t) for t in targets))
|
|
2213
|
+
else:
|
|
2214
|
+
r["_state"] = r["state"]
|
|
2215
|
+
prov_of[r["_id"]] = r["_prov"].strip().upper()
|
|
2216
|
+
|
|
2217
|
+
# boundary: every pointer must resolve after the import, never onto a foreign row
|
|
2218
|
+
held = {}
|
|
2219
|
+
for _slug, (_p, _t, trows) in kb_collect_topics(docs).items():
|
|
2220
|
+
for t in trows:
|
|
2221
|
+
if t["id"]:
|
|
2222
|
+
held[t["id"]] = (t["prov"].strip().upper(), t["state"])
|
|
2223
|
+
for rel, r in rows_w:
|
|
2224
|
+
kind, targets = _kb_states(r["_state"])
|
|
2225
|
+
for t in targets:
|
|
2226
|
+
prov = prov_of.get(t) or (held.get(t) or (None,))[0]
|
|
2227
|
+
if t not in prov_of and t in held and kind == "CONTESTED":
|
|
2228
|
+
hkind, hts = _kb_states(held[t][1])
|
|
2229
|
+
if hkind != "CONTESTED" or r["_id"] not in hts:
|
|
2230
|
+
errors.append("claim %s in %s is CONTESTED with %s, which this "
|
|
2231
|
+
"project holds without naming it back: the import "
|
|
2232
|
+
"never edits an existing topic -- add the pointer "
|
|
2233
|
+
"there first, or carry the row by hand"
|
|
2234
|
+
% (r["_id"], rel, t))
|
|
2235
|
+
continue
|
|
2236
|
+
if prov is None:
|
|
2237
|
+
errors.append("claim %s in %s points at %s, which this import does not "
|
|
2238
|
+
"write and this project does not hold: its set would "
|
|
2239
|
+
"arrive incomplete -- import the topics together"
|
|
2240
|
+
% (r["_id"], rel, t))
|
|
2241
|
+
elif kind == "SUPERSEDED" and prov == "IMPORTED":
|
|
2242
|
+
errors.append("claim %s in %s is SUPERSEDED by %s, which is IMPORTED "
|
|
2243
|
+
"here: a foreign decision cannot settle it -- import the "
|
|
2244
|
+
"topics together, or reopen the set by hand"
|
|
2245
|
+
% (r["_id"], rel, t))
|
|
2246
|
+
if errors:
|
|
2247
|
+
return content, drop, report, errors
|
|
2248
|
+
|
|
2249
|
+
for rel in topic_rels:
|
|
2250
|
+
if rel not in written:
|
|
2251
|
+
continue
|
|
2252
|
+
text, rows = parsed[rel]
|
|
2253
|
+
lines = text.split("\n")
|
|
2254
|
+
for r in rows:
|
|
2255
|
+
up = {}
|
|
2256
|
+
for col, key in (("id", "_id"), ("source", "_source"), ("prov", "_prov"),
|
|
2257
|
+
("state", "_state")):
|
|
2258
|
+
if r[key] != r[col]:
|
|
2259
|
+
up[CLAIM_COLUMNS.index(col)] = r[key]
|
|
2260
|
+
if up:
|
|
2261
|
+
lines[r["_line"] - 1] = _kb_replace_cells(lines[r["_line"] - 1], up)
|
|
2262
|
+
new = "\n".join(lines).encode("utf-8")
|
|
2263
|
+
if new != (bundle / rel).read_bytes():
|
|
2264
|
+
content[rel] = new
|
|
2265
|
+
for rel, r in rulings:
|
|
2266
|
+
path = r["source"].split(";")[0].strip().rpartition("#")[0]
|
|
2267
|
+
if rel not in written and path in renames:
|
|
2268
|
+
note = renames[path][0]
|
|
2269
|
+
if note not in report["notes"]:
|
|
2270
|
+
report["notes"].append(note)
|
|
2271
|
+
return content, drop, report, errors
|
|
2272
|
+
|
|
2273
|
+
|
|
2274
|
+
def kb_import_plan(bundle, docs, report=None):
|
|
1845
2275
|
"""(writes, skipped_topics, dedup, errors) -- computed BEFORE anything is
|
|
1846
2276
|
written, because an import that half-applies leaves a tree whose checks fail
|
|
1847
|
-
and whose owner cannot tell what landed.
|
|
2277
|
+
and whose owner cannot tell what landed. Each write is (rel, bytes, dst):
|
|
2278
|
+
the bytes are the bundle's, except where F-083 makes a foreign ruling land
|
|
2279
|
+
foreign (kb_import_foreign_rulings). `report`, when a dict, receives what
|
|
2280
|
+
that conversion did."""
|
|
1848
2281
|
errors, writes, skipped, dedup = [], [], [], []
|
|
1849
2282
|
man = bundle / BUNDLE_MANIFEST
|
|
1850
2283
|
if not man.is_file():
|
|
@@ -1860,6 +2293,7 @@ def kb_import_plan(bundle, docs):
|
|
|
1860
2293
|
if r["id"]:
|
|
1861
2294
|
known_ids.add(r["id"])
|
|
1862
2295
|
|
|
2296
|
+
entries, topic_rels, written = [], [], set()
|
|
1863
2297
|
for p in sorted(bundle.rglob("*")):
|
|
1864
2298
|
if not p.is_file() or p.name == BUNDLE_MANIFEST:
|
|
1865
2299
|
continue
|
|
@@ -1869,22 +2303,48 @@ def kb_import_plan(bundle, docs):
|
|
|
1869
2303
|
errors.append("bundle entry %r escapes the docs root — refusing the "
|
|
1870
2304
|
"whole import, not just this file" % rel)
|
|
1871
2305
|
continue
|
|
2306
|
+
if rel.startswith("topics/"):
|
|
2307
|
+
topic_rels.append(rel)
|
|
2308
|
+
if dst.is_file():
|
|
2309
|
+
skipped.append(rel)
|
|
2310
|
+
continue
|
|
2311
|
+
written.add(rel)
|
|
2312
|
+
entries.append((rel, p, dst))
|
|
2313
|
+
for rel, p, _dst in entries:
|
|
2314
|
+
if rel.startswith("topics/"):
|
|
2315
|
+
for r in kb_parse_claims(sdlc_core.read_text(p))[0]:
|
|
2316
|
+
if _kb_is_declared(r):
|
|
2317
|
+
errors.append("%s: claim %s is DECLARED -- a declaration stays in "
|
|
2318
|
+
"the project that made it; export never ships one, "
|
|
2319
|
+
"so this bundle was edited by hand"
|
|
2320
|
+
% (rel, r["id"] or "(no id)"))
|
|
2321
|
+
if errors:
|
|
2322
|
+
return [], skipped, [], errors
|
|
2323
|
+
|
|
2324
|
+
content, drop, rep, errors = kb_import_foreign_rulings(
|
|
2325
|
+
bundle, docs, topic_rels, written, str(meta.get("source_project", "")).strip())
|
|
2326
|
+
if report is not None:
|
|
2327
|
+
report.update(rep)
|
|
2328
|
+
if errors:
|
|
2329
|
+
return [], skipped, [], errors
|
|
2330
|
+
planned = [(rel, p, dst) for rel, p, dst in entries if rel not in drop]
|
|
2331
|
+
for rel in sorted(set(content) - set(rel for rel, _p, _d in entries)):
|
|
2332
|
+
planned.append((rel, None, sdlc_core.confine_under(docs, rel)))
|
|
2333
|
+
for rel, p, dst in planned:
|
|
2334
|
+
data = content[rel] if rel in content else p.read_bytes()
|
|
1872
2335
|
if dst.is_file() and not rel.startswith("topics/"):
|
|
1873
|
-
if kb_sha256_bytes(dst) !=
|
|
2336
|
+
if kb_sha256_bytes(dst) != hashlib.sha256(data).hexdigest():
|
|
1874
2337
|
errors.append("%s exists with different bytes (target %s… vs "
|
|
1875
2338
|
"bundle %s…): content-addressed names must mean "
|
|
1876
2339
|
"equal content" % (rel, kb_sha256_bytes(dst)[:8],
|
|
1877
|
-
|
|
2340
|
+
hashlib.sha256(data).hexdigest()[:8]))
|
|
1878
2341
|
continue
|
|
1879
|
-
|
|
1880
|
-
skipped.append(rel)
|
|
1881
|
-
continue
|
|
1882
|
-
writes.append((rel, p, dst))
|
|
2342
|
+
writes.append((rel, data, dst))
|
|
1883
2343
|
|
|
1884
|
-
for rel,
|
|
2344
|
+
for rel, data, _dst in writes:
|
|
1885
2345
|
if not rel.startswith("topics/"):
|
|
1886
2346
|
continue
|
|
1887
|
-
rows, _ = kb_parse_claims(
|
|
2347
|
+
rows, _ = kb_parse_claims(data.decode("utf-8"))
|
|
1888
2348
|
for r in rows:
|
|
1889
2349
|
if r["id"] and r["id"] in known_ids:
|
|
1890
2350
|
dedup.append(r["id"])
|
|
@@ -2023,7 +2483,8 @@ def kb_validate_surface(docs, full=False):
|
|
|
2023
2483
|
return rc
|
|
2024
2484
|
|
|
2025
2485
|
INTERCEPTED = {"index", "validate", "check", "graph", "corpus", "claim-id",
|
|
2026
|
-
"anchor", "export", "import", "stale", "recall", "adopt"
|
|
2486
|
+
"anchor", "export", "import", "stale", "recall", "adopt",
|
|
2487
|
+
"inventory"}
|
|
2027
2488
|
|
|
2028
2489
|
|
|
2029
2490
|
def _kb_root(args):
|
|
@@ -2093,6 +2554,9 @@ def _kb_extra_validate(docs):
|
|
|
2093
2554
|
"""The kb additions to validate: generated-index freshness for the overlay's
|
|
2094
2555
|
two indexes. Prints nothing on a tree without them (TS-K7)."""
|
|
2095
2556
|
rc = 0
|
|
2557
|
+
for e in kb_exclusions(docs)[1]:
|
|
2558
|
+
print("[ERROR] %s" % e)
|
|
2559
|
+
rc = 1
|
|
2096
2560
|
checks = (("topics", kb_build_topic_index), ("corpus", kb_build_corpus_index))
|
|
2097
2561
|
for dirname, builder in checks:
|
|
2098
2562
|
idx = docs / dirname / "INDEX.md"
|
|
@@ -2185,6 +2649,391 @@ def kb_cmd_adopt(root, docs):
|
|
|
2185
2649
|
return 0
|
|
2186
2650
|
|
|
2187
2651
|
|
|
2652
|
+
# --------------------------------------------------------------------------
|
|
2653
|
+
# F-084: the corpus's reach over the project that holds it. A file left out on
|
|
2654
|
+
# purpose and a file nobody opened used to look the same; these classify every
|
|
2655
|
+
# project file against the sidecars, by content, and name the residue.
|
|
2656
|
+
|
|
2657
|
+
KB_REGISTRY = "audit/INVENTORY_exclusions.md"
|
|
2658
|
+
KB_REACH_DEADLINE = 1.0 # orient's walk; inventory has none (on demand)
|
|
2659
|
+
_kb_clock = time.monotonic # injectable for the deadline test
|
|
2660
|
+
_KB_DATE_RE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})\b")
|
|
2661
|
+
# Windows: FILE_ATTRIBUTE_OFFLINE, RECALL_ON_OPEN, RECALL_ON_DATA_ACCESS;
|
|
2662
|
+
# macOS: SF_DATALESS. A file whose bytes are not on the disk is never read --
|
|
2663
|
+
# reading it would download it.
|
|
2664
|
+
_KB_CLOUD_ATTRS = 0x1000 | 0x40000 | 0x400000
|
|
2665
|
+
_KB_SF_DATALESS = 0x40000000
|
|
2666
|
+
_KB_REPARSE_POINT = 0x400
|
|
2667
|
+
|
|
2668
|
+
|
|
2669
|
+
class _KbDeadline(Exception):
|
|
2670
|
+
pass
|
|
2671
|
+
|
|
2672
|
+
|
|
2673
|
+
def _kb_glob_regex(pattern):
|
|
2674
|
+
"""FS2's gitignore subset -> (compiled regex, dir_only)."""
|
|
2675
|
+
dir_only = pattern.endswith("/")
|
|
2676
|
+
pat = pattern.rstrip("/")
|
|
2677
|
+
out, i = [], 0
|
|
2678
|
+
while i < len(pat):
|
|
2679
|
+
c = pat[i]
|
|
2680
|
+
if pat.startswith("**/", i):
|
|
2681
|
+
out.append("(?:[^/]+/)*")
|
|
2682
|
+
i += 3
|
|
2683
|
+
elif pat.startswith("**", i):
|
|
2684
|
+
out.append(".*")
|
|
2685
|
+
i += 2
|
|
2686
|
+
elif c == "*":
|
|
2687
|
+
out.append("[^/]*")
|
|
2688
|
+
i += 1
|
|
2689
|
+
elif c == "?":
|
|
2690
|
+
out.append("[^/]")
|
|
2691
|
+
i += 1
|
|
2692
|
+
elif c == "[":
|
|
2693
|
+
j = pat.find("]", i + 1)
|
|
2694
|
+
if j < 0:
|
|
2695
|
+
out.append(re.escape(c))
|
|
2696
|
+
i += 1
|
|
2697
|
+
else:
|
|
2698
|
+
body = pat[i + 1:j]
|
|
2699
|
+
if body.startswith("!"):
|
|
2700
|
+
body = "^" + body[1:]
|
|
2701
|
+
out.append("[%s]" % body.replace("\\", "\\\\"))
|
|
2702
|
+
i = j + 1
|
|
2703
|
+
else:
|
|
2704
|
+
out.append(re.escape(c))
|
|
2705
|
+
i += 1
|
|
2706
|
+
rx = "".join(out)
|
|
2707
|
+
rx = ("^" + rx + "$") if "/" in pat else ("^(?:.*/)?" + rx + "$")
|
|
2708
|
+
return re.compile(rx, re.IGNORECASE if os.name == "nt" else 0), dir_only
|
|
2709
|
+
|
|
2710
|
+
|
|
2711
|
+
def kb_exclusions(docs):
|
|
2712
|
+
"""(rows, errors) of the exclusion registry. rows: (pattern, regex,
|
|
2713
|
+
dir_only). An absent registry excludes nothing and is silent."""
|
|
2714
|
+
p = docs / KB_REGISTRY
|
|
2715
|
+
rows, errors = [], []
|
|
2716
|
+
if not p.is_file():
|
|
2717
|
+
return rows, errors
|
|
2718
|
+
import datetime as _dt
|
|
2719
|
+
for n, line in enumerate(sdlc_core.read_text(p).splitlines(), 1):
|
|
2720
|
+
s = line.strip()
|
|
2721
|
+
if not s.startswith("|"):
|
|
2722
|
+
continue
|
|
2723
|
+
cells = [c.strip() for c in s.strip("|").split("|")]
|
|
2724
|
+
joined = "".join(cells)
|
|
2725
|
+
if "-" in joined and set(joined) <= set("-: "):
|
|
2726
|
+
continue # separator
|
|
2727
|
+
if cells and cells[0].lower() == "pattern":
|
|
2728
|
+
continue # header
|
|
2729
|
+
where = "%s:%d" % (KB_REGISTRY, n)
|
|
2730
|
+
if len(cells) != 3:
|
|
2731
|
+
errors.append("%s: a row has three cells: pattern | reason | approved" % where)
|
|
2732
|
+
continue
|
|
2733
|
+
pattern, reason, approved = cells
|
|
2734
|
+
bad = []
|
|
2735
|
+
if not pattern:
|
|
2736
|
+
bad.append("empty pattern")
|
|
2737
|
+
elif pattern.startswith("/") or ".." in pattern.split("/"):
|
|
2738
|
+
bad.append("pattern %r leaves the project root" % pattern)
|
|
2739
|
+
if not reason:
|
|
2740
|
+
bad.append("empty reason -- say why it never enters the corpus")
|
|
2741
|
+
m = _KB_DATE_RE.match(approved)
|
|
2742
|
+
ok_date = False
|
|
2743
|
+
if m:
|
|
2744
|
+
try:
|
|
2745
|
+
_dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
2746
|
+
ok_date = True
|
|
2747
|
+
except ValueError:
|
|
2748
|
+
pass
|
|
2749
|
+
if not ok_date:
|
|
2750
|
+
bad.append("approved %r is not the day of the practitioner's approval "
|
|
2751
|
+
"(YYYY-MM-DD)" % approved)
|
|
2752
|
+
rx = dir_only = None
|
|
2753
|
+
if not bad:
|
|
2754
|
+
try:
|
|
2755
|
+
rx, dir_only = _kb_glob_regex(pattern)
|
|
2756
|
+
except re.error as e:
|
|
2757
|
+
bad.append("pattern %r is not a valid glob (%s)" % (pattern, e))
|
|
2758
|
+
if bad:
|
|
2759
|
+
errors.append("%s: %s" % (where, "; ".join(bad)))
|
|
2760
|
+
continue
|
|
2761
|
+
rows.append((pattern, rx, dir_only))
|
|
2762
|
+
return rows, errors
|
|
2763
|
+
|
|
2764
|
+
|
|
2765
|
+
def kb_excluded(rel, is_dir, rows):
|
|
2766
|
+
"""The registry pattern matching project-relative `rel`, or None."""
|
|
2767
|
+
for pattern, rx, dir_only in rows:
|
|
2768
|
+
if dir_only and not is_dir:
|
|
2769
|
+
continue
|
|
2770
|
+
if rx.match(rel):
|
|
2771
|
+
return pattern
|
|
2772
|
+
return None
|
|
2773
|
+
|
|
2774
|
+
|
|
2775
|
+
def kb_unreadable(entry):
|
|
2776
|
+
"""'link' / 'cloud-only' for a DirEntry whose bytes are never read; None
|
|
2777
|
+
otherwise. Links include Windows junctions (the `_memory_safe` idiom)."""
|
|
2778
|
+
try:
|
|
2779
|
+
if entry.is_symlink():
|
|
2780
|
+
return "link"
|
|
2781
|
+
is_junction = getattr(entry, "is_junction", None)
|
|
2782
|
+
if is_junction is not None and is_junction():
|
|
2783
|
+
return "link"
|
|
2784
|
+
st = entry.stat(follow_symlinks=False)
|
|
2785
|
+
except OSError:
|
|
2786
|
+
return "unreadable"
|
|
2787
|
+
attrs = getattr(st, "st_file_attributes", 0) or 0
|
|
2788
|
+
if attrs & _KB_REPARSE_POINT and entry.is_dir(follow_symlinks=False):
|
|
2789
|
+
return "link" # a junction on a runtime without is_junction
|
|
2790
|
+
if attrs & _KB_CLOUD_ATTRS or (getattr(st, "st_flags", 0) or 0) & _KB_SF_DATALESS:
|
|
2791
|
+
return "cloud-only"
|
|
2792
|
+
return None
|
|
2793
|
+
|
|
2794
|
+
|
|
2795
|
+
def _kb_path_key(p):
|
|
2796
|
+
return os.path.normcase(os.path.abspath(str(p)))
|
|
2797
|
+
|
|
2798
|
+
|
|
2799
|
+
def _kb_reach_index(docs):
|
|
2800
|
+
"""What the sidecars say about originals: digest -> sidecar, named path ->
|
|
2801
|
+
(sidecar, original_sha256 or None), the artifacts' sizes. Tombstones count
|
|
2802
|
+
for nothing, their `original_path:` included."""
|
|
2803
|
+
by_digest, by_path, sizes = {}, {}, set()
|
|
2804
|
+
given = docs / "corpus" / "given"
|
|
2805
|
+
if not given.is_dir():
|
|
2806
|
+
return by_digest, by_path, sizes
|
|
2807
|
+
for meta_p in sorted(given.glob("*.meta.md")):
|
|
2808
|
+
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
|
|
2809
|
+
if kb_is_tombstone(meta):
|
|
2810
|
+
continue
|
|
2811
|
+
rel = "corpus/given/" + meta_p.name
|
|
2812
|
+
try:
|
|
2813
|
+
sizes.add((given / meta_p.name[:-len(".meta.md")]).stat().st_size)
|
|
2814
|
+
except OSError:
|
|
2815
|
+
pass
|
|
2816
|
+
for k in ("sha256", "original_sha256"):
|
|
2817
|
+
v = (meta.get(k) or "").strip().lower()
|
|
2818
|
+
if v:
|
|
2819
|
+
by_digest.setdefault(v, rel)
|
|
2820
|
+
op = kb_unquote(meta.get("original_path"))
|
|
2821
|
+
if op:
|
|
2822
|
+
osha = (meta.get("original_sha256") or "").strip().lower() or None
|
|
2823
|
+
for c in _kb_original_candidates(op, docs):
|
|
2824
|
+
try:
|
|
2825
|
+
present = c.is_symlink() or c.exists()
|
|
2826
|
+
except OSError:
|
|
2827
|
+
present = False
|
|
2828
|
+
if present:
|
|
2829
|
+
by_path.setdefault(_kb_path_key(c), (rel, osha))
|
|
2830
|
+
break
|
|
2831
|
+
return by_digest, by_path, sizes
|
|
2832
|
+
|
|
2833
|
+
|
|
2834
|
+
def kb_corpus_reach(docs, rel=".", exact=True, deadline=None):
|
|
2835
|
+
"""FS1/FS3: classify the project files under `rel` (project-relative).
|
|
2836
|
+
|
|
2837
|
+
exact=True (inventory): every candidate is hashed -- comparison by content,
|
|
2838
|
+
wherever the file now lives. exact=False (orient's bound): only files whose
|
|
2839
|
+
size equals some given/ artifact's are hashed, and every file an
|
|
2840
|
+
original_path names counts as examined, so the NOT_EXAMINED count never
|
|
2841
|
+
undercounts. Returns {"entries": [(rel, STATE, basis)], "not_examined": n}.
|
|
2842
|
+
Raises _KbDeadline when `deadline` seconds of walking pass.
|
|
2843
|
+
"""
|
|
2844
|
+
project = docs.parent
|
|
2845
|
+
rows, _errors = kb_exclusions(docs)
|
|
2846
|
+
by_digest, by_path, sizes = _kb_reach_index(docs)
|
|
2847
|
+
docs_key = _kb_path_key(docs)
|
|
2848
|
+
entries, counter = [], [0]
|
|
2849
|
+
start = _kb_clock()
|
|
2850
|
+
|
|
2851
|
+
def add(r, state, basis=""):
|
|
2852
|
+
entries.append((r, state, basis))
|
|
2853
|
+
if state == "NOT_EXAMINED":
|
|
2854
|
+
counter[0] += 1
|
|
2855
|
+
|
|
2856
|
+
def visit(e, erel):
|
|
2857
|
+
if deadline is not None and _kb_clock() - start > deadline:
|
|
2858
|
+
raise _KbDeadline()
|
|
2859
|
+
kind = kb_unreadable(e)
|
|
2860
|
+
try:
|
|
2861
|
+
# A link's target is never looked up (a dead network target would
|
|
2862
|
+
# stall orient): a link is matched by its NAME, as if it were a
|
|
2863
|
+
# directory, for the skip rule and dir-only exclusions.
|
|
2864
|
+
dir_like = kind == "link" or e.is_dir(follow_symlinks=False)
|
|
2865
|
+
except OSError:
|
|
2866
|
+
dir_like = False
|
|
2867
|
+
if dir_like and _kb_path_key(e.path) == docs_key:
|
|
2868
|
+
return
|
|
2869
|
+
if dir_like and sdlc_core.skip_dir(e.name):
|
|
2870
|
+
add(erel + "/", "SKIPPED", "built-in rule")
|
|
2871
|
+
return
|
|
2872
|
+
pat = kb_excluded(erel, dir_like, rows)
|
|
2873
|
+
if pat:
|
|
2874
|
+
add(erel + "/" if dir_like else erel, "EXCLUDED", pat)
|
|
2875
|
+
return
|
|
2876
|
+
if kind is None and dir_like:
|
|
2877
|
+
walk(e.path, erel)
|
|
2878
|
+
return
|
|
2879
|
+
if kind == "link":
|
|
2880
|
+
dir_like = False
|
|
2881
|
+
named = by_path.get(_kb_path_key(e.path))
|
|
2882
|
+
if kind:
|
|
2883
|
+
if named:
|
|
2884
|
+
add(erel, "INGESTED", "%s, by path — not read" % named[0])
|
|
2885
|
+
else:
|
|
2886
|
+
add(erel, "NOT_EXAMINED", {"link": "link — not followed",
|
|
2887
|
+
"cloud-only": "cloud-only — not read"}
|
|
2888
|
+
.get(kind, "unreadable — not read"))
|
|
2889
|
+
return
|
|
2890
|
+
if not e.is_file(follow_symlinks=False):
|
|
2891
|
+
return
|
|
2892
|
+
digest = None
|
|
2893
|
+
try:
|
|
2894
|
+
if exact or e.stat(follow_symlinks=False).st_size in sizes:
|
|
2895
|
+
digest = kb_sha256_bytes(Path(e.path))
|
|
2896
|
+
except OSError:
|
|
2897
|
+
digest = None
|
|
2898
|
+
if digest and digest in by_digest:
|
|
2899
|
+
add(erel, "INGESTED", by_digest[digest])
|
|
2900
|
+
elif named:
|
|
2901
|
+
srel, osha = named
|
|
2902
|
+
if osha is None:
|
|
2903
|
+
add(erel, "INGESTED", "%s, by path — digest not kept" % srel)
|
|
2904
|
+
elif not exact:
|
|
2905
|
+
add(erel, "INGESTED", srel) # examined; exactness is inventory's
|
|
2906
|
+
else:
|
|
2907
|
+
add(erel, "INGESTED_EARLIER", "%s, original changed since ingest" % srel)
|
|
2908
|
+
else:
|
|
2909
|
+
add(erel, "NOT_EXAMINED")
|
|
2910
|
+
|
|
2911
|
+
def walk(d, drel):
|
|
2912
|
+
try:
|
|
2913
|
+
with os.scandir(d) as it:
|
|
2914
|
+
items = sorted(it, key=lambda x: x.name)
|
|
2915
|
+
except OSError:
|
|
2916
|
+
add((drel + "/") if drel else "./", "NOT_EXAMINED", "unreadable — not read")
|
|
2917
|
+
return
|
|
2918
|
+
for e in items:
|
|
2919
|
+
visit(e, (drel + "/" + e.name) if drel else e.name)
|
|
2920
|
+
|
|
2921
|
+
rel = (rel or ".").replace("\\", "/").strip("/")
|
|
2922
|
+
if rel in ("", "."):
|
|
2923
|
+
walk(str(project), "")
|
|
2924
|
+
else:
|
|
2925
|
+
target = project / rel
|
|
2926
|
+
with os.scandir(str(target.parent)) as it:
|
|
2927
|
+
for e in it:
|
|
2928
|
+
if os.path.normcase(e.name) == os.path.normcase(target.name):
|
|
2929
|
+
visit(e, rel)
|
|
2930
|
+
entries.sort(key=lambda t: t[0])
|
|
2931
|
+
return {"entries": entries, "not_examined": counter[0]}
|
|
2932
|
+
|
|
2933
|
+
|
|
2934
|
+
def kb_cmd_inventory(root, docs, path):
|
|
2935
|
+
"""FS3: one line per project file under `path`, in path order, with its
|
|
2936
|
+
state. No totals, no filter, no sort by state (r37). Writes nothing."""
|
|
2937
|
+
if not docs.is_dir():
|
|
2938
|
+
print("[ERROR] no docs root")
|
|
2939
|
+
return 1
|
|
2940
|
+
project = docs.parent
|
|
2941
|
+
rel = (path or "").replace("\\", "/").strip()
|
|
2942
|
+
parts = [x for x in rel.split("/") if x not in ("", ".")]
|
|
2943
|
+
if rel.startswith("/") or ntpath.isabs(rel) or ".." in parts:
|
|
2944
|
+
print("[ERROR] %s is outside the project root" % path)
|
|
2945
|
+
return 2
|
|
2946
|
+
# Lexical, never resolved: a link on the way is not followed (FS1). Each
|
|
2947
|
+
# component is matched to the name on disk, case-insensitively where the
|
|
2948
|
+
# filesystem is, so a differently-cased path is never an empty listing.
|
|
2949
|
+
real = []
|
|
2950
|
+
for name in parts:
|
|
2951
|
+
here = project.joinpath(*real)
|
|
2952
|
+
try:
|
|
2953
|
+
with os.scandir(str(here)) as it:
|
|
2954
|
+
found = [e.name for e in it
|
|
2955
|
+
if os.path.normcase(e.name) == os.path.normcase(name)]
|
|
2956
|
+
except OSError:
|
|
2957
|
+
found = []
|
|
2958
|
+
if not found:
|
|
2959
|
+
print("[ERROR] %s does not exist" % path)
|
|
2960
|
+
return 2
|
|
2961
|
+
real.append(name if name in found else found[0])
|
|
2962
|
+
if len(real) < len(parts) and (
|
|
2963
|
+
os.path.islink(project.joinpath(*real))
|
|
2964
|
+
or getattr(os.path, "isjunction", lambda _p: False)(project.joinpath(*real))):
|
|
2965
|
+
print("[ERROR] %s lies behind %s, a link the inventory never follows"
|
|
2966
|
+
% (path, "/".join(real)))
|
|
2967
|
+
return 2
|
|
2968
|
+
parts = real
|
|
2969
|
+
rows, _e = kb_exclusions(docs)
|
|
2970
|
+
for i, name in enumerate(parts):
|
|
2971
|
+
prefix = "/".join(parts[:i + 1])
|
|
2972
|
+
here = project.joinpath(*parts[:i + 1])
|
|
2973
|
+
if _kb_path_key(here) == _kb_path_key(docs):
|
|
2974
|
+
print("[ERROR] %s is the docs root: the corpus is not a project-file area" % path)
|
|
2975
|
+
return 2
|
|
2976
|
+
dir_like = os.path.islink(here) or os.path.isdir(here)
|
|
2977
|
+
if dir_like and sdlc_core.skip_dir(name):
|
|
2978
|
+
print("[ERROR] %s lies in %s/, which the validator never treats as "
|
|
2979
|
+
"project files (built-in rule)" % (path, prefix))
|
|
2980
|
+
return 2
|
|
2981
|
+
if dir_like and kb_excluded(prefix, True, rows):
|
|
2982
|
+
print("[ERROR] %s lies in %s/, excluded by %s" % (path, prefix, KB_REGISTRY))
|
|
2983
|
+
return 2
|
|
2984
|
+
reach = kb_corpus_reach(docs, "/".join(parts) or ".", exact=True)
|
|
2985
|
+
for r, state, basis in reach["entries"]:
|
|
2986
|
+
print(("%-16s %s %s" % (state, r, basis)).rstrip())
|
|
2987
|
+
print("Nothing was written.")
|
|
2988
|
+
return 0
|
|
2989
|
+
|
|
2990
|
+
|
|
2991
|
+
def kb_changed_originals(docs):
|
|
2992
|
+
"""FS5: [(artifact rel, original_path, n citing claims)] for every live,
|
|
2993
|
+
non-superseded sidecar whose local original's bytes differ from the
|
|
2994
|
+
`original_sha256:` it recorded at ingest."""
|
|
2995
|
+
given = docs / "corpus" / "given"
|
|
2996
|
+
if not given.is_dir():
|
|
2997
|
+
return []
|
|
2998
|
+
metas = []
|
|
2999
|
+
superseded = set()
|
|
3000
|
+
for meta_p in sorted(given.glob("*.meta.md")):
|
|
3001
|
+
meta = sdlc_core.load_frontmatter(sdlc_core.read_text(meta_p).splitlines()) or {}
|
|
3002
|
+
metas.append((meta_p, meta))
|
|
3003
|
+
sup = kb_unquote(meta.get("supersedes"))
|
|
3004
|
+
if sup:
|
|
3005
|
+
superseded.add(sup)
|
|
3006
|
+
cites = {}
|
|
3007
|
+
for _slug, (_p, _t, rows) in kb_collect_topics(docs).items():
|
|
3008
|
+
for r in rows:
|
|
3009
|
+
for src in set(kb_claim_sources(r)):
|
|
3010
|
+
cites[src] = cites.get(src, 0) + 1
|
|
3011
|
+
out = []
|
|
3012
|
+
for meta_p, meta in metas:
|
|
3013
|
+
art = meta_p.name[:-len(".meta.md")]
|
|
3014
|
+
if kb_is_tombstone(meta) or art in superseded:
|
|
3015
|
+
continue
|
|
3016
|
+
op = kb_unquote(meta.get("original_path"))
|
|
3017
|
+
osha = (meta.get("original_sha256") or "").strip().lower()
|
|
3018
|
+
if not (op and osha):
|
|
3019
|
+
continue
|
|
3020
|
+
for c in _kb_original_candidates(op, docs):
|
|
3021
|
+
try:
|
|
3022
|
+
if c.is_symlink() or not c.is_file():
|
|
3023
|
+
continue
|
|
3024
|
+
st = os.lstat(str(c)) # attributes without opening the file
|
|
3025
|
+
if (getattr(st, "st_file_attributes", 0) or 0) & _KB_CLOUD_ATTRS or \
|
|
3026
|
+
(getattr(st, "st_flags", 0) or 0) & _KB_SF_DATALESS:
|
|
3027
|
+
break
|
|
3028
|
+
if kb_sha256_bytes(c) != osha:
|
|
3029
|
+
rel = "corpus/given/" + art
|
|
3030
|
+
out.append((rel, op, cites.get(rel, 0)))
|
|
3031
|
+
except OSError:
|
|
3032
|
+
pass
|
|
3033
|
+
break
|
|
3034
|
+
return out
|
|
3035
|
+
|
|
3036
|
+
|
|
2188
3037
|
def kb_cmd_stale(root, docs, hybrid=False):
|
|
2189
3038
|
"""F-044: the consolidation view. The spine's output and exit semantics
|
|
2190
3039
|
come first (--hybrid passed through untouched), then the `## claims`
|
|
@@ -2200,6 +3049,10 @@ def kb_cmd_stale(root, docs, hybrid=False):
|
|
|
2200
3049
|
% (topic_rel, rid, until,
|
|
2201
3050
|
("cited by: " + ", ".join(by)) if by else "uncited"))
|
|
2202
3051
|
lines += chain_w
|
|
3052
|
+
for rel, op, n in kb_changed_originals(docs):
|
|
3053
|
+
lines.append("%s: original %s changed since ingest -- %d claim(s) cite it; "
|
|
3054
|
+
"ingest the new version as a successor (supersedes:)"
|
|
3055
|
+
% (rel, op, n))
|
|
2203
3056
|
if lines:
|
|
2204
3057
|
print("")
|
|
2205
3058
|
print("## claims")
|
|
@@ -2290,6 +3143,8 @@ def kb_cmd_export(args):
|
|
|
2290
3143
|
if dropped:
|
|
2291
3144
|
print(" not-yet-honored markers dropped: they describe this "
|
|
2292
3145
|
"project's code")
|
|
3146
|
+
if kb_has_declarations(selected):
|
|
3147
|
+
print(" declarations stay in this project: not exported")
|
|
2293
3148
|
if added:
|
|
2294
3149
|
# never silent: a set that grew is a fact about the export, and the
|
|
2295
3150
|
# alternative -- dropping the partner rows -- ships a broken tree.
|
|
@@ -2304,7 +3159,8 @@ def kb_cmd_import(args):
|
|
|
2304
3159
|
if not bundle.is_dir():
|
|
2305
3160
|
print("[ERROR] no such bundle directory: %s" % bundle)
|
|
2306
3161
|
return 2
|
|
2307
|
-
|
|
3162
|
+
report = {}
|
|
3163
|
+
writes, skipped, dedup, errors = kb_import_plan(bundle, docs, report)
|
|
2308
3164
|
for e in errors:
|
|
2309
3165
|
print("[ERROR] %s" % e)
|
|
2310
3166
|
if errors:
|
|
@@ -2314,10 +3170,18 @@ def kb_cmd_import(args):
|
|
|
2314
3170
|
if args.dry_run:
|
|
2315
3171
|
print("[ok] dry run: %d file(s) would be written" % len(writes))
|
|
2316
3172
|
else:
|
|
2317
|
-
for _rel,
|
|
3173
|
+
for _rel, data, dst in writes:
|
|
2318
3174
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
2319
|
-
dst.write_bytes(
|
|
3175
|
+
dst.write_bytes(data)
|
|
2320
3176
|
print("[ok] imported %d file(s) into %s" % (len(writes), docs))
|
|
3177
|
+
if report.get("converted"):
|
|
3178
|
+
# portability.md §0 point 4: the agent must be able to tell the user
|
|
3179
|
+
print(" %d ruling(s) arrive as IMPORTED: each rests on a renamed copy of "
|
|
3180
|
+
"its note (imported_<project>_...) and settles nothing until you "
|
|
3181
|
+
"ratify it (portability.md §4)" % report["converted"])
|
|
3182
|
+
for note in report.get("notes", []):
|
|
3183
|
+
print("[note] %s: a ruling of a skipped topic lands only as this foreign "
|
|
3184
|
+
"copy -- a row merged by hand cites it as IMPORTED" % note)
|
|
2321
3185
|
if dedup:
|
|
2322
3186
|
print(" %d claim(s) already present, by id — the same artifact cited "
|
|
2323
3187
|
"at the same span mints the same id in any project" % len(dedup))
|
|
@@ -2326,7 +3190,8 @@ def kb_cmd_import(args):
|
|
|
2326
3190
|
"(taxonomy.md) and merge by hand — an import never decides that."
|
|
2327
3191
|
% rel)
|
|
2328
3192
|
print("[note] re-run 'sdlc_check.py check' now: the import is additive, and "
|
|
2329
|
-
"an imported
|
|
3193
|
+
"an imported ruling stays IMPORTED until you ratify it with a note of "
|
|
3194
|
+
"your own.")
|
|
2330
3195
|
return 0
|
|
2331
3196
|
|
|
2332
3197
|
|
|
@@ -2512,6 +3377,23 @@ def _kb_orient_append(argv):
|
|
|
2512
3377
|
% (n_docs, m_exp, k_chain))
|
|
2513
3378
|
except (Exception, SystemExit):
|
|
2514
3379
|
pass
|
|
3380
|
+
try:
|
|
3381
|
+
# F-084: the corpus's reach, an upper bound of the project files never
|
|
3382
|
+
# examined (R5). Its own try: a failing walk prints nothing and takes
|
|
3383
|
+
# nothing else with it.
|
|
3384
|
+
if root is not None and (root / name / "corpus").is_dir():
|
|
3385
|
+
try:
|
|
3386
|
+
n = kb_corpus_reach(root / name, ".", exact=False,
|
|
3387
|
+
deadline=KB_REACH_DEADLINE)["not_examined"]
|
|
3388
|
+
except _KbDeadline:
|
|
3389
|
+
print("\nCorpus reach: not counted within 1s -- "
|
|
3390
|
+
"sdlc_check.py inventory <path>")
|
|
3391
|
+
else:
|
|
3392
|
+
if n:
|
|
3393
|
+
print("\nCorpus reach: <= %d project files not examined -- "
|
|
3394
|
+
"exact: sdlc_check.py inventory <path>" % n)
|
|
3395
|
+
except (Exception, SystemExit):
|
|
3396
|
+
pass
|
|
2515
3397
|
|
|
2516
3398
|
|
|
2517
3399
|
def kb_cmd_help():
|
|
@@ -2541,6 +3423,10 @@ shared project memory -- also available:
|
|
|
2541
3423
|
adopt dry run: document guides resting on .sources/
|
|
2542
3424
|
snapshots, and what moving them into corpus/given/
|
|
2543
3425
|
involves (writes nothing; portability.md par.6)
|
|
3426
|
+
inventory <path> the project files under <path> (. = all), each with
|
|
3427
|
+
its state against the corpus: INGESTED,
|
|
3428
|
+
INGESTED_EARLIER, EXCLUDED, NOT_EXAMINED (or
|
|
3429
|
+
SKIPPED); no totals, writes nothing
|
|
2544
3430
|
remind one-line per-turn reminder (UserPromptSubmit hook,
|
|
2545
3431
|
opt-in -- ENFORCEMENT par.4; constant, zero-read)
|
|
2546
3432
|
|
|
@@ -2611,6 +3497,10 @@ def main(argv=None):
|
|
|
2611
3497
|
p = sub.add_parser("adopt")
|
|
2612
3498
|
p.add_argument("--root")
|
|
2613
3499
|
p.add_argument("--docs-dir")
|
|
3500
|
+
p = sub.add_parser("inventory")
|
|
3501
|
+
p.add_argument("path")
|
|
3502
|
+
p.add_argument("--root")
|
|
3503
|
+
p.add_argument("--docs-dir")
|
|
2614
3504
|
args = ap.parse_args(argv)
|
|
2615
3505
|
if args.cmd == "claim-id":
|
|
2616
3506
|
return kb_cmd_claim_id(args)
|
|
@@ -2638,6 +3528,8 @@ def main(argv=None):
|
|
|
2638
3528
|
return kb_cmd_corpus(docs, extra_errors=kb_credential_errors(root, docs))
|
|
2639
3529
|
if args.cmd == "adopt":
|
|
2640
3530
|
return kb_cmd_adopt(root, docs)
|
|
3531
|
+
if args.cmd == "inventory":
|
|
3532
|
+
return kb_cmd_inventory(root, docs, args.path)
|
|
2641
3533
|
if args.cmd == "validate":
|
|
2642
3534
|
return kb_cmd_validate(root, docs, strict=args.strict, hybrid=args.hybrid)
|
|
2643
3535
|
return kb_cmd_check(root, docs, strict=args.strict, hybrid=args.hybrid)
|