@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.
@@ -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() != "GIVEN":
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
- m = re.match(r"^(CONTESTED|SUPERSEDED) ([0-9a-f, ]+)$", state)
575
- if not m:
576
- errors.append("%s: state must be OK, 'CONTESTED <id>[,..]' or "
577
- "'SUPERSEDED <id>[,..]': %r" % (where, state))
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
- # Re-ratify first: write your own ruling note with your own
595
- # basis and flip the row to RULING.
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
- "Re-ratify it (own note, own 'basis:', prov "
599
- "RULING) or resolve this some other way"
600
- % (where, t))
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
- m = re.match(r"^(CONTESTED|SUPERSEDED) ([0-9a-f, ]+)$", r["state"].strip())
1786
- if not m:
1787
- continue
1788
- for ref in [x.strip() for x in m.group(2).split(",") if x.strip()]:
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 other is None:
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 kb_import_plan(bundle, docs):
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) != kb_sha256_bytes(p):
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
- kb_sha256_bytes(p)[:8]))
2340
+ hashlib.sha256(data).hexdigest()[:8]))
1878
2341
  continue
1879
- if rel.startswith("topics/") and dst.is_file():
1880
- skipped.append(rel)
1881
- continue
1882
- writes.append((rel, p, dst))
2342
+ writes.append((rel, data, dst))
1883
2343
 
1884
- for rel, p, _dst in writes:
2344
+ for rel, data, _dst in writes:
1885
2345
  if not rel.startswith("topics/"):
1886
2346
  continue
1887
- rows, _ = kb_parse_claims(sdlc_core.read_text(p))
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
- writes, skipped, dedup, errors = kb_import_plan(bundle, docs)
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, src, dst in writes:
3173
+ for _rel, data, dst in writes:
2318
3174
  dst.parent.mkdir(parents=True, exist_ok=True)
2319
- dst.write_bytes(src.read_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 RULING stays IMPORTED until you re-ratify it.")
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)