@xenonbyte/req-2-plan 0.2.3 → 0.3.0
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/package.json +3 -3
- package/tools/workflow_cli/agent_shortcuts.py +57 -6
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +2 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +2 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +1 -1
- package/tools/workflow_cli/cli.py +75 -2
- package/tools/workflow_cli/context_pack.py +98 -0
- package/tools/workflow_cli/gates.py +416 -56
- package/tools/workflow_cli/link_expander.py +28 -0
- package/tools/workflow_cli/markdown.py +160 -0
- package/tools/workflow_cli/stage_schema.py +57 -0
- package/tools/workflow_cli/stage_templates.py +55 -0
- package/tools/workflow_cli/trace.py +224 -0
- package/tools/workflow_cli/version.py +1 -1
- package/docs/req-to-plan-design.md +0 -277
|
@@ -7,6 +7,7 @@ The Agent handles semantic quality; this module handles structural validation.
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
9
|
import re
|
|
10
|
+
from collections import Counter
|
|
10
11
|
from dataclasses import dataclass, field
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
@@ -19,7 +20,14 @@ from tools.workflow_cli.models import (
|
|
|
19
20
|
TierEstimate,
|
|
20
21
|
TierModifier,
|
|
21
22
|
)
|
|
23
|
+
from tools.workflow_cli.markdown import (
|
|
24
|
+
heading_bounded_bodies,
|
|
25
|
+
strip_readonly_sections,
|
|
26
|
+
unfenced_markdown_lines,
|
|
27
|
+
unfenced_markdown_text,
|
|
28
|
+
)
|
|
22
29
|
from tools.workflow_cli.output import EXIT_GATE_FAIL
|
|
30
|
+
from tools.workflow_cli.stage_schema import PLAN_TASK_FIELD_RE, PLAN_TASK_FIELDS
|
|
23
31
|
|
|
24
32
|
# ---------------------------------------------------------------------------
|
|
25
33
|
# GateResult
|
|
@@ -82,11 +90,42 @@ def check_entry_gate(
|
|
|
82
90
|
# Upstream ID reference pattern
|
|
83
91
|
# ---------------------------------------------------------------------------
|
|
84
92
|
|
|
93
|
+
_FILL_IN_PLACEHOLDER_RE = re.compile(r"<!--\s*fill in\s*-->", re.IGNORECASE)
|
|
94
|
+
|
|
95
|
+
_PLACEHOLDER_PATTERNS = [
|
|
96
|
+
_FILL_IN_PLACEHOLDER_RE, # untouched template body
|
|
97
|
+
re.compile(
|
|
98
|
+
r"(?im)^\s*(?:[-*]\s*)?(?:[A-Za-z][A-Za-z0-9 /_-]*:\s*)?TBD\s*$"
|
|
99
|
+
), # TBD as a line, field value, or list item
|
|
100
|
+
re.compile(r"(?im)^\s*(?:[-*]\s*)?maybe\s*$"),
|
|
101
|
+
re.compile(r"\bTODO later\b", re.IGNORECASE),
|
|
102
|
+
re.compile(
|
|
103
|
+
r"(?im)^\s*(?:[-*]\s*)?(?:[A-Za-z][A-Za-z0-9 /_-]*:\s*)?FIXME\s*$"
|
|
104
|
+
), # FIXME as a placeholder line/field, not prose
|
|
105
|
+
]
|
|
106
|
+
|
|
85
107
|
# IDs that represent upstream references: REQ-*, RISK-*, DES-*, SPEC-*
|
|
86
108
|
_UPSTREAM_ID_PATTERN = re.compile(
|
|
87
109
|
r"\b(REQ-[A-Z]+-\d+|RISK-[A-Z]+-\d+|DES-[A-Z]+-\d+|SPEC-[A-Z]+-\d+)\b"
|
|
88
110
|
)
|
|
89
111
|
|
|
112
|
+
# Trace-ID validation: well-formed vs candidate patterns
|
|
113
|
+
_VALID_TRACE_ID_RE = re.compile(
|
|
114
|
+
r"^(?:REQ|RISK|DES|SPEC)-[A-Z]+-\d+$|^SCOPE-(?:IN|OUT)-\d+$|^PLAN-TASK-\d+$"
|
|
115
|
+
)
|
|
116
|
+
_TRACE_ID_CANDIDATE_RE = re.compile(
|
|
117
|
+
r"\b(?:REQ|RISK|DES|SPEC)-[A-Za-z][A-Za-z0-9_-]*\d[A-Za-z0-9_-]*"
|
|
118
|
+
r"|\bSCOPE-(?:IN|OUT)-[A-Za-z0-9_-]*\d[A-Za-z0-9_-]*"
|
|
119
|
+
r"|\bPLAN-TASK-[A-Za-z0-9_-]*\d[A-Za-z0-9_-]*"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Native-ID heading patterns: stages that MUST define at least one native ID in a heading
|
|
123
|
+
_STAGE_NATIVE_HEADING_PATTERNS = {
|
|
124
|
+
Stage.RISK_DISCOVERY: re.compile(r"(?m)^#+\s+.*\bRISK-[A-Z]+-\d+\b"),
|
|
125
|
+
Stage.DESIGN: re.compile(r"(?m)^#+\s+.*\bDES-[A-Z]+-\d+\b"),
|
|
126
|
+
Stage.SPEC: re.compile(r"(?m)^#+\s+.*\bSPEC-[A-Z]+-\d+\b"),
|
|
127
|
+
}
|
|
128
|
+
|
|
90
129
|
# Closure status tags
|
|
91
130
|
_CLOSURE_TAGS = frozenset(["[ADDRESSED]", "[DEFERRED]", "[N/A]", "[OUT-OF-SCOPE]", "[CLOSED]"])
|
|
92
131
|
|
|
@@ -97,7 +136,7 @@ _DEFINED_ID_PATTERN = re.compile(r"\b([A-Z]+-[A-Z]+-\d+)\b")
|
|
|
97
136
|
def _find_defined_ids(content: str) -> set[str]:
|
|
98
137
|
"""Return IDs that are defined in headings (i.e. the current artifact is defining them)."""
|
|
99
138
|
heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
|
|
100
|
-
return set(heading_pattern.findall(content))
|
|
139
|
+
return set(heading_pattern.findall(unfenced_markdown_text(content)))
|
|
101
140
|
|
|
102
141
|
|
|
103
142
|
def _find_ids_without_closure(content: str) -> list[str]:
|
|
@@ -106,7 +145,8 @@ def _find_ids_without_closure(content: str) -> list[str]:
|
|
|
106
145
|
IDs defined in headings of the current artifact are excluded — they are
|
|
107
146
|
being *defined* here, not referencing upstream artifacts that need closure.
|
|
108
147
|
"""
|
|
109
|
-
|
|
148
|
+
search_content = unfenced_markdown_text(content)
|
|
149
|
+
all_refs = set(_UPSTREAM_ID_PATTERN.findall(search_content))
|
|
110
150
|
defined_here = _find_defined_ids(content)
|
|
111
151
|
# Only check IDs that are referenced but NOT defined in this artifact
|
|
112
152
|
refs_to_check = all_refs - defined_here
|
|
@@ -121,7 +161,7 @@ def _find_ids_without_closure(content: str) -> list[str]:
|
|
|
121
161
|
re.escape(ref_id) + r"[^\n]*" + re.escape(tag),
|
|
122
162
|
re.IGNORECASE,
|
|
123
163
|
)
|
|
124
|
-
if pattern.search(
|
|
164
|
+
if pattern.search(search_content):
|
|
125
165
|
has_closure = True
|
|
126
166
|
break
|
|
127
167
|
if not has_closure:
|
|
@@ -132,7 +172,7 @@ def _find_ids_without_closure(content: str) -> list[str]:
|
|
|
132
172
|
def _find_duplicate_ids(content: str) -> list[str]:
|
|
133
173
|
"""Return IDs that appear more than once in heading (definition) context."""
|
|
134
174
|
heading_pattern = re.compile(r"^#{1,6}\s+.*\b([A-Z]+-[A-Z]+-\d+)\b", re.MULTILINE)
|
|
135
|
-
heading_ids = heading_pattern.findall(content)
|
|
175
|
+
heading_ids = heading_pattern.findall(unfenced_markdown_text(content))
|
|
136
176
|
|
|
137
177
|
from collections import Counter
|
|
138
178
|
counts = Counter(heading_ids)
|
|
@@ -145,41 +185,9 @@ def _find_duplicate_ids(content: str) -> list[str]:
|
|
|
145
185
|
|
|
146
186
|
_EXTERNAL_DOCS_RE = re.compile(r"^## External Documentation Checked\s*$", re.MULTILINE)
|
|
147
187
|
_H2_RE = re.compile(r"^##\s+", re.MULTILINE)
|
|
148
|
-
_MARKDOWN_CODE_FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})", re.MULTILINE)
|
|
149
|
-
_MARKDOWN_FENCE_MARKER_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})")
|
|
150
188
|
_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
151
189
|
|
|
152
190
|
|
|
153
|
-
def _unfenced_markdown_lines(content: str):
|
|
154
|
-
"""Yield (line, start, end) for lines outside Markdown fenced code blocks."""
|
|
155
|
-
fence_char = ""
|
|
156
|
-
fence_len = 0
|
|
157
|
-
offset = 0
|
|
158
|
-
for line in content.splitlines(keepends=True):
|
|
159
|
-
marker = _MARKDOWN_FENCE_MARKER_RE.match(line)
|
|
160
|
-
if fence_char:
|
|
161
|
-
if (
|
|
162
|
-
marker
|
|
163
|
-
and marker.group(1)[0] == fence_char
|
|
164
|
-
and len(marker.group(1)) >= fence_len
|
|
165
|
-
and not line[marker.end():].strip()
|
|
166
|
-
):
|
|
167
|
-
fence_char = ""
|
|
168
|
-
fence_len = 0
|
|
169
|
-
offset += len(line)
|
|
170
|
-
continue
|
|
171
|
-
|
|
172
|
-
if marker:
|
|
173
|
-
fence_char = marker.group(1)[0]
|
|
174
|
-
fence_len = len(marker.group(1))
|
|
175
|
-
offset += len(line)
|
|
176
|
-
continue
|
|
177
|
-
|
|
178
|
-
start = offset
|
|
179
|
-
offset += len(line)
|
|
180
|
-
yield line, start, offset
|
|
181
|
-
|
|
182
|
-
|
|
183
191
|
def _plain_table_cell(cell: str) -> str:
|
|
184
192
|
return cell.strip().strip("_*`").strip()
|
|
185
193
|
|
|
@@ -218,14 +226,14 @@ def _is_external_docs_inventory_row(line: str) -> bool:
|
|
|
218
226
|
|
|
219
227
|
def _has_external_docs_inventory(content: str) -> bool:
|
|
220
228
|
section_start = None
|
|
221
|
-
for line, _, end in
|
|
229
|
+
for line, _, end in unfenced_markdown_lines(content):
|
|
222
230
|
if _EXTERNAL_DOCS_RE.match(line):
|
|
223
231
|
section_start = end
|
|
224
232
|
break
|
|
225
233
|
if section_start is None:
|
|
226
234
|
return False
|
|
227
235
|
|
|
228
|
-
for line, start, _ in
|
|
236
|
+
for line, start, _ in unfenced_markdown_lines(content):
|
|
229
237
|
if start < section_start:
|
|
230
238
|
continue
|
|
231
239
|
if _H2_RE.match(line):
|
|
@@ -242,15 +250,14 @@ def _has_external_docs_inventory(content: str) -> bool:
|
|
|
242
250
|
|
|
243
251
|
_PLAN_TASK_RE = re.compile(r"^### PLAN-TASK-\d+", re.MULTILINE)
|
|
244
252
|
_CODE_FENCE_LINE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(.*)$")
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
)
|
|
253
|
+
_INLINE_CODE_VALUE_RE = re.compile(r"^(`+)(.*?)\1$", re.DOTALL)
|
|
254
|
+
_MARKDOWN_LINK_VALUE_RE = re.compile(r"^\[([^\]]+)\]\([^)]+\)$")
|
|
248
255
|
|
|
249
256
|
|
|
250
257
|
def _plan_task_starts(content: str) -> list[int]:
|
|
251
258
|
return [
|
|
252
259
|
start
|
|
253
|
-
for line, start, _ in
|
|
260
|
+
for line, start, _ in unfenced_markdown_lines(content)
|
|
254
261
|
if _PLAN_TASK_RE.match(line)
|
|
255
262
|
]
|
|
256
263
|
|
|
@@ -268,7 +275,7 @@ def _plan_task_field_body(task_body: str, field: str) -> str:
|
|
|
268
275
|
|
|
269
276
|
def _find_plan_task_field(task_body: str, field: str):
|
|
270
277
|
field_re = re.compile(rf"^{re.escape(field)}:[ \t]*(.*)$")
|
|
271
|
-
for line, start, _ in
|
|
278
|
+
for line, start, _ in unfenced_markdown_lines(task_body):
|
|
272
279
|
match = field_re.match(line)
|
|
273
280
|
if match:
|
|
274
281
|
return match, start
|
|
@@ -276,8 +283,8 @@ def _find_plan_task_field(task_body: str, field: str):
|
|
|
276
283
|
|
|
277
284
|
|
|
278
285
|
def _find_next_plan_task_field_start(task_body: str, after: int) -> int | None:
|
|
279
|
-
for line, start, _ in
|
|
280
|
-
if start >= after and
|
|
286
|
+
for line, start, _ in unfenced_markdown_lines(task_body):
|
|
287
|
+
if start >= after and PLAN_TASK_FIELD_RE.match(line):
|
|
281
288
|
return start
|
|
282
289
|
return None
|
|
283
290
|
|
|
@@ -290,6 +297,161 @@ def _plan_task_field_value(task_body: str, field: str) -> str:
|
|
|
290
297
|
return match.group(1).strip()
|
|
291
298
|
|
|
292
299
|
|
|
300
|
+
def _iter_plan_task_bodies(content: str):
|
|
301
|
+
return heading_bounded_bodies(content, _PLAN_TASK_RE.match)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _plan_task_label(body: str) -> str:
|
|
305
|
+
m = re.match(r"###\s+PLAN-TASK-(\d+)", body)
|
|
306
|
+
return f"PLAN-TASK-{m.group(1)}" if m else "PLAN-TASK-?"
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _strip_markdown_path_wrappers(value: str) -> str:
|
|
310
|
+
text = value.strip()
|
|
311
|
+
changed = True
|
|
312
|
+
while changed:
|
|
313
|
+
changed = False
|
|
314
|
+
inline_code = _INLINE_CODE_VALUE_RE.fullmatch(text)
|
|
315
|
+
if inline_code:
|
|
316
|
+
text = inline_code.group(2).strip()
|
|
317
|
+
changed = True
|
|
318
|
+
continue
|
|
319
|
+
markdown_link = _MARKDOWN_LINK_VALUE_RE.fullmatch(text)
|
|
320
|
+
if markdown_link:
|
|
321
|
+
text = markdown_link.group(1).strip()
|
|
322
|
+
changed = True
|
|
323
|
+
continue
|
|
324
|
+
if len(text) >= 2 and text.startswith("<") and text.endswith(">"):
|
|
325
|
+
text = text[1:-1].strip()
|
|
326
|
+
changed = True
|
|
327
|
+
continue
|
|
328
|
+
for marker in ("**", "__"):
|
|
329
|
+
if (
|
|
330
|
+
len(text) > 2 * len(marker)
|
|
331
|
+
and text.startswith(marker)
|
|
332
|
+
and text.endswith(marker)
|
|
333
|
+
):
|
|
334
|
+
text = text[len(marker):-len(marker)].strip()
|
|
335
|
+
changed = True
|
|
336
|
+
break
|
|
337
|
+
return text
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _plan_task_path_part(raw_path: str) -> str:
|
|
341
|
+
value = _strip_markdown_path_wrappers(raw_path)
|
|
342
|
+
return _strip_markdown_path_wrappers(value.split("::")[0])
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _plan_task_file_paths(files_field: str) -> list[str]:
|
|
346
|
+
paths: list[str] = []
|
|
347
|
+
lines = [line for line, _, _ in unfenced_markdown_lines(files_field)]
|
|
348
|
+
if not lines:
|
|
349
|
+
return paths
|
|
350
|
+
|
|
351
|
+
first = lines[0].strip()
|
|
352
|
+
if first:
|
|
353
|
+
raw_path = first[2:].strip() if first.startswith(("- ", "* ")) else first
|
|
354
|
+
path_part = _plan_task_path_part(raw_path)
|
|
355
|
+
if path_part:
|
|
356
|
+
paths.append(path_part)
|
|
357
|
+
|
|
358
|
+
for line in lines[1:]:
|
|
359
|
+
stripped = line.strip()
|
|
360
|
+
if not stripped.startswith(("- ", "* ")):
|
|
361
|
+
continue
|
|
362
|
+
path_part = _plan_task_path_part(stripped[2:])
|
|
363
|
+
if path_part:
|
|
364
|
+
paths.append(path_part)
|
|
365
|
+
return paths
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _check_plan_file_refs(run_dir: Path, content: str) -> list[str]:
|
|
369
|
+
"""Hard-check Files paths against the Context Pack repo_root. create-type tasks
|
|
370
|
+
are exempt; the part after '::' (a symbol) is advisory and not checked (no AST pack yet)."""
|
|
371
|
+
import json
|
|
372
|
+
pack_json = run_dir / "02-project-context.json"
|
|
373
|
+
if not pack_json.exists():
|
|
374
|
+
return [] # no ground truth -> advisory only
|
|
375
|
+
try:
|
|
376
|
+
repo_root = Path(json.loads(pack_json.read_text(encoding="utf-8")).get("repo_root", ""))
|
|
377
|
+
except (ValueError, OSError):
|
|
378
|
+
return []
|
|
379
|
+
if not repo_root or not repo_root.exists():
|
|
380
|
+
return []
|
|
381
|
+
repo_root = repo_root.resolve()
|
|
382
|
+
issues: list[str] = []
|
|
383
|
+
for body in _iter_plan_task_bodies(content):
|
|
384
|
+
change_type = _plan_task_field_value(body, "Change Type").strip().lower()
|
|
385
|
+
skip_missing_path = change_type == "create"
|
|
386
|
+
files_field = _plan_task_field_body(body, "Files")
|
|
387
|
+
for path_part in _plan_task_file_paths(files_field):
|
|
388
|
+
path = Path(path_part)
|
|
389
|
+
if path.is_absolute():
|
|
390
|
+
resolved = path.resolve()
|
|
391
|
+
else:
|
|
392
|
+
resolved = (repo_root / path).resolve()
|
|
393
|
+
try:
|
|
394
|
+
resolved.relative_to(repo_root)
|
|
395
|
+
except ValueError:
|
|
396
|
+
issues.append(
|
|
397
|
+
f"PLAN-TASK Files references path outside repo_root {path_part!r}."
|
|
398
|
+
)
|
|
399
|
+
continue
|
|
400
|
+
if not resolved.exists():
|
|
401
|
+
if not skip_missing_path:
|
|
402
|
+
issues.append(
|
|
403
|
+
f"PLAN-TASK Files references missing path {path_part!r} "
|
|
404
|
+
"(mark the task 'Change Type: create' if it is a new file)."
|
|
405
|
+
)
|
|
406
|
+
return issues
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _check_spec_refs_valid(run_dir: Path, content: str) -> list[str]:
|
|
410
|
+
spec_path = run_dir / STAGE_ARTIFACT_MAP[Stage.SPEC]
|
|
411
|
+
spec_content = (
|
|
412
|
+
strip_readonly_sections(spec_path.read_text(encoding="utf-8"))
|
|
413
|
+
if spec_path.exists()
|
|
414
|
+
else ""
|
|
415
|
+
)
|
|
416
|
+
defined_specs: set[str] = set()
|
|
417
|
+
for line, _, _ in unfenced_markdown_lines(spec_content):
|
|
418
|
+
if line.lstrip().startswith("#"):
|
|
419
|
+
defined_specs.update(re.findall(r"\bSPEC-[A-Z]+-\d+\b", line))
|
|
420
|
+
issues: list[str] = []
|
|
421
|
+
for body in _iter_plan_task_bodies(content):
|
|
422
|
+
refs_body = _plan_task_field_body(body, "Spec References")
|
|
423
|
+
refs = re.findall(r"SPEC-[A-Z]+-\d+", refs_body)
|
|
424
|
+
if refs_body.strip() and not refs:
|
|
425
|
+
issues.append(
|
|
426
|
+
f"{_plan_task_label(body)} must reference at least one SPEC-* ID "
|
|
427
|
+
"in 'Spec References:'."
|
|
428
|
+
)
|
|
429
|
+
for ref in refs:
|
|
430
|
+
if ref not in defined_specs:
|
|
431
|
+
issues.append(f"PLAN-TASK references {ref} which is not defined in the SPEC artifact.")
|
|
432
|
+
return issues
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _check_plan_task_fields(content: str) -> list[str]:
|
|
436
|
+
issues: list[str] = []
|
|
437
|
+
numbers: list[int] = []
|
|
438
|
+
for body in _iter_plan_task_bodies(content):
|
|
439
|
+
m = re.match(r"###\s+PLAN-TASK-(\d+)", body)
|
|
440
|
+
num = int(m.group(1)) if m else None
|
|
441
|
+
if num is not None:
|
|
442
|
+
numbers.append(num)
|
|
443
|
+
label = _plan_task_label(body)
|
|
444
|
+
for field in PLAN_TASK_FIELDS:
|
|
445
|
+
if not _plan_task_field_body(body, field).strip():
|
|
446
|
+
issues.append(f"{label} is missing a non-empty '{field}:' field.")
|
|
447
|
+
if numbers:
|
|
448
|
+
if len(set(numbers)) != len(numbers):
|
|
449
|
+
issues.append("PLAN-TASK numbers must be unique.")
|
|
450
|
+
elif sorted(numbers) != list(range(1, len(numbers) + 1)):
|
|
451
|
+
issues.append("PLAN-TASK numbers must be contiguous starting at 1.")
|
|
452
|
+
return issues
|
|
453
|
+
|
|
454
|
+
|
|
293
455
|
def _has_complete_code_fence(content: str) -> bool:
|
|
294
456
|
fence_char = ""
|
|
295
457
|
fence_len = 0
|
|
@@ -324,12 +486,10 @@ def _has_complete_code_fence(content: str) -> bool:
|
|
|
324
486
|
|
|
325
487
|
def _plan_tasks_missing_code(content: str) -> bool:
|
|
326
488
|
"""True if any TDD-applicable PLAN-TASK has no fenced code block in its Skeleton field."""
|
|
327
|
-
|
|
328
|
-
if not
|
|
489
|
+
bodies = list(_iter_plan_task_bodies(content))
|
|
490
|
+
if not bodies:
|
|
329
491
|
return False
|
|
330
|
-
|
|
331
|
-
for i in range(len(starts)):
|
|
332
|
-
body = content[bounds[i]:bounds[i + 1]]
|
|
492
|
+
for body in bodies:
|
|
333
493
|
skeleton = _plan_task_field_body(body, "Skeleton")
|
|
334
494
|
tdd_applicable = _plan_task_field_value(body, "TDD Applicable")
|
|
335
495
|
if tdd_applicable.lower() == "yes" and not _has_complete_code_fence(skeleton):
|
|
@@ -337,6 +497,173 @@ def _plan_tasks_missing_code(content: str) -> bool:
|
|
|
337
497
|
return False
|
|
338
498
|
|
|
339
499
|
|
|
500
|
+
def _check_plan_task_skeleton_placeholders(content: str) -> list[str]:
|
|
501
|
+
issues: list[str] = []
|
|
502
|
+
for body in _iter_plan_task_bodies(content):
|
|
503
|
+
skeleton = _plan_task_field_body(body, "Skeleton")
|
|
504
|
+
if skeleton.strip() and any(pattern.search(skeleton) for pattern in _PLACEHOLDER_PATTERNS):
|
|
505
|
+
issues.append(
|
|
506
|
+
f"{_plan_task_label(body)} Skeleton contains an unresolved template "
|
|
507
|
+
"placeholder; replace placeholder text before passing the gate."
|
|
508
|
+
)
|
|
509
|
+
return issues
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _section_body(content: str, heading: str) -> str:
|
|
513
|
+
"""Return the text of the section under `heading`, stopping at the next same-or-higher heading."""
|
|
514
|
+
level = len(heading) - len(heading.lstrip("#"))
|
|
515
|
+
out, capture = [], False
|
|
516
|
+
for line, _, _ in unfenced_markdown_lines(content):
|
|
517
|
+
if line.strip() == heading:
|
|
518
|
+
capture = True
|
|
519
|
+
continue
|
|
520
|
+
if capture:
|
|
521
|
+
stripped = line.lstrip()
|
|
522
|
+
if stripped.startswith("#"):
|
|
523
|
+
# Count hashes of this line's heading
|
|
524
|
+
line_level = len(stripped) - len(stripped.lstrip("#"))
|
|
525
|
+
if line_level <= level:
|
|
526
|
+
break
|
|
527
|
+
out.append(line.rstrip("\r\n"))
|
|
528
|
+
return "\n".join(out)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _section_entries_missing_id(content: str, heading: str, id_prefix: str) -> list[str]:
|
|
532
|
+
missing: list[str] = []
|
|
533
|
+
pattern = re.compile(rf"\b{re.escape(id_prefix)}-\d+\b")
|
|
534
|
+
for line in _section_body(content, heading).splitlines():
|
|
535
|
+
stripped = line.strip()
|
|
536
|
+
if not stripped or stripped.startswith("<!--"):
|
|
537
|
+
continue
|
|
538
|
+
if not stripped.startswith(("- ", "* ")):
|
|
539
|
+
continue
|
|
540
|
+
if not pattern.search(stripped):
|
|
541
|
+
missing.append(stripped)
|
|
542
|
+
return missing
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _section_entry_ids(content: str, heading: str, id_prefix: str) -> list[str]:
|
|
546
|
+
ids: list[str] = []
|
|
547
|
+
pattern = re.compile(rf"\b{re.escape(id_prefix)}-\d+\b")
|
|
548
|
+
for line in _section_body(content, heading).splitlines():
|
|
549
|
+
stripped = line.strip()
|
|
550
|
+
if not stripped or stripped.startswith("<!--"):
|
|
551
|
+
continue
|
|
552
|
+
if not stripped.startswith(("- ", "* ")):
|
|
553
|
+
continue
|
|
554
|
+
match = pattern.search(stripped)
|
|
555
|
+
if match:
|
|
556
|
+
ids.append(match.group(0))
|
|
557
|
+
return ids
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _section_has_bullets(content: str, heading: str) -> bool:
|
|
561
|
+
return any(l.lstrip().startswith(("- ", "* ")) for l in _section_body(content, heading).splitlines())
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _check_elicitation(stage: Stage, tier: TierEstimate, content: str) -> list[str]:
|
|
565
|
+
"""R8: standard-tier brief must record at least one assumption or open question."""
|
|
566
|
+
from tools.workflow_cli.models import TierBase
|
|
567
|
+
if stage != Stage.REQUIREMENT_BRIEF or tier.base != TierBase.STANDARD:
|
|
568
|
+
return []
|
|
569
|
+
if _section_has_bullets(content, "## Assumptions") or _section_has_bullets(content, "## Open Questions"):
|
|
570
|
+
return []
|
|
571
|
+
return ["Standard-tier brief must record at least one assumption or open question (R8 elicitation)."]
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _check_scope_freeze(stage: Stage, content: str) -> list[str]:
|
|
575
|
+
"""R8: brief's In/Out-of-Scope must carry stable IDs so trace can anchor them."""
|
|
576
|
+
if stage != Stage.REQUIREMENT_BRIEF:
|
|
577
|
+
return []
|
|
578
|
+
issues: list[str] = []
|
|
579
|
+
for entry in _section_entries_missing_id(content, "## In-Scope", "SCOPE-IN"):
|
|
580
|
+
issues.append(f"In-Scope entry must carry a SCOPE-IN-* stable ID (R8): {entry}")
|
|
581
|
+
for entry in _section_entries_missing_id(content, "## Out-of-Scope", "SCOPE-OUT"):
|
|
582
|
+
issues.append(f"Out-of-Scope entry must carry a SCOPE-OUT-* stable ID (R8): {entry}")
|
|
583
|
+
for id_, count in Counter(_section_entry_ids(content, "## In-Scope", "SCOPE-IN")).items():
|
|
584
|
+
if count > 1:
|
|
585
|
+
issues.append(f"In-Scope stable ID {id_} is duplicate; scope IDs must be unique (R8).")
|
|
586
|
+
for id_, count in Counter(_section_entry_ids(content, "## Out-of-Scope", "SCOPE-OUT")).items():
|
|
587
|
+
if count > 1:
|
|
588
|
+
issues.append(f"Out-of-Scope stable ID {id_} is duplicate; scope IDs must be unique (R8).")
|
|
589
|
+
if not re.search(r"\bSCOPE-IN-\d+\b", _section_body(content, "## In-Scope")):
|
|
590
|
+
issues.append("In-Scope must list at least one stable-ID entry (SCOPE-IN-001, ...); none found (R8).")
|
|
591
|
+
if not re.search(r"\bSCOPE-OUT-\d+\b", _section_body(content, "## Out-of-Scope")):
|
|
592
|
+
issues.append("Out-of-Scope must list at least one stable-ID entry (SCOPE-OUT-001, ...); none found (R8).")
|
|
593
|
+
return issues
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _has_meaningful_body(text: str) -> bool:
|
|
597
|
+
"""True if `text` has at least one non-empty, non-comment line."""
|
|
598
|
+
for line in text.splitlines():
|
|
599
|
+
stripped = line.strip()
|
|
600
|
+
if stripped and not stripped.startswith("<!--"):
|
|
601
|
+
return True
|
|
602
|
+
return False
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _check_stage_schema(stage: Stage, tier: TierEstimate, content: str) -> list[str]:
|
|
606
|
+
"""R2 schema gate: required headings present, each required section has a
|
|
607
|
+
non-placeholder body, trace IDs are well-formed, RISK/DESIGN/SPEC define a
|
|
608
|
+
native ID heading, and no unresolved placeholders remain."""
|
|
609
|
+
from tools.workflow_cli.stage_schema import required_headings
|
|
610
|
+
issues: list[str] = []
|
|
611
|
+
headings = required_headings(stage, tier.base)
|
|
612
|
+
native = _STAGE_NATIVE_HEADING_PATTERNS.get(stage)
|
|
613
|
+
unfenced_content = unfenced_markdown_text(content)
|
|
614
|
+
present_headings = {
|
|
615
|
+
line.strip()
|
|
616
|
+
for line, _, _ in unfenced_markdown_lines(content)
|
|
617
|
+
if line.lstrip().startswith("#")
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if not headings and native is None:
|
|
621
|
+
return issues
|
|
622
|
+
|
|
623
|
+
# R2.1: required headings must be present
|
|
624
|
+
for heading in headings:
|
|
625
|
+
if heading not in present_headings:
|
|
626
|
+
issues.append(
|
|
627
|
+
f"Missing required section {heading!r} for stage {stage.value!r} "
|
|
628
|
+
f"at tier '{tier.base.value}'."
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
# R2.3a: each required heading's section must have a non-placeholder body
|
|
632
|
+
for heading in headings:
|
|
633
|
+
if heading not in present_headings:
|
|
634
|
+
continue # already reported by the required-heading presence check
|
|
635
|
+
body = _section_body(content, heading)
|
|
636
|
+
if not _has_meaningful_body(body):
|
|
637
|
+
issues.append(
|
|
638
|
+
f"Required section {heading!r} must contain non-placeholder body content."
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
# R2.3b: any trace-ID-looking token must be well-formed
|
|
642
|
+
for token in _TRACE_ID_CANDIDATE_RE.findall(unfenced_content):
|
|
643
|
+
if not _VALID_TRACE_ID_RE.fullmatch(token):
|
|
644
|
+
issues.append(
|
|
645
|
+
f"Malformed trace ID {token!r}; use REQ-AREA-001, SPEC-AREA-001, "
|
|
646
|
+
"SCOPE-IN-001, or PLAN-TASK-001 style IDs."
|
|
647
|
+
)
|
|
648
|
+
|
|
649
|
+
# R2.3c: RISK_DISCOVERY / DESIGN / SPEC must define at least one native trace ID in a heading
|
|
650
|
+
if native is not None and not native.search(unfenced_content):
|
|
651
|
+
issues.append(
|
|
652
|
+
f"Stage {stage.value!r} must define at least one native trace ID in a heading "
|
|
653
|
+
f"matching {native.pattern!r}."
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# R2.2: placeholder scan
|
|
657
|
+
for pat in _PLACEHOLDER_PATTERNS:
|
|
658
|
+
if pat.search(unfenced_content):
|
|
659
|
+
issues.append(
|
|
660
|
+
"Artifact contains an unresolved placeholder "
|
|
661
|
+
f"(pattern {pat.pattern!r}); fill it before passing the gate."
|
|
662
|
+
)
|
|
663
|
+
break
|
|
664
|
+
return issues
|
|
665
|
+
|
|
666
|
+
|
|
340
667
|
# ---------------------------------------------------------------------------
|
|
341
668
|
# Quality Gate
|
|
342
669
|
# ---------------------------------------------------------------------------
|
|
@@ -358,14 +685,22 @@ def check_quality_gate(
|
|
|
358
685
|
)
|
|
359
686
|
|
|
360
687
|
issues: list[str] = []
|
|
688
|
+
gate_content = strip_readonly_sections(artifact_content)
|
|
361
689
|
|
|
362
690
|
# Check 2: content must be non-empty
|
|
363
|
-
if not
|
|
691
|
+
if not gate_content or not gate_content.strip():
|
|
364
692
|
issues.append("Artifact content is empty or whitespace-only.")
|
|
365
693
|
|
|
366
694
|
if not issues:
|
|
367
695
|
# Check 3: upstream reference coverage closure (all tiers)
|
|
368
|
-
unclosed = _find_ids_without_closure(
|
|
696
|
+
unclosed = _find_ids_without_closure(gate_content)
|
|
697
|
+
if stage == Stage.PLAN:
|
|
698
|
+
from tools.workflow_cli.trace import plan_consumed_spec_ids
|
|
699
|
+
consumed_specs = plan_consumed_spec_ids(run_dir)
|
|
700
|
+
unclosed = [
|
|
701
|
+
ref_id for ref_id in unclosed
|
|
702
|
+
if not (ref_id.startswith("SPEC-") and ref_id in consumed_specs)
|
|
703
|
+
]
|
|
369
704
|
for ref_id in unclosed:
|
|
370
705
|
issues.append(
|
|
371
706
|
f"Upstream reference {ref_id!r} appears in artifact but has no closure status tag "
|
|
@@ -373,7 +708,7 @@ def check_quality_gate(
|
|
|
373
708
|
)
|
|
374
709
|
|
|
375
710
|
# Check 4: ID uniqueness within artifact
|
|
376
|
-
duplicates = _find_duplicate_ids(
|
|
711
|
+
duplicates = _find_duplicate_ids(gate_content)
|
|
377
712
|
for dup_id in duplicates:
|
|
378
713
|
issues.append(
|
|
379
714
|
f"Duplicate ID definition {dup_id!r} found in artifact; each ID must be unique."
|
|
@@ -382,26 +717,51 @@ def check_quality_gate(
|
|
|
382
717
|
# Check 5 (PLAN, standard tier): TDD-applicable tasks must carry a code block.
|
|
383
718
|
from tools.workflow_cli.models import TierBase
|
|
384
719
|
if stage == Stage.PLAN and tier.base == TierBase.STANDARD:
|
|
385
|
-
if not _plan_task_starts(
|
|
720
|
+
if not _plan_task_starts(gate_content):
|
|
386
721
|
issues.append(
|
|
387
722
|
"PLAN is missing '### PLAN-TASK-*' sections; standard tier requires "
|
|
388
723
|
"machine-parseable executable anchors."
|
|
389
724
|
)
|
|
390
|
-
elif _plan_tasks_missing_code(
|
|
725
|
+
elif _plan_tasks_missing_code(gate_content):
|
|
391
726
|
issues.append(
|
|
392
727
|
"PLAN has a 'TDD Applicable: yes' task with no fenced code block; "
|
|
393
728
|
"add a Skeleton code block (standard tier requires executable anchors)."
|
|
394
729
|
)
|
|
395
730
|
|
|
731
|
+
# Check 5b (PLAN): trace closure — all upstream SPEC/RISK/SCOPE-IN IDs must be consumed.
|
|
732
|
+
if stage == Stage.PLAN:
|
|
733
|
+
from tools.workflow_cli.trace import check_trace_closure, scope_out_violations
|
|
734
|
+
issues.extend(check_trace_closure(run_dir))
|
|
735
|
+
for sid in scope_out_violations(run_dir):
|
|
736
|
+
issues.append(f"PLAN references out-of-scope item {sid}; scope overflow (R8).")
|
|
737
|
+
# R5.1: required fields + contiguous numbering
|
|
738
|
+
issues.extend(_check_plan_task_fields(gate_content))
|
|
739
|
+
# R5.2: dangling SPEC references
|
|
740
|
+
issues.extend(_check_spec_refs_valid(run_dir, gate_content))
|
|
741
|
+
# R5.2b: Skeleton is fenced, so detect template placeholders there explicitly.
|
|
742
|
+
issues.extend(_check_plan_task_skeleton_placeholders(gate_content))
|
|
743
|
+
# R5.3: file refs vs Context Pack repo_root
|
|
744
|
+
issues.extend(_check_plan_file_refs(run_dir, gate_content))
|
|
745
|
+
|
|
396
746
|
# Check 6 (SPEC): the External Documentation Checked section must be present and non-empty.
|
|
397
747
|
if stage == Stage.SPEC:
|
|
398
|
-
if not _has_external_docs_inventory(
|
|
748
|
+
if not _has_external_docs_inventory(gate_content):
|
|
399
749
|
issues.append(
|
|
400
750
|
"SPEC is missing a non-empty '## External Documentation Checked' section. "
|
|
401
751
|
"Add it; if there are no external dependencies, include an explicit "
|
|
402
752
|
"'N/A — no external dependencies' row."
|
|
403
753
|
)
|
|
404
754
|
|
|
755
|
+
# Check 7 (R2): tier-aware required-section schema.
|
|
756
|
+
if not issues:
|
|
757
|
+
issues.extend(_check_stage_schema(stage, tier, gate_content))
|
|
758
|
+
|
|
759
|
+
# Check 8 (R8): scope-freeze — In/Out-of-Scope entries must carry stable IDs.
|
|
760
|
+
issues.extend(_check_scope_freeze(stage, gate_content))
|
|
761
|
+
|
|
762
|
+
# Check 9 (R8): elicitation — standard-tier brief must record at least one assumption or open question.
|
|
763
|
+
issues.extend(_check_elicitation(stage, tier, gate_content))
|
|
764
|
+
|
|
405
765
|
return GateResult(
|
|
406
766
|
passed=len(issues) == 0,
|
|
407
767
|
issues=issues,
|
|
@@ -25,6 +25,11 @@ class LinkExpansionResult:
|
|
|
25
25
|
_URL_PATTERN = re.compile(r'https?://[^\s\)\]\>\"\']+')
|
|
26
26
|
_LOCAL_DOT_PATTERN = re.compile(r'(?:^|[\s\(\[])(\./[^\s\)\]\>\"\']+|\.{2}/[^\s\)\]\>\"\']+)')
|
|
27
27
|
_LOCAL_SUBDIR_PATTERN = re.compile(r'(?:^|[\s\(\[])([a-zA-Z][a-zA-Z0-9_\-]*/[a-zA-Z0-9_\-][a-zA-Z0-9_\-./]*\.md)')
|
|
28
|
+
_PREVIEWABLE_LOCAL_EXTENSIONS = frozenset({".md", ".markdown", ".rst", ".adoc"})
|
|
29
|
+
_SENSITIVE_LOCAL_NAME_RE = re.compile(
|
|
30
|
+
r"(?:^|[-_.])(?:secrets?|credentials?|tokens?|passwords?|passwd|private[-_]?key|api[-_]?key)(?:$|[-_.])",
|
|
31
|
+
re.IGNORECASE,
|
|
32
|
+
)
|
|
28
33
|
|
|
29
34
|
|
|
30
35
|
def extract_links(text: str) -> list[str]:
|
|
@@ -54,7 +59,23 @@ def _fetch_url(url: str) -> LinkExpansionResult:
|
|
|
54
59
|
return LinkExpansionResult(url=url, status=LinkStatus.UNREACHABLE, error=str(e))
|
|
55
60
|
|
|
56
61
|
|
|
62
|
+
def _local_preview_block_reason(path_str: str, candidate: Path, base: Path | None) -> str | None:
|
|
63
|
+
try:
|
|
64
|
+
relative = candidate.relative_to(base) if base is not None else Path(path_str)
|
|
65
|
+
except ValueError:
|
|
66
|
+
relative = Path(path_str)
|
|
67
|
+
parts = [part for part in relative.parts if part not in ("", ".", "..")]
|
|
68
|
+
if any(part.startswith(".") for part in parts):
|
|
69
|
+
return "Local preview skipped for hidden path."
|
|
70
|
+
if any(_SENSITIVE_LOCAL_NAME_RE.search(part) for part in parts):
|
|
71
|
+
return "Local preview skipped for sensitive-looking path."
|
|
72
|
+
if candidate.suffix.lower() not in _PREVIEWABLE_LOCAL_EXTENSIONS:
|
|
73
|
+
return "Local preview skipped for unsupported local document extension."
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
57
77
|
def _expand_local(path_str: str, base_path: Path | None) -> LinkExpansionResult:
|
|
78
|
+
base = None
|
|
58
79
|
if base_path is not None:
|
|
59
80
|
base = base_path.resolve()
|
|
60
81
|
candidate = (base / path_str).resolve()
|
|
@@ -68,6 +89,13 @@ def _expand_local(path_str: str, base_path: Path | None) -> LinkExpansionResult:
|
|
|
68
89
|
candidate = Path(path_str).resolve()
|
|
69
90
|
|
|
70
91
|
if candidate.exists() and candidate.is_file():
|
|
92
|
+
block_reason = _local_preview_block_reason(path_str, candidate, base)
|
|
93
|
+
if block_reason is not None:
|
|
94
|
+
return LinkExpansionResult(
|
|
95
|
+
url=path_str,
|
|
96
|
+
status=LinkStatus.LOCAL_FOUND,
|
|
97
|
+
error=block_reason,
|
|
98
|
+
)
|
|
71
99
|
try:
|
|
72
100
|
preview = candidate.read_text(encoding="utf-8", errors="ignore")[:500]
|
|
73
101
|
return LinkExpansionResult(url=path_str, status=LinkStatus.LOCAL_FOUND,
|