its-magic 0.1.2-43 → 0.1.2-48

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.
@@ -0,0 +1,608 @@
1
+ """
2
+ README feature coverage predicate, backlog parser, and affinity resolver (US-0091 / DEC-0074).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import os
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Dict, List, Optional, Set, Tuple
12
+
13
+ US_BLOCK_HEADER = re.compile(r"^## (US-\d{4})\s*[—-]\s*(.*)\s*$")
14
+ BUG_BLOCK_HEADER = re.compile(r"^### (BUG-\d{4})\s*[—-]\s*(.*)\s*$")
15
+ BUG_SECTION_HEADER = "## Bug issues (canonical)"
16
+ STATUS_LINE = re.compile(r"^-\s*Status:\s*(OPEN|DONE)\s*$", re.IGNORECASE)
17
+ USER_VISIBLE_LINE = re.compile(
18
+ r"^-\s*user_visible:\s*(true|false)\s*$", re.IGNORECASE
19
+ )
20
+ FIELD_LINE = re.compile(r"^-\s*([A-Za-z_][A-Za-z0-9_]*):\s*(.*)\s*$")
21
+ SLASH_CMD = re.compile(r"(?:^|\s)(/[a-z][a-z0-9-]*)")
22
+ SCRATCHPAD_KEY = re.compile(r"(?:^|[\s`'])([A-Z][A-Z0-9_]+)(?:=|\b)")
23
+ PYTHON_SCRIPT = re.compile(
24
+ r"python\s+scripts/[a-z0-9_./-]+\.py", re.IGNORECASE
25
+ )
26
+ US_ID = re.compile(r"\bUS-\d{4}\b")
27
+ BUG_ID = re.compile(r"\bBUG-\d{4}\b")
28
+
29
+ H5_KEYWORDS = (
30
+ "archiver",
31
+ "hot-surface rollover",
32
+ "intake evidence schema",
33
+ "template parity guard",
34
+ "triad",
35
+ )
36
+
37
+ REASON_BLOCKED = "README_FEATURE_COVERAGE_BLOCKED"
38
+ REASON_GAP = "README_FEATURE_COVERAGE_GAP"
39
+ REASON_PARITY_FAIL = "README_FEATURE_COVERAGE_PARITY_FAIL"
40
+ REASON_INPUT_INVALID = "README_FEATURE_COVERAGE_INPUT_INVALID"
41
+ REASON_PROFILE_VIOLATION = "README_FEATURE_COVERAGE_PROFILE_VIOLATION"
42
+
43
+ REPORT_SCHEMA_VERSION = 1
44
+
45
+ DEFAULT_AFFINITY: Dict[str, Any] = {
46
+ "affinity_version": 1,
47
+ "rules": [
48
+ {
49
+ "tag": "slash_command",
50
+ "root_h2": "Commands and workflow",
51
+ "dev_h2": "Workflow",
52
+ },
53
+ {
54
+ "tag": "scratchpad_mode",
55
+ "root_h2": "Other useful capabilities",
56
+ "dev_h2": "Quality gates",
57
+ },
58
+ {
59
+ "tag": "distribution",
60
+ "root_h2": "Features",
61
+ "dev_h2": "Architecture notes",
62
+ },
63
+ {
64
+ "tag": "release_gate",
65
+ "root_h2": "Commands and workflow",
66
+ "dev_h2": "Quality gates",
67
+ },
68
+ {
69
+ "tag": "governance",
70
+ "root_h2": "Other useful capabilities",
71
+ "dev_h2": "Engineering decisions",
72
+ },
73
+ ],
74
+ }
75
+
76
+
77
+ @dataclass
78
+ class WorkItem:
79
+ item_id: str
80
+ kind: str # US | BUG
81
+ title: str
82
+ status: Optional[str]
83
+ summary: str
84
+ user_visible: Optional[bool]
85
+ body_text: str
86
+ fields: Dict[str, str] = field(default_factory=dict)
87
+
88
+
89
+ @dataclass
90
+ class PredicateResult:
91
+ in_scope: bool
92
+ predicate_source: str
93
+ input_invalid: bool = False
94
+ invalid_reason: str = ""
95
+
96
+
97
+ @dataclass
98
+ class AffinityTarget:
99
+ tag: str
100
+ root_h2: str
101
+ dev_h2: str
102
+
103
+
104
+ def read_utf8(path: str) -> str:
105
+ with open(path, "r", encoding="utf-8") as f:
106
+ return f.read()
107
+
108
+
109
+ def load_affinity_manifest(repo_root: str) -> Dict[str, Any]:
110
+ path = os.path.join(
111
+ repo_root, "docs", "engineering", "context", "readme-section-affinity.json"
112
+ )
113
+ if not os.path.isfile(path):
114
+ return dict(DEFAULT_AFFINITY)
115
+ with open(path, "r", encoding="utf-8") as f:
116
+ data = json.load(f)
117
+ if data.get("affinity_version") != 1:
118
+ raise ValueError("affinity_version must be 1")
119
+ return data
120
+
121
+
122
+ def extract_bug_section(text: str) -> Optional[str]:
123
+ m_hdr = re.search(r"^## Bug issues \(canonical\)\s*$", text, re.MULTILINE)
124
+ if not m_hdr:
125
+ return None
126
+ start = m_hdr.end()
127
+ rest = text[start:]
128
+ m = re.search(r"\n## [^\n]+\n", rest)
129
+ if m:
130
+ return rest[: m.start()].strip()
131
+ return rest.strip()
132
+
133
+
134
+ def _parse_block_fields(block_lines: List[str]) -> Tuple[Optional[str], Dict[str, str], str]:
135
+ status: Optional[str] = None
136
+ fields: Dict[str, str] = {}
137
+ body_parts: List[str] = []
138
+ for raw in block_lines[1:]:
139
+ line = raw.rstrip()
140
+ body_parts.append(line)
141
+ sm = STATUS_LINE.match(line.strip())
142
+ if sm:
143
+ status = sm.group(1).upper()
144
+ continue
145
+ fm = FIELD_LINE.match(line.strip())
146
+ if fm:
147
+ key = fm.group(1).lower()
148
+ val = fm.group(2).strip()
149
+ fields[key] = val
150
+ return status, fields, "\n".join(body_parts)
151
+
152
+
153
+ def parse_us_blocks(text: str) -> List[WorkItem]:
154
+ lines = text.splitlines()
155
+ items: List[WorkItem] = []
156
+ i = 0
157
+ while i < len(lines):
158
+ m = US_BLOCK_HEADER.match(lines[i].strip())
159
+ if not m:
160
+ i += 1
161
+ continue
162
+ item_id, title = m.group(1), m.group(2).strip()
163
+ block_start = i
164
+ i += 1
165
+ while i < len(lines):
166
+ nxt = lines[i].strip()
167
+ if US_BLOCK_HEADER.match(nxt) or nxt == BUG_SECTION_HEADER:
168
+ break
169
+ if nxt.startswith("## ") and not nxt.startswith("### "):
170
+ break
171
+ i += 1
172
+ block_lines = lines[block_start:i]
173
+ status, fields, body_text = _parse_block_fields(block_lines)
174
+ uv_raw = None
175
+ for raw in block_lines[1:]:
176
+ uvm = USER_VISIBLE_LINE.match(raw.strip())
177
+ if uvm:
178
+ uv_raw = uvm.group(1).lower() == "true"
179
+ break
180
+ items.append(
181
+ WorkItem(
182
+ item_id=item_id,
183
+ kind="US",
184
+ title=title,
185
+ status=status,
186
+ summary=fields.get("summary", ""),
187
+ user_visible=uv_raw,
188
+ body_text=body_text,
189
+ fields=fields,
190
+ )
191
+ )
192
+ return items
193
+
194
+
195
+ def parse_bug_blocks(text: str) -> List[WorkItem]:
196
+ section = extract_bug_section(text)
197
+ if not section:
198
+ return []
199
+ lines = section.splitlines()
200
+ items: List[WorkItem] = []
201
+ i = 0
202
+ while i < len(lines):
203
+ m = BUG_BLOCK_HEADER.match(lines[i].strip())
204
+ if not m:
205
+ i += 1
206
+ continue
207
+ item_id, title = m.group(1), m.group(2).strip()
208
+ block_start = i
209
+ i += 1
210
+ while i < len(lines) and not BUG_BLOCK_HEADER.match(lines[i].strip()):
211
+ i += 1
212
+ block_lines = lines[block_start:i]
213
+ status, fields, body_text = _parse_block_fields(block_lines)
214
+ uv_raw = None
215
+ for raw in block_lines[1:]:
216
+ uvm = USER_VISIBLE_LINE.match(raw.strip())
217
+ if uvm:
218
+ uv_raw = uvm.group(1).lower() == "true"
219
+ break
220
+ items.append(
221
+ WorkItem(
222
+ item_id=item_id,
223
+ kind="BUG",
224
+ title=title,
225
+ status=status,
226
+ summary=fields.get("summary", title),
227
+ user_visible=uv_raw,
228
+ body_text=body_text,
229
+ fields=fields,
230
+ )
231
+ )
232
+ return items
233
+
234
+
235
+ def parse_backlog(backlog_text: str) -> List[WorkItem]:
236
+ return parse_us_blocks(backlog_text) + parse_bug_blocks(backlog_text)
237
+
238
+
239
+ def _heuristic_signals(text: str) -> Dict[str, bool]:
240
+ low = text.lower()
241
+ h1 = bool(SLASH_CMD.search(text))
242
+ h2 = bool(SCRATCHPAD_KEY.search(text)) or bool(
243
+ re.search(r"`[A-Z][A-Z0-9_]+`", text)
244
+ )
245
+ h3 = bool(PYTHON_SCRIPT.search(text))
246
+ h4 = False
247
+ if "expected" in low or "actual" in low:
248
+ h4 = True
249
+ h5 = any(kw in low for kw in H5_KEYWORDS)
250
+ return {"h1": h1, "h2": h2, "h3": h3, "h4": h4, "h5": h5}
251
+
252
+
253
+ def migration_heuristic(item: WorkItem) -> str:
254
+ """Return in_scope | out | ambiguous per DEC-0074 H1-H8."""
255
+ text = f"{item.title}\n{item.summary}\n{item.body_text}"
256
+ sig = _heuristic_signals(text)
257
+ if sig["h5"] and any(sig[k] for k in ("h1", "h2", "h3", "h4")):
258
+ return "in_scope"
259
+ if sig["h1"]:
260
+ return "in_scope"
261
+ if sig["h2"]:
262
+ return "in_scope"
263
+ if sig["h3"]:
264
+ return "in_scope"
265
+ if item.kind == "BUG" and sig["h4"]:
266
+ return "in_scope"
267
+ if sig["h5"]:
268
+ return "out"
269
+ if item.kind == "BUG":
270
+ return "out"
271
+ return "ambiguous"
272
+
273
+
274
+ def classify_item(item: WorkItem, enforce: bool) -> PredicateResult:
275
+ if item.status != "DONE":
276
+ return PredicateResult(False, "status:not_done")
277
+ if item.user_visible is False:
278
+ return PredicateResult(False, "explicit:false")
279
+ if item.user_visible is True:
280
+ return PredicateResult(True, "explicit:true")
281
+ if enforce:
282
+ return PredicateResult(
283
+ False,
284
+ "unset:enforce",
285
+ input_invalid=True,
286
+ invalid_reason=f"{item.item_id}: user_visible unset with README_FEATURE_COVERAGE_ENFORCE=1",
287
+ )
288
+ h = migration_heuristic(item)
289
+ if h == "in_scope":
290
+ src = _heuristic_source(item)
291
+ return PredicateResult(True, src)
292
+ if h == "out":
293
+ return PredicateResult(False, "heuristic:out")
294
+ return PredicateResult(
295
+ False,
296
+ "heuristic:ambiguous",
297
+ input_invalid=True,
298
+ invalid_reason=f"{item.item_id}: H7 ambiguous — set user_visible explicitly",
299
+ )
300
+
301
+
302
+ def _heuristic_source(item: WorkItem) -> str:
303
+ text = f"{item.title}\n{item.summary}\n{item.body_text}"
304
+ sig = _heuristic_signals(text)
305
+ if sig["h1"]:
306
+ return "heuristic:H1"
307
+ if sig["h2"]:
308
+ return "heuristic:H2"
309
+ if sig["h3"]:
310
+ return "heuristic:H3"
311
+ if item.kind == "BUG" and sig["h4"]:
312
+ return "heuristic:H4"
313
+ if sig["h5"] and any(sig[k] for k in ("h1", "h2", "h3", "h4")):
314
+ return "heuristic:H6"
315
+ return "heuristic:H1"
316
+
317
+
318
+ def resolve_affinity(item: WorkItem, manifest: Dict[str, Any]) -> AffinityTarget:
319
+ rules = manifest.get("rules", DEFAULT_AFFINITY["rules"])
320
+ by_tag = {r["tag"]: r for r in rules}
321
+ text = f"{item.title}\n{item.summary}\n{item.body_text}"
322
+ low = text.lower()
323
+ if SLASH_CMD.search(text) and "slash_command" in by_tag:
324
+ r = by_tag["slash_command"]
325
+ return AffinityTarget("slash_command", r["root_h2"], r["dev_h2"])
326
+ if any(k in low for k in ("npm", "chocolatey", "homebrew", "publish", "distribution")):
327
+ r = by_tag.get("distribution", rules[0])
328
+ return AffinityTarget("distribution", r["root_h2"], r["dev_h2"])
329
+ if any(k in low for k in ("/release", "release gate", "uat", "verify-work")):
330
+ r = by_tag.get("release_gate", rules[0])
331
+ return AffinityTarget("release_gate", r["root_h2"], r["dev_h2"])
332
+ if SCRATCHPAD_KEY.search(text) or re.search(r"`[A-Z][A-Z0-9_]+`", text):
333
+ r = by_tag.get("scratchpad_mode", rules[0])
334
+ return AffinityTarget("scratchpad_mode", r["root_h2"], r["dev_h2"])
335
+ r = by_tag.get("governance", rules[-1])
336
+ return AffinityTarget("governance", r["root_h2"], r["dev_h2"])
337
+
338
+
339
+ def split_h2_sections(markdown: str) -> Dict[str, str]:
340
+ sections: Dict[str, str] = {}
341
+ current: Optional[str] = None
342
+ buf: List[str] = []
343
+ for line in markdown.splitlines():
344
+ if line.startswith("## ") and not line.startswith("### "):
345
+ if current is not None:
346
+ sections[current] = "\n".join(buf)
347
+ current = line[3:].strip()
348
+ buf = []
349
+ elif current is not None:
350
+ buf.append(line)
351
+ if current is not None:
352
+ sections[current] = "\n".join(buf)
353
+ return sections
354
+
355
+
356
+ def _h2_match(section_title: str, wanted: str) -> bool:
357
+ if section_title == wanted:
358
+ return True
359
+ return section_title.startswith(wanted + " ") or section_title.startswith(wanted + "(")
360
+
361
+
362
+ def section_body(markdown: str, h2_wanted: str) -> str:
363
+ for title, body in split_h2_sections(markdown).items():
364
+ if _h2_match(title, h2_wanted):
365
+ return body
366
+ return ""
367
+
368
+
369
+ def has_root_coverage(section_text: str, item: WorkItem) -> bool:
370
+ if SLASH_CMD.search(section_text):
371
+ for m in SLASH_CMD.finditer(f"{item.title}\n{item.summary}"):
372
+ if m.group(1) in section_text:
373
+ return True
374
+ for m in SCRATCHPAD_KEY.finditer(f"{item.title}\n{item.summary}\n{item.body_text}"):
375
+ if m.group(1) in section_text:
376
+ return True
377
+ if item.item_id in section_text:
378
+ return True
379
+ if re.search(rf"\b{re.escape(item.item_id)}\b", section_text):
380
+ return True
381
+ return False
382
+
383
+
384
+ def has_dev_coverage(section_text: str, item: WorkItem) -> bool:
385
+ needle = item.item_id
386
+ for line in section_text.splitlines():
387
+ if needle not in line:
388
+ continue
389
+ if re.search(rf"\*\*{re.escape(needle)}\*\*", line):
390
+ return True
391
+ if "traceability:" in line.lower() and needle in line:
392
+ return True
393
+ return False
394
+
395
+
396
+ def check_template_parity(repo_root: str) -> List[str]:
397
+ errors: List[str] = []
398
+ pairs = (
399
+ ("README.md", "template/README.md"),
400
+ (
401
+ "scripts/readme_feature_coverage_lib.py",
402
+ "template/scripts/readme_feature_coverage_lib.py",
403
+ ),
404
+ (
405
+ "scripts/validate_readme_feature_coverage.py",
406
+ "template/scripts/validate_readme_feature_coverage.py",
407
+ ),
408
+ )
409
+ for active_rel, tpl_rel in pairs:
410
+ a = os.path.join(repo_root, active_rel.replace("/", os.sep))
411
+ t = os.path.join(repo_root, tpl_rel.replace("/", os.sep))
412
+ if not os.path.isfile(a) or not os.path.isfile(t):
413
+ errors.append(f"{REASON_PARITY_FAIL}: missing {active_rel} or {tpl_rel}")
414
+ continue
415
+ if read_utf8(a) != read_utf8(t):
416
+ errors.append(f"{REASON_PARITY_FAIL}: {active_rel} != {tpl_rel}")
417
+ return errors
418
+
419
+
420
+ def check_profile_budget(repo_root: str, merged: Dict[str, str]) -> List[str]:
421
+ try:
422
+ import doc_profile_lib
423
+ except ImportError:
424
+ return []
425
+ errs = doc_profile_lib.validate_repo_doc_profile(
426
+ repo_root, merged, os.path.join(repo_root, "template")
427
+ )
428
+ out: List[str] = []
429
+ for e in errs:
430
+ if "DOC_SECTION_BUDGET_EXCEEDED" in e or "DOC_SECTION_MISSING" in e:
431
+ out.append(f"{REASON_PROFILE_VIOLATION}: {e}")
432
+ return out
433
+
434
+
435
+ def build_report(
436
+ repo_root: str,
437
+ backlog_path: str,
438
+ enforce: bool,
439
+ merged: Optional[Dict[str, str]] = None,
440
+ skip_parity: bool = False,
441
+ ) -> Tuple[Dict[str, Any], List[str]]:
442
+ """Return (report_dict, stderr_lines)."""
443
+ stderr: List[str] = []
444
+ backlog_text = read_utf8(backlog_path)
445
+ items = parse_backlog(backlog_text)
446
+ manifest = load_affinity_manifest(repo_root)
447
+ root_readme = read_utf8(os.path.join(repo_root, "README.md"))
448
+ dev_readme = read_utf8(os.path.join(repo_root, "docs", "developer", "README.md"))
449
+
450
+ if not skip_parity:
451
+ for e in check_template_parity(repo_root):
452
+ stderr.append(e)
453
+
454
+ if merged is not None:
455
+ for e in check_profile_budget(repo_root, merged):
456
+ stderr.append(e)
457
+
458
+ gaps: List[Dict[str, Any]] = []
459
+ present: List[str] = []
460
+ missing: List[str] = []
461
+ total = 0
462
+
463
+ for item in sorted(items, key=lambda x: x.item_id):
464
+ pred = classify_item(item, enforce)
465
+ if pred.input_invalid:
466
+ stderr.append(f"{REASON_INPUT_INVALID}: {pred.invalid_reason}")
467
+ continue
468
+ if not pred.in_scope:
469
+ continue
470
+ total += 1
471
+ aff = resolve_affinity(item, manifest)
472
+ root_sec = section_body(root_readme, aff.root_h2)
473
+ dev_sec = section_body(dev_readme, aff.dev_h2)
474
+ root_ok = has_root_coverage(root_sec, item)
475
+ dev_ok = has_dev_coverage(dev_sec, item)
476
+ if root_ok and dev_ok:
477
+ present.append(item.item_id)
478
+ else:
479
+ missing.append(item.item_id)
480
+ gaps.append(
481
+ {
482
+ "dev_h2": aff.dev_h2,
483
+ "id": item.item_id,
484
+ "kind": item.kind,
485
+ "predicate_source": pred.predicate_source,
486
+ "root_h2": aff.root_h2,
487
+ "user_visible": item.user_visible if item.user_visible is not None else True,
488
+ }
489
+ )
490
+ stderr.append(f"{REASON_GAP}:{item.item_id}")
491
+
492
+ status = "PASS" if not stderr and not missing else "FAIL"
493
+ if missing or any(REASON_GAP in s for s in stderr):
494
+ status = "FAIL"
495
+
496
+ report = {
497
+ "coverage_missing": sorted(missing),
498
+ "coverage_present": sorted(present),
499
+ "coverage_total": total,
500
+ "gaps": sorted(gaps, key=lambda g: g["id"]),
501
+ "report_schema_version": REPORT_SCHEMA_VERSION,
502
+ "repo_root": ".",
503
+ "status": status,
504
+ }
505
+ return report, stderr
506
+
507
+
508
+ def canonical_json(obj: Dict[str, Any]) -> str:
509
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
510
+
511
+
512
+ def self_test_predicate_matrix() -> None:
513
+ cases = [
514
+ (
515
+ WorkItem("US-0001", "US", "t", "DONE", "workflow", True, "", {}),
516
+ False,
517
+ True,
518
+ "explicit:true",
519
+ ),
520
+ (
521
+ WorkItem("US-0002", "US", "t", "DONE", "internal", False, "", {}),
522
+ False,
523
+ False,
524
+ "explicit:false",
525
+ ),
526
+ (
527
+ WorkItem("US-0003", "US", "t", "DONE", "Use /release for ship", None, "", {}),
528
+ False,
529
+ True,
530
+ "heuristic:H1",
531
+ ),
532
+ (
533
+ WorkItem(
534
+ "US-0004",
535
+ "US",
536
+ "t",
537
+ "DONE",
538
+ "template parity guard triad archiver",
539
+ None,
540
+ "",
541
+ {},
542
+ ),
543
+ False,
544
+ False,
545
+ "heuristic:out",
546
+ ),
547
+ (
548
+ WorkItem(
549
+ "US-0005",
550
+ "US",
551
+ "t",
552
+ "DONE",
553
+ "triad archiver /intake",
554
+ None,
555
+ "",
556
+ {},
557
+ ),
558
+ False,
559
+ True,
560
+ None,
561
+ ),
562
+ (
563
+ WorkItem("US-0006", "US", "t", "DONE", "pure internal refactor", None, "", {}),
564
+ False,
565
+ False,
566
+ "heuristic:ambiguous",
567
+ True,
568
+ ),
569
+ (
570
+ WorkItem("BUG-0001", "BUG", "t", "DONE", "silent", None, "", {}),
571
+ False,
572
+ False,
573
+ "heuristic:out",
574
+ ),
575
+ ]
576
+ for case in cases:
577
+ item, enforce, want_in, want_src, *extra = case
578
+ want_invalid = bool(extra and extra[0] is True)
579
+ pred = classify_item(item, enforce)
580
+ if want_invalid:
581
+ assert pred.input_invalid, item.item_id
582
+ else:
583
+ assert pred.in_scope == want_in, (item.item_id, pred)
584
+ if want_in and want_src is not None:
585
+ assert pred.predicate_source == want_src, (
586
+ item.item_id,
587
+ pred.predicate_source,
588
+ )
589
+
590
+ # enforce=1 unset fails
591
+ unset = WorkItem("US-0099", "US", "t", "DONE", "x", None, "", {})
592
+ pred = classify_item(unset, True)
593
+ assert pred.input_invalid
594
+
595
+
596
+ def self_test_report_schema() -> None:
597
+ sample = {
598
+ "coverage_missing": [],
599
+ "coverage_present": ["US-0001"],
600
+ "coverage_total": 1,
601
+ "gaps": [],
602
+ "report_schema_version": 1,
603
+ "repo_root": ".",
604
+ "status": "PASS",
605
+ }
606
+ a = canonical_json(sample)
607
+ b = canonical_json(sample)
608
+ assert a == b