its-magic 0.1.2-43 → 0.1.2-49
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/README.md +119 -0
- package/package.json +1 -1
- package/scripts/check_intake_template_parity.py +90 -2
- package/scripts/guard_installer_publish.py +19 -1
- package/template/.cursor/commands/architecture.md +16 -6
- package/template/.cursor/commands/auto.md +42 -0
- package/template/.cursor/commands/execute.md +49 -0
- package/template/.cursor/commands/qa.md +37 -0
- package/template/.cursor/commands/release.md +11 -0
- package/template/.cursor/commands/verify-work.md +43 -1
- package/template/.cursor/rules/caveman.mdc +43 -0
- package/template/.cursor/scratchpad.local.example.md +39 -4
- package/template/.cursor/scratchpad.md +34 -4
- package/template/.github/workflows/ci.yml +4 -114
- package/template/README.md +113 -0
- package/template/docs/engineering/auto-orchestration-reference.md +62 -1
- package/template/docs/engineering/context/installer-owned-paths.manifest +12 -0
- package/template/docs/engineering/context/readme-section-affinity.json +30 -0
- package/template/docs/engineering/runbook.md +151 -5
- package/template/scripts/auto_outer_driver.py +521 -0
- package/template/scripts/check_downstream_ci_guard.py +67 -0
- package/template/scripts/check_intake_template_parity.py +90 -2
- package/template/scripts/downstream_ci_guard_lib.py +222 -0
- package/template/scripts/enforce-triad-hot-surface.py +135 -8
- package/template/scripts/readme_feature_coverage_lib.py +608 -0
- package/template/scripts/uat_probe_lib.py +868 -0
- package/template/scripts/validate_readme_feature_coverage.py +140 -0
|
@@ -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
|