@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.
@@ -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
- all_refs = set(_UPSTREAM_ID_PATTERN.findall(content))
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(content):
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 _unfenced_markdown_lines(content):
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 _unfenced_markdown_lines(content):
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
- _PLAN_TASK_FIELD_RE = re.compile(
246
- r"^(Spec References|Change Type|TDD Applicable|Files|Skeleton|Steps|Verification):"
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 _unfenced_markdown_lines(content)
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 _unfenced_markdown_lines(task_body):
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 _unfenced_markdown_lines(task_body):
280
- if start >= after and _PLAN_TASK_FIELD_RE.match(line):
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
- starts = _plan_task_starts(content)
328
- if not starts:
489
+ bodies = list(_iter_plan_task_bodies(content))
490
+ if not bodies:
329
491
  return False
330
- bounds = starts + [len(content)]
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 artifact_content or not artifact_content.strip():
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(artifact_content)
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(artifact_content)
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(artifact_content):
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(artifact_content):
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(artifact_content):
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,