aepkit 0.6.0__py3-none-any.whl

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.
aepkit/lint.py ADDED
@@ -0,0 +1,549 @@
1
+ """Cross-check the document set for the failure modes this protocol names by hand:
2
+ task IDs that exist in one document but not the other, gates with no stated negative
3
+ control, DONE reports with no pasted verify output, work started in a later phase before
4
+ earlier phases are APPROVED, unfilled opportunity/shaping/non-goal fields or open deferred
5
+ items in plan-of-record.md, near-duplicate paragraphs from append-only logging, and an
6
+ annex count that signals an under-verified plan rather than a healthy one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import sys
13
+ from collections import Counter, defaultdict
14
+ from pathlib import Path
15
+
16
+ REPORT_ID_RE = re.compile(r"\*\*Report:\*\*\s*`?([A-Za-z0-9_.\-]+)`?")
17
+ GUIDE_HEADER_RE = re.compile(r"^###\s+([A-Za-z0-9_.\-]+)\s+[\u2014\u2013\-]\s*(.*)$", re.MULTILINE)
18
+ LOG_HEADER_RE = re.compile(
19
+ r"^###\s+([A-Za-z0-9_.\-]+)\s+[\u2014\u2013\-]\s+([A-Za-z_]+)\s*$", re.MULTILINE
20
+ )
21
+ GATE_ID_RE = re.compile(r"-G\d+$|^G\d+$", re.IGNORECASE)
22
+ VERIFY_FIELD_RE = re.compile(r"^\*\*Verify output:\*\*", re.MULTILINE)
23
+ NEXT_FIELD_RE = re.compile(r"^\*\*[^*\n]+:\*\*", re.MULTILINE)
24
+ SECTION_END_RE = re.compile(r"^#{1,6}\s|^---\s*$", re.MULTILINE)
25
+ PLACEHOLDER_RE = re.compile(r"<[^>\n]*>")
26
+ # A mention that negates or defers the control ("negative control: n/a", "no negative
27
+ # control needed", "negative control: TBD", "the negative control step was not done")
28
+ # does not count as stating one. This is a wording heuristic, not proof the control ran.
29
+ _DEFERRAL = (
30
+ r"n/?a|n\.\s?a\.?|none(?:\s+yet)?|tbd|todo|later|pending|skip(?:ped)?|deferred|missing"
31
+ r"|not\s+(?:yet|needed|required|applicable|done|run|performed|written)"
32
+ r"|to\s+be\s+(?:written|done|added|decided|determined|defined|confirmed|run)"
33
+ )
34
+ NEGATED_CONTROL_RE = re.compile(
35
+ r"\b(?:no|without|skip(?:ped)?|omit(?:ted)?)\s+(?:the\s+)?negative\s+control"
36
+ rf"|negative\s+controls?[^A-Za-z0-9\n]{{0,10}}(?:{_DEFERRAL})\b"
37
+ rf"|negative\s+controls?\b[^.\n]{{0,40}}\b(?:was|is|were|are)\s+(?:{_DEFERRAL})\b",
38
+ re.IGNORECASE,
39
+ )
40
+ # Text that only defers ("n/a", "TBD", "none yet") or is only punctuation ("-", "...") is
41
+ # not a stated control or pasted output.
42
+ DEFERRAL_ONLY_RE = re.compile(rf"^[^A-Za-z0-9]*(?:(?:{_DEFERRAL})[^A-Za-z0-9]*)?$", re.IGNORECASE)
43
+ CONTROL_MENTION_RE = re.compile(r"negative\s+controls?", re.IGNORECASE)
44
+ # A stated control says what is broken and what fails: at least a short sentence, with no
45
+ # deferral in it ("will add later", "TBA") and not a bare cross-reference ("see above").
46
+ MIN_CONTROL_WORDS = 4
47
+ DEFERRAL_PHRASE_RE = re.compile(
48
+ rf"\b(?:{_DEFERRAL}|tba|tbc|tbd|will\s+(?:be\s+)?(?:add|writ|do|defin|document|creat|implement|provid|decid)\w*"
49
+ r"|see\s+(?:above|below)|to\s+follow|coming\s+soon|planned\s+(?:for|in)|in\s+a\s+later|once\s+\w+\s+(?:is|are|lands?)"
50
+ r"|next\s+(?:sprint|release|phase|iteration)"
51
+ r"|no\s+(?:negative\s+)?controls?|omit\w*|could\s*n[o']?t\s+\w+|can\s*n[o']?t\s+be\s+(?:broken|shown|tested)"
52
+ r"|nothing\s+to\s+(?:show|break|test)|not\s+possible|impossible|unnecessary|unneeded"
53
+ r"|waiv\w*|out\s+of\s+scope|hypothetical\w*|postpon\w*|would|might|anyway|eventually"
54
+ r"|could|should|may|maybe|perhaps|probably|presumably|supposedly|hopefully|in\s+theory"
55
+ r"|(?:remov|disabl|break|chang|delet|revert|bypass|mutat)\w*\s+nothing"
56
+ r"|do\s+not\s+know|don'?t\s+know)\b",
57
+ re.IGNORECASE,
58
+ )
59
+ WORD_RE = re.compile(r"[A-Za-z0-9_]+")
60
+ # A stated control names an action that removes the protection or feeds the input it must
61
+ # stop, and a failure it causes. Both are required, so a refusal or a waiver in new words
62
+ # ("waived by the lead", "out of scope") does not read as a control.
63
+ CONTROL_ACTION_RE = re.compile(
64
+ r"\b(?:remov|revert|delet|disabl|comment\w*\s+out|drop|bypass|flip|mutat|replac|strip|undo"
65
+ r"|roll\s*back|swap|corrupt|tamper|chang|patch|turn\s+off|unset|send|feed|submit|pass|call"
66
+ r"|request|post|inject|use|set|break|weaken|loosen|invert|negat|stub|mock"
67
+ r"|add|insert|introduc|stash)\w*",
68
+ re.IGNORECASE,
69
+ )
70
+ CONTROL_FAILURE_RE = re.compile(
71
+ r"\b(?:fail\w*|error\w*|reject\w*|red|non-?zero|exit(?:s|ed)?\s+(?:code\s+)?[1-9]\d*|rais\w*|throw\w*"
72
+ r"|crash\w*|den(?:y|ies|ied)|block\w*|refus\w*|[45]\d\d|assert\w*|catch\w*|caught|flag\w*|trip\w*)\b",
73
+ re.IGNORECASE,
74
+ )
75
+ # "nothing fails", "would not fail", "no error": the failure is negated.
76
+ NEGATED_FAILURE_RE = re.compile(
77
+ r"\b(?:not|never|no|nothing|without|n't)\s+(?:\w+\s+){0,2}(?:fail|error|reject|rais|throw|crash|block)\w*",
78
+ re.IGNORECASE,
79
+ )
80
+ # "did not remove", "never disabled": the action itself is negated, so it was not done.
81
+ NEGATED_ACTION_RE = re.compile(
82
+ r"\b(?:not|never|no|without|n't|did\s+not|didn'?t)\s+(?:\w+\s+){0,3}"
83
+ r"(?:remov|revert|delet|disabl|drop|bypass|flip|mutat|replac|strip|undo"
84
+ r"|swap|corrupt|tamper|chang|patch|unset|send|feed|submit|pass|call"
85
+ r"|request|post|inject|break|weaken|loosen|invert|negat|stub|mock"
86
+ r"|add|insert|introduc|stash)\w*",
87
+ re.IGNORECASE,
88
+ )
89
+ # Where a stated control ends: the next bold field, heading, or blank line.
90
+ CONTROL_END_RE = re.compile(r"\n\s*(?:[-*]\s+)?(?:\*\*|__)[^*_\n]+:|\n#|\n\s*\n")
91
+ # A task or gate ID contains a digit (P0-T1, T3, G2); headings like "### Notes — x" are not tasks.
92
+ TASK_ID_RE = re.compile(r"\d")
93
+ FENCE_LINE_RE = re.compile(r"^\s*(```|~~~)[\w-]*\s*$", re.MULTILINE)
94
+ # Pasted output is a fenced block with something in it; prose about the output is a claim.
95
+ FENCED_BLOCK_RE = re.compile(r"^\s*(```|~~~)[\w-]*\s*\n(.*?)^\s*\1\s*$", re.MULTILINE | re.DOTALL)
96
+ PHASE_RE = re.compile(r"^P(\d+)-", re.IGNORECASE)
97
+ DEFERRED_PHASE_RE = re.compile(r"\b(?:P|Phase\s*)(\d+)\b", re.IGNORECASE)
98
+ VERDICTS = {"APPROVED", "CONDITIONAL", "REJECTED"}
99
+ DETERMINISTIC_BYPASS_RE = re.compile(
100
+ r"\b(?:n/?a|not\s+applicable)\b[^\n]{0,60}\b"
101
+ r"(?:deterministic|bug\s*fix|fix|compliance|security|patch|migration)\b",
102
+ re.IGNORECASE,
103
+ )
104
+ PLAN_DEFERRAL_RE = re.compile(
105
+ r"^\s*(?:n/?a|n\.\s?a\.?|none(?:\s+yet)?|tbd|todo|tba|tbc|later|pending|skip(?:ped)?|deferred)\s*$"
106
+ r"|\b(?:tbd|todo|tba|tbc|to\s+be\s+(?:written|done|added|decided|determined|defined|confirmed))\b",
107
+ re.IGNORECASE,
108
+ )
109
+ PLAN_ALWAYS_REQUIRED: tuple[tuple[str, re.Pattern[str]], ...] = (
110
+ ("Target outcome", re.compile(r"\*\*Target outcome:\*\*", re.IGNORECASE)),
111
+ ("Problem / friction", re.compile(r"\*\*Problem\s*/\s*friction:\*\*", re.IGNORECASE)),
112
+ ("Cost of doing nothing", re.compile(r"\*\*Cost of doing nothing:\*\*", re.IGNORECASE)),
113
+ ("Explicit non-goals", re.compile(r"\*\*Explicit non-goals:\*\*", re.IGNORECASE)),
114
+ )
115
+ PLAN_SHAPING_OPTIONS: tuple[tuple[str, re.Pattern[str]], ...] = (
116
+ ("Option A", re.compile(r"\*\*Option A(?:\s*\([^*\n]*\))?:\*\*", re.IGNORECASE)),
117
+ ("Option B", re.compile(r"\*\*Option B(?:\s*\([^*\n]*\))?:\*\*", re.IGNORECASE)),
118
+ ("Option C", re.compile(r"\*\*Option C(?:\s*\([^*\n]*\))?:\*\*", re.IGNORECASE)),
119
+ )
120
+ PLAN_SELECTED_OPTION_RE = re.compile(
121
+ r"\*\*Selected option\s*(?:&|and)\s*rationale:\*\*", re.IGNORECASE
122
+ )
123
+ PLAN_RISKIEST_ASSUMPTION_RE = re.compile(
124
+ r"\*\*Riskiest assumption(?:\s*(?:&|and)\s*first-phase validation)?:\*\*", re.IGNORECASE
125
+ )
126
+
127
+ MIN_DUP_LEN = 120
128
+ DEFAULT_ANNEX_THRESHOLD = 6
129
+
130
+
131
+ def _read(path: Path) -> str:
132
+ return path.read_text(encoding="utf-8") if path.exists() else ""
133
+
134
+
135
+ def _guide_sections(text: str) -> list[tuple[str, str, str]]:
136
+ """Return (id, title, body) for every '### ID — title' section."""
137
+ matches = list(GUIDE_HEADER_RE.finditer(text))
138
+ sections = []
139
+ for i, m in enumerate(matches):
140
+ start = m.end()
141
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
142
+ sections.append((m.group(1), m.group(2).strip(), text[start:end]))
143
+ return sections
144
+
145
+
146
+ def _states_negative_control(body: str) -> bool:
147
+ """True when a mention of the negative control is followed by what it is: a sentence that
148
+ names the action that removes the protection (or feeds the input it must stop) and the
149
+ failure it causes, with no deferral or negation. This is a wording heuristic; the Auditor
150
+ still re-runs the control."""
151
+ if NEGATED_CONTROL_RE.search(body):
152
+ return False
153
+ for mention in CONTROL_MENTION_RE.finditer(body):
154
+ rest = body[mention.end() :]
155
+ end = CONTROL_END_RE.search(rest)
156
+ text = PLACEHOLDER_RE.sub("", rest[: end.start()] if end else rest).strip()
157
+ if (
158
+ len(WORD_RE.findall(text)) >= MIN_CONTROL_WORDS
159
+ and not DEFERRAL_PHRASE_RE.search(text)
160
+ and CONTROL_ACTION_RE.search(text)
161
+ and CONTROL_FAILURE_RE.search(text)
162
+ and not NEGATED_FAILURE_RE.search(text)
163
+ and not NEGATED_ACTION_RE.search(text)
164
+ ):
165
+ return True
166
+ return False
167
+
168
+
169
+ def _is_gate(task_id: str, title: str) -> bool:
170
+ return bool(GATE_ID_RE.search(task_id)) or "gate" in title.lower()
171
+
172
+
173
+ def _done_without_evidence(log_text: str) -> list[str]:
174
+ """IDs reported DONE whose **Verify output:** field is missing, empty, or only defers.
175
+
176
+ The protocol records such a report as FAILED: evidence is pasted command output,
177
+ not a claim that the command passed. Output counts only inside a fenced code block,
178
+ so prose such as "all tests pass" or "looks correct" is not evidence; template
179
+ placeholders, empty fences, and a lone deferral such as "n/a" or "TBD" are not either.
180
+ """
181
+ missing = []
182
+ for m in LOG_HEADER_RE.finditer(log_text):
183
+ if m.group(2).upper() != "DONE":
184
+ continue
185
+ rest = log_text[m.end() :]
186
+ end = SECTION_END_RE.search(rest)
187
+ body = rest[: end.start()] if end else rest
188
+ field = VERIFY_FIELD_RE.search(body)
189
+ if not field:
190
+ missing.append(m.group(1))
191
+ continue
192
+ after = body[field.end() :]
193
+ nxt = NEXT_FIELD_RE.search(after)
194
+ evidence = after[: nxt.start()] if nxt else after
195
+ blocks = [
196
+ PLACEHOLDER_RE.sub("", b.group(2)).strip() for b in FENCED_BLOCK_RE.finditer(evidence)
197
+ ]
198
+ if not any(b and not DEFERRAL_ONLY_RE.match(b) for b in blocks):
199
+ missing.append(m.group(1))
200
+ return missing
201
+
202
+
203
+ def _phase_order_violations(guide_text: str, log_text: str) -> list[str]:
204
+ """IDs reported DONE in a phase that started before every earlier phase was APPROVED."""
205
+ from .status import _board_verdicts
206
+
207
+ ids = [task_id for task_id, _, _ in _guide_sections(guide_text) if TASK_ID_RE.search(task_id)]
208
+ verdicts = {k: v for k, v in _board_verdicts(log_text).items() if v in VERDICTS}
209
+ reports: dict[str, str] = {}
210
+ for m in LOG_HEADER_RE.finditer(log_text):
211
+ task_id, value = m.group(1), m.group(2).upper()
212
+ ids.append(task_id)
213
+ if value in VERDICTS:
214
+ verdicts.setdefault(task_id, value)
215
+ else:
216
+ reports[task_id] = value
217
+ phases: defaultdict[int, set[str]] = defaultdict(set)
218
+ for task_id in ids:
219
+ found = PHASE_RE.match(task_id)
220
+ if found:
221
+ phases[int(found.group(1))].add(task_id)
222
+ violations = []
223
+ for phase, members in sorted(phases.items()):
224
+ earlier = {i for p, m in phases.items() if p < phase for i in m}
225
+ if any(verdicts.get(i) != "APPROVED" for i in earlier):
226
+ violations += sorted(i for i in members if reports.get(i) == "DONE" or i in verdicts)
227
+ return violations
228
+
229
+
230
+ HEADER_LINE_RE = re.compile(r"^###\s+.*$", re.MULTILINE)
231
+
232
+
233
+ def _duplicate_paragraphs(text: str) -> list[tuple[str, int]]:
234
+ paragraphs = re.split(r"\n\s*\n", text)
235
+ stripped = [HEADER_LINE_RE.sub("", p) for p in paragraphs]
236
+ normalized = [re.sub(r"\s+", " ", p).strip() for p in stripped]
237
+ normalized = [p for p in normalized if len(p) >= MIN_DUP_LEN]
238
+ counts = Counter(normalized)
239
+ return [(p, n) for p, n in counts.items() if n > 1]
240
+
241
+
242
+ def _extract_plan_field(plan_text: str, pattern: re.Pattern[str]) -> str | None:
243
+ match = pattern.search(plan_text)
244
+ if not match:
245
+ return None
246
+ rest = plan_text[match.end() :]
247
+ end = CONTROL_END_RE.search(rest)
248
+ raw = rest[: end.start()] if end else rest
249
+ return PLACEHOLDER_RE.sub("", raw).strip()
250
+
251
+
252
+ def _is_filled_plan_value(value: str | None) -> bool:
253
+ if not value:
254
+ return False
255
+ if PLAN_DEFERRAL_RE.search(value) or DEFERRAL_ONLY_RE.match(value):
256
+ return False
257
+ return len(WORD_RE.findall(value)) >= 2
258
+
259
+
260
+ def _plan_gaps(plan_text: str) -> list[str]:
261
+ """Return missing or unfilled opportunity/shaping/non-goal fields in plan-of-record.md."""
262
+ gaps: list[str] = []
263
+ for label, pattern in PLAN_ALWAYS_REQUIRED:
264
+ val = _extract_plan_field(plan_text, pattern)
265
+ if not _is_filled_plan_value(val):
266
+ gaps.append(label)
267
+
268
+ selected = _extract_plan_field(plan_text, PLAN_SELECTED_OPTION_RE)
269
+ is_bypass = bool(selected and DETERMINISTIC_BYPASS_RE.search(selected))
270
+ if not is_bypass and not _is_filled_plan_value(selected):
271
+ gaps.append("Selected option & rationale (or state 'N/A (Deterministic Fix/Compliance)')")
272
+
273
+ riskiest = _extract_plan_field(plan_text, PLAN_RISKIEST_ASSUMPTION_RE)
274
+ if not (
275
+ (riskiest and DETERMINISTIC_BYPASS_RE.search(riskiest)) or _is_filled_plan_value(riskiest)
276
+ ):
277
+ gaps.append(
278
+ "Riskiest assumption & first-phase validation "
279
+ "(or state 'N/A (Deterministic Fix/Compliance)')"
280
+ )
281
+
282
+ if not is_bypass:
283
+ for opt_label, opt_pattern in PLAN_SHAPING_OPTIONS:
284
+ opt_val = _extract_plan_field(plan_text, opt_pattern)
285
+ if not _is_filled_plan_value(opt_val):
286
+ gaps.append(opt_label)
287
+
288
+ return gaps
289
+
290
+
291
+ def _open_deferred_items(plan_text: str, known_ids: set[str], log_text: str) -> list[str]:
292
+ """Flag deferred-ledger rows whose closing task ID does not exist or remains empty when
293
+ its target phase is being closed."""
294
+ from .status import _board_verdicts, _cells
295
+
296
+ verdicts = {k: v for k, v in _board_verdicts(log_text).items() if v in VERDICTS}
297
+ reports: dict[str, str] = {}
298
+ for m in LOG_HEADER_RE.finditer(log_text):
299
+ tid, val = m.group(1), m.group(2).upper()
300
+ if val in VERDICTS:
301
+ verdicts.setdefault(tid, val)
302
+ else:
303
+ reports[tid] = val
304
+
305
+ closing_phases: set[int] = set()
306
+ phase_members: defaultdict[int, set[str]] = defaultdict(set)
307
+ for tid in known_ids | set(verdicts) | set(reports):
308
+ pm = PHASE_RE.match(tid)
309
+ if not pm:
310
+ continue
311
+ pnum = int(pm.group(1))
312
+ phase_members[pnum].add(tid)
313
+ if GATE_ID_RE.search(tid) and (
314
+ reports.get(tid) == "DONE" or verdicts.get(tid) == "APPROVED"
315
+ ):
316
+ closing_phases.add(pnum)
317
+ for pnum, members in phase_members.items():
318
+ if members and all(
319
+ reports.get(i) == "DONE" or verdicts.get(i) == "APPROVED" for i in members
320
+ ):
321
+ closing_phases.add(pnum)
322
+
323
+ issues: list[str] = []
324
+ lines = plan_text.splitlines()
325
+ for i, line in enumerate(lines):
326
+ if not line.lstrip().startswith("|"):
327
+ continue
328
+ header = [c.lower() for c in _cells(line)]
329
+ if "id" not in header or "deferred to" not in header or "closed by" not in header:
330
+ continue
331
+ id_col = header.index("id")
332
+ def_col = header.index("deferred to")
333
+ closed_col = header.index("closed by")
334
+ for row in lines[i + 1 :]:
335
+ if not row.lstrip().startswith("|"):
336
+ break
337
+ cells = _cells(row)
338
+ if len(cells) <= max(id_col, def_col, closed_col):
339
+ continue
340
+ item_id = PLACEHOLDER_RE.sub("", cells[id_col]).strip()
341
+ if not item_id or set(item_id) <= set("-: "):
342
+ continue
343
+ deferred_to = PLACEHOLDER_RE.sub("", cells[def_col]).strip()
344
+ closed_by = PLACEHOLDER_RE.sub("", cells[closed_col]).strip()
345
+ closing_ids = (
346
+ []
347
+ if PLAN_DEFERRAL_RE.search(closed_by)
348
+ else [
349
+ tok
350
+ for tok in re.findall(r"[A-Za-z0-9_.\-]+", closed_by)
351
+ if TASK_ID_RE.search(tok)
352
+ ]
353
+ )
354
+ unknown = [cid for cid in closing_ids if cid not in known_ids]
355
+ if unknown:
356
+ issues.append(
357
+ f"{item_id} (Closed by {', '.join(unknown)}, which is not in execution-guide.md)"
358
+ )
359
+ continue
360
+ if not closing_ids:
361
+ pm = DEFERRED_PHASE_RE.search(deferred_to)
362
+ if pm and int(pm.group(1)) in closing_phases:
363
+ issues.append(
364
+ f"{item_id} (deferred to Phase {pm.group(1)}, still open in Closed by)"
365
+ )
366
+ break
367
+ return issues
368
+
369
+
370
+ def run(target_dir: str, annex_threshold: int = DEFAULT_ANNEX_THRESHOLD) -> int:
371
+ target = Path(target_dir)
372
+ # A missing document is an error, not a clean result: linting nothing proves nothing.
373
+ missing_docs = [
374
+ name
375
+ for name in ("execution-guide.md", "compliance-log.md")
376
+ if not (target / name).is_file()
377
+ ]
378
+ if missing_docs:
379
+ print(
380
+ f"error: {target} is missing {', '.join(missing_docs)} (run `aepkit init {target}`).",
381
+ file=sys.stderr,
382
+ )
383
+ return 2
384
+ guide_text = _read(target / "execution-guide.md")
385
+ log_text = _read(target / "compliance-log.md")
386
+
387
+ problems = 0
388
+
389
+ # 1. Every **Report:** ID in the guide should have an entry in the log.
390
+ guide_ids = REPORT_ID_RE.findall(guide_text)
391
+ log_ids = {m.group(1) for m in LOG_HEADER_RE.finditer(log_text)}
392
+
393
+ missing = [i for i in guide_ids if i not in log_ids]
394
+ if missing:
395
+ problems += len(missing)
396
+ print(
397
+ f"[missing report] {len(missing)} task(s) in execution-guide.md have no entry in compliance-log.md:"
398
+ )
399
+ for i in missing:
400
+ print(f" - {i}")
401
+
402
+ # 1a. The guide must define tasks, and every task or gate must name its Report ID;
403
+ # otherwise the missing-report check above has nothing to compare.
404
+ task_sections = [
405
+ (task_id, body)
406
+ for task_id, _, body in _guide_sections(guide_text)
407
+ if TASK_ID_RE.search(task_id)
408
+ ]
409
+ if not task_sections:
410
+ problems += 1
411
+ print("[empty guide] execution-guide.md defines no tasks or gates (### <ID> — <title>).")
412
+ no_report_line = [task_id for task_id, body in task_sections if not REPORT_ID_RE.search(body)]
413
+ if no_report_line:
414
+ problems += len(no_report_line)
415
+ print(
416
+ f"[missing report line] {len(no_report_line)} task(s) in execution-guide.md have no **Report:** line:"
417
+ )
418
+ for i in no_report_line:
419
+ print(f" - {i}")
420
+
421
+ # 1b. Every log entry should belong to a task or gate in the guide.
422
+ known_ids = set(guide_ids) | {task_id for task_id, _, _ in _guide_sections(guide_text)}
423
+ orphans = sorted(i for i in log_ids if i not in known_ids)
424
+ if orphans:
425
+ problems += len(orphans)
426
+ print(
427
+ f"[orphan report] {len(orphans)} entry(ies) in compliance-log.md have no task or gate in execution-guide.md:"
428
+ )
429
+ for i in orphans:
430
+ print(f" - {i}")
431
+
432
+ # 2. Gates with no stated negative control. A gate still exactly as scaffolded (its title
433
+ # is a template placeholder) and not yet reported DONE is an unwritten gate, not a gap.
434
+ log_status = {m.group(1): m.group(2).upper() for m in LOG_HEADER_RE.finditer(log_text)}
435
+ gate_gaps = []
436
+ for task_id, title, body in _guide_sections(guide_text):
437
+ unwritten = PLACEHOLDER_RE.fullmatch(title.split(":", 1)[-1].strip()) is not None
438
+ if unwritten and log_status.get(task_id) != "DONE":
439
+ continue
440
+ if _is_gate(task_id, title) and not _states_negative_control(body):
441
+ gate_gaps.append(task_id)
442
+ if gate_gaps:
443
+ problems += len(gate_gaps)
444
+ print(
445
+ f"[gate w/o negative control] {len(gate_gaps)} gate(s) don't mention a negative control:"
446
+ )
447
+ for i in gate_gaps:
448
+ print(f" - {i}")
449
+
450
+ # 3. DONE reports with no pasted verify output.
451
+ unevidenced = _done_without_evidence(log_text)
452
+ if unevidenced:
453
+ problems += len(unevidenced)
454
+ print(
455
+ f"[done without evidence] {len(unevidenced)} report(s) marked DONE in "
456
+ "compliance-log.md have no pasted Verify output (the protocol records these as FAILED):"
457
+ )
458
+ for i in unevidenced:
459
+ print(f" - {i}")
460
+ print(
461
+ " Paste the literal command output under **Verify output:** inside a ``` code "
462
+ "block; a sentence about the result is not output."
463
+ )
464
+
465
+ # 3a. Work reported in a phase whose predecessors are not all APPROVED.
466
+ out_of_order = _phase_order_violations(guide_text, log_text)
467
+ if out_of_order:
468
+ problems += len(out_of_order)
469
+ print(
470
+ f"[phase order] {len(out_of_order)} report(s) in a phase started before every "
471
+ "earlier phase was APPROVED:"
472
+ )
473
+ for i in out_of_order:
474
+ print(f" - {i}")
475
+
476
+ # 3b. Plan of record: opportunity/shaping/non-goals (once work starts) and deferred items.
477
+ plan_path = target / "plan-of-record.md"
478
+ if plan_path.is_file():
479
+ from .status import _board_verdicts
480
+
481
+ plan_text = _read(plan_path)
482
+ board = _board_verdicts(log_text)
483
+ work_started = any(
484
+ v == "DONE" or v in VERDICTS for v in (*log_status.values(), *board.values())
485
+ )
486
+ if work_started:
487
+ plan_gaps = _plan_gaps(plan_text)
488
+ if plan_gaps:
489
+ problems += len(plan_gaps)
490
+ print(
491
+ f"[unfilled plan] {len(plan_gaps)} required field(s) in plan-of-record.md "
492
+ "are missing, empty, or still placeholders while work is reported DONE/audited:"
493
+ )
494
+ for g in plan_gaps:
495
+ print(f" - {g}")
496
+ deferred_issues = _open_deferred_items(plan_text, known_ids, log_text)
497
+ if deferred_issues:
498
+ problems += len(deferred_issues)
499
+ print(
500
+ f"[open deferred item] {len(deferred_issues)} row(s) in the deferred items "
501
+ "ledger of plan-of-record.md are open or reference an unknown task:"
502
+ )
503
+ for d in deferred_issues:
504
+ print(f" - {d}")
505
+
506
+ # 4. Near-duplicate paragraphs in the log (append-only rot).
507
+ dups = _duplicate_paragraphs(log_text)
508
+ if dups:
509
+ problems += len(dups)
510
+ print(
511
+ f"[duplicate content] {len(dups)} paragraph(s) repeated verbatim in compliance-log.md:"
512
+ )
513
+ for p, n in dups:
514
+ preview = p[:100] + ("…" if len(p) > 100 else "")
515
+ print(f" - x{n}: {preview}")
516
+
517
+ # 5. Annex count as a plan-health signal.
518
+ annex_dir = target / "annexes"
519
+ if annex_dir.exists():
520
+ annex_files = [f for f in annex_dir.iterdir() if f.is_file() and f.name != ".gitkeep"]
521
+ phase_pattern = re.compile(r"^(?:P(\d+)|phase[-_]?(\d+))", re.IGNORECASE)
522
+ phase_counts: defaultdict[str, int] = defaultdict(int)
523
+ for f in annex_files:
524
+ m = phase_pattern.search(f.stem)
525
+ if m:
526
+ phase_num = m.group(1) or m.group(2)
527
+ phase_counts[f"Phase {phase_num}"] += 1
528
+ else:
529
+ phase_counts["General"] += 1
530
+
531
+ phase_exceeded = {p: c for p, c in phase_counts.items() if c > annex_threshold}
532
+ if len(annex_files) > annex_threshold or phase_exceeded:
533
+ problems += 1
534
+ print(
535
+ f"[annex count] {len(annex_files)} annexes exceeds the threshold of "
536
+ f"{annex_threshold}. This usually means a phase's plan was under-verified "
537
+ f"before work started, not that it hit unusual surprises. Worth naming as a "
538
+ f"planning finding, not only fixing task by task."
539
+ )
540
+ if len(phase_counts) > 1 or (len(phase_counts) == 1 and "General" not in phase_counts):
541
+ for p_name, count in sorted(phase_counts.items()):
542
+ print(f" - {p_name}: {count} annex(es)")
543
+
544
+ if problems == 0:
545
+ print("clean — no cross-document gaps found.")
546
+ return 0
547
+
548
+ print(f"\n{problems} issue(s) found.")
549
+ return 1