@antoneeo/course-creator 0.1.0-beta.1 → 0.1.0-beta.10

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.
@@ -125,6 +125,21 @@ def kb_unquote(value):
125
125
  return v
126
126
 
127
127
 
128
+ def frontmatter_date(meta):
129
+ """A note's or a sidecar's `date:` as `YYYY-MM-DD`, or None (F-073).
130
+ Quotes stripped, a datetime suffix tolerated, an impossible date refused --
131
+ the one parser behind orient's recency probe and any chronology reader."""
132
+ m = re.match(r"(\d{4}-\d{2}-\d{2})", kb_unquote((meta or {}).get("date")))
133
+ if not m:
134
+ return None
135
+ try:
136
+ import datetime as _dt
137
+ _dt.date.fromisoformat(m.group(1))
138
+ except ValueError:
139
+ return None
140
+ return m.group(1)
141
+
142
+
128
143
  def kb_parse_scope(text):
129
144
  """'-' | 'from X' | 'until X' | 'from X until Y' | 'if <cond>' ->
130
145
  ('always',) | ('window', from, until) | ('if', cond). Raises on junk."""
@@ -336,6 +351,17 @@ def kb_check_claims(root):
336
351
  if rel not in fm_cache:
337
352
  fm_cache[rel] = _note_frontmatter(root, rel)
338
353
  return fm_cache[rel]
354
+ # F-069: the same per-run memo for the source paths themselves -- each
355
+ # distinct path is resolved and stat'ed once, and each cited extraction
356
+ # read once (loc_cache, owned here, dropped when this call returns).
357
+ src_cache = {}
358
+ loc_cache = {}
359
+
360
+ def _resolved(path_s):
361
+ if path_s not in src_cache:
362
+ t = sdlc_core.confine_under(root, path_s)
363
+ src_cache[path_s] = (t, t is not None and t.is_file())
364
+ return src_cache[path_s]
339
365
  per_file_rows = []
340
366
  for p in sorted(topics.glob("*.md")):
341
367
  rel = "topics/" + p.name
@@ -357,16 +383,16 @@ def kb_check_claims(root):
357
383
  errors.append("%s: source %r has no locator" % (where, src))
358
384
  continue
359
385
  path_s, loc = src.rsplit("#", 1)
360
- target = sdlc_core.confine_under(root, path_s)
386
+ target, is_file = _resolved(path_s)
361
387
  if target is None:
362
388
  errors.append("%s: source %r escapes the docs root" % (where, src))
363
389
  continue
364
- if not target.is_file():
390
+ if not is_file:
365
391
  errors.append("%s: source %r does not resolve — a claim whose "
366
392
  "origin cannot be reopened is model knowledge"
367
393
  % (where, src))
368
394
  continue
369
- kb_check_locator(target, loc, where, errors)
395
+ kb_check_locator(target, loc, where, errors, loc_cache)
370
396
  # --- provenance ---
371
397
  prov = row["prov"]
372
398
  # Resolved ONCE, for every provenance: the non-GIVEN classes read it
@@ -556,8 +582,14 @@ def kb_check_claims(root):
556
582
  return errors, warnings, notes
557
583
 
558
584
 
559
- def kb_check_locator(target, loc, where, errors):
560
- """A locator must address an existing span of the stored bytes."""
585
+ def kb_check_locator(target, loc, where, errors, cache=None):
586
+ """A locator must address an existing span of the stored bytes.
587
+
588
+ `cache` is an optional dict the CALLER owns for one run (F-069): keyed by
589
+ the path actually read, it holds only what the checks compare against --
590
+ page lengths, a line count, or False for a missing extraction -- never the
591
+ text. Findings are still appended per call, so per row. Without it, every
592
+ call reads the file, as before."""
561
593
  m = LOC_PAGE_RE.match(loc)
562
594
  if m:
563
595
  page, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
@@ -566,17 +598,24 @@ def kb_check_locator(target, loc, where, errors):
566
598
  return
567
599
  # offsets address the stored extraction (<stem>.txt beside the original)
568
600
  ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
569
- if not ext.is_file():
601
+ key = ("p", ext)
602
+ if cache is None or key not in cache:
603
+ lengths = ([len(pg) for pg in sdlc_core.read_text(ext).split("\f")]
604
+ if ext.is_file() else False)
605
+ if cache is not None:
606
+ cache[key] = lengths
607
+ else:
608
+ lengths = cache[key]
609
+ if lengths is False:
570
610
  errors.append("%s: no stored extraction %s for locator %r — offsets "
571
611
  "must address kept bytes" % (where, ext.name, loc))
572
612
  return
573
- pages = sdlc_core.read_text(ext).split("\f")
574
- if page < 1 or page > len(pages):
613
+ if page < 1 or page > len(lengths):
575
614
  errors.append("%s: locator %r addresses page %d of %d" %
576
- (where, loc, page, len(pages)))
577
- elif b > len(pages[page - 1]):
615
+ (where, loc, page, len(lengths)))
616
+ elif b > lengths[page - 1]:
578
617
  errors.append("%s: locator %r spans past the end of page %d "
579
- "(%d chars)" % (where, loc, page, len(pages[page - 1])))
618
+ "(%d chars)" % (where, loc, page, lengths[page - 1]))
580
619
  return
581
620
  m = LOC_LINE_RE.match(loc)
582
621
  if m:
@@ -584,7 +623,13 @@ def kb_check_locator(target, loc, where, errors):
584
623
  if a > b or a < 1:
585
624
  errors.append("%s: locator %r has an invalid line range" % (where, loc))
586
625
  return
587
- n = sdlc_core.read_text(target).count("\n") + 1
626
+ key = ("L", target)
627
+ if cache is None or key not in cache:
628
+ n = sdlc_core.read_text(target).count("\n") + 1
629
+ if cache is not None:
630
+ cache[key] = n
631
+ else:
632
+ n = cache[key]
588
633
  if b > n:
589
634
  errors.append("%s: locator %r spans past line %d" % (where, loc, n))
590
635
  return
@@ -1085,12 +1130,18 @@ KB_GENERATED_RE = re.compile(r"GENERATED by \S+ index - do not edit by hand")
1085
1130
  KB_HEX12_RE = re.compile(r"^[0-9a-f]{12}$")
1086
1131
 
1087
1132
 
1088
- def _kb_hex_bounded(text, cid):
1089
- """True when cid appears NOT embedded in a longer hex run: a git hash
1090
- sharing a substring must never count as a citation (either side, either
1091
- case). cid is pre-validated 12-hex, so it is regex-safe by construction."""
1092
- return re.search(r"(?<![0-9a-fA-F])%s(?![0-9a-fA-F])" % cid,
1093
- text) is not None
1133
+ # A citation of a claim id is a run of exactly its 12 lowercase hex characters
1134
+ # NOT embedded in a longer hex run: a git hash sharing a substring must never
1135
+ # count as a citation (either side, either case). Two such bounded runs cannot
1136
+ # overlap, so findall returns every one of them.
1137
+ KB_HEX_TOKEN_RE = re.compile(r"(?<![0-9a-fA-F])[0-9a-f]{12}(?![0-9a-fA-F])")
1138
+
1139
+
1140
+ def _kb_hex_tokens(text):
1141
+ """The ids a text cites, extracted in one pass (F-069). Callers test
1142
+ membership instead of scanning the text once per id: that per-(document,
1143
+ id) scan made the time cycle grow with superseded ids x living documents."""
1144
+ return frozenset(KB_HEX_TOKEN_RE.findall(text))
1094
1145
 
1095
1146
 
1096
1147
  def _kb_df_entries(value):
@@ -1172,19 +1223,23 @@ def kb_time_cycle(docs):
1172
1223
  if (meta.get("status") or "").strip() == "SUPERSEDED":
1173
1224
  continue
1174
1225
  living.append((rel, text))
1175
- for rel, text in living:
1176
- for old in sorted(superseded):
1177
- if _kb_hex_bounded(text, old):
1178
- succs = superseded[old]
1179
- missing = tuple(x for x in succs
1180
- if not _kb_hex_bounded(text, x))
1181
- if missing:
1182
- cited.append((rel, old, missing, succs))
1226
+ # One token pass per living document, and none at all on a KB with no
1227
+ # supersession and no validity window (the orient line runs this at every
1228
+ # session start).
1229
+ tokens = ({rel: _kb_hex_tokens(text) for rel, text in living}
1230
+ if superseded or windows else {})
1231
+ for rel, _text in living:
1232
+ cites = tokens[rel] if tokens else frozenset()
1233
+ for old in cites.intersection(superseded):
1234
+ succs = superseded[old]
1235
+ missing = tuple(x for x in succs if x not in cites)
1236
+ if missing:
1237
+ cited.append((rel, old, missing, succs))
1183
1238
  today = _dt.date.today().isoformat()
1184
1239
  for topic_rel, rid, until in sorted(windows):
1185
1240
  if until <= today:
1186
- by = tuple(rel for rel, text in living
1187
- if rel != topic_rel and _kb_hex_bounded(text, rid))
1241
+ by = tuple(rel for rel, _text in living
1242
+ if rel != topic_rel and rid in tokens[rel])
1188
1243
  expired.append((topic_rel, rid, until, by))
1189
1244
  rev = {}
1190
1245
  node_meta = {}
@@ -1491,16 +1546,17 @@ def memory_records(docs):
1491
1546
  paths.append(p)
1492
1547
  records = []
1493
1548
  for p in sorted(set(paths), key=lambda p: p.relative_to(docs).as_posix()):
1494
- raw = p.read_bytes()
1495
- text = raw.decode("utf-8", errors="replace")
1549
+ text = p.read_bytes().decode("utf-8", errors="replace")
1496
1550
  meta = sdlc_core.load_frontmatter(text.splitlines())
1497
1551
  title = next((line[2:].strip() for line in text.splitlines()
1498
1552
  if line.startswith("# ")), p.stem)
1553
+ # LF-normalized like the guides' source_hash: an autocrlf checkout must
1554
+ # regenerate the same catalog as an LF one.
1499
1555
  records.append((p.relative_to(docs).as_posix(),
1500
1556
  kb_unquote(meta.get("domain")) or default,
1501
1557
  ", ".join(_as_list(meta.get("topics", ""))),
1502
1558
  kb_unquote(meta.get("description")) or title,
1503
- hashlib.sha256(raw).hexdigest()))
1559
+ sdlc_core.sha256_file(p)))
1504
1560
  return records
1505
1561
 
1506
1562
 
@@ -1662,17 +1718,18 @@ def kb_cmd_validate(root, docs, strict=False, hybrid=False):
1662
1718
 
1663
1719
  def kb_cmd_check(root, docs, strict=False, hybrid=False):
1664
1720
  # The spine's check owns its banners and summary line: reuse it whole, so a
1665
- # tree with no kb surface gets byte-identical output. The kb checks run
1666
- # after, and only when the surface exists.
1667
- rc = sdlc_core.cmd_check(root, strict=strict, hybrid=hybrid)
1668
- rc = max(rc, _kb_extra_validate(docs), memory_validate(docs))
1721
+ # tree with no kb surface gets byte-identical output. The kb checks ride its
1722
+ # hooks -- index freshness in the validate stage, graph/corpus as sections
1723
+ # only when the surface exists -- so the summary is printed after all of them.
1724
+ sections = ()
1669
1725
  if (docs / "topics").is_dir() or (docs / "corpus").is_dir():
1670
1726
  cycle = kb_time_cycle(docs)
1671
- print("===== graph =====")
1672
- rc = max(rc, kb_cmd_graph(docs, cycle))
1673
- print("===== corpus =====")
1674
- rc = max(rc, kb_cmd_corpus(docs, cycle))
1675
- return rc
1727
+ sections = (("graph", lambda: kb_cmd_graph(docs, cycle)),
1728
+ ("corpus", lambda: kb_cmd_corpus(docs, cycle)))
1729
+ return sdlc_core.cmd_check(
1730
+ root, strict=strict, hybrid=hybrid,
1731
+ extra_validate=lambda: max(_kb_extra_validate(docs), memory_validate(docs)),
1732
+ extra_sections=sections)
1676
1733
 
1677
1734
 
1678
1735
  def kb_cmd_stale(root, docs, hybrid=False):
@@ -1903,14 +1960,13 @@ def kb_cmd_orient(argv):
1903
1960
  # it -- the file was already fully read, so the cap bought
1904
1961
  # only failure. load_frontmatter is the authoritative
1905
1962
  # reader (fence-bounded, so a column-0 date: in the body
1906
- # still cannot misdate the note); kb_unquote covers quoted
1907
- # dates; the prefix match tolerates a datetime suffix.
1963
+ # still cannot misdate the note); frontmatter_date covers
1964
+ # quoted dates and tolerates a datetime suffix.
1908
1965
  meta = sdlc_core.load_frontmatter(
1909
1966
  sdlc_core.read_text(p).splitlines()) or {}
1910
- m = re.match(r"(\d{4}-\d{2}-\d{2})",
1911
- kb_unquote(meta.get("date")))
1912
- if m:
1913
- stamp = _dt.date.fromisoformat(m.group(1))
1967
+ day = frontmatter_date(meta)
1968
+ if day:
1969
+ stamp = _dt.date.fromisoformat(day)
1914
1970
  except Exception:
1915
1971
  pass
1916
1972
  if stamp is None: