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/__init__.py +1 -0
- aepkit/__main__.py +7 -0
- aepkit/cli.py +151 -0
- aepkit/install_skill.py +126 -0
- aepkit/lint.py +549 -0
- aepkit/negcontrol.py +234 -0
- aepkit/py.typed +0 -0
- aepkit/scaffold.py +40 -0
- aepkit/skill/SKILL.md +448 -0
- aepkit/skill/references/autonomous-mode.md +144 -0
- aepkit/skill/references/failure-modes-and-example.md +103 -0
- aepkit/skill/references/handoffs.md +133 -0
- aepkit/skill/references/tasks-and-gates.md +84 -0
- aepkit/status.py +122 -0
- aepkit/templates/compliance-log.md +37 -0
- aepkit/templates/execution-guide.md +75 -0
- aepkit/templates/plan-of-record.md +79 -0
- aepkit-0.6.0.dist-info/METADATA +223 -0
- aepkit-0.6.0.dist-info/RECORD +23 -0
- aepkit-0.6.0.dist-info/WHEEL +5 -0
- aepkit-0.6.0.dist-info/entry_points.txt +2 -0
- aepkit-0.6.0.dist-info/licenses/LICENSE +21 -0
- aepkit-0.6.0.dist-info/top_level.txt +1 -0
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
|