@xenonbyte/req-2-plan 0.2.3 → 0.4.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.
@@ -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,
@@ -0,0 +1,160 @@
1
+ """Shared Markdown helpers used by gate and trace checks.
2
+
3
+ The single source of truth for "read only the text outside fenced code
4
+ blocks", so template/example snippets inside ``` ... ``` (or ~~~) fences
5
+ do not register as real headings, trace IDs, or references.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+
11
+ # A fence opener/closer: up to 3 leading spaces, then 3+ backticks or tildes.
12
+ _FENCE_MARKER_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})")
13
+ _READONLY_SECTION_RE = re.compile(
14
+ r"^[ \t]{0,3}(#{1,6})\s+"
15
+ r"(?:Upstream Summary|Project Context)\s+\(read-only\)\s*(?:#+\s*)?$",
16
+ re.IGNORECASE,
17
+ )
18
+ _READONLY_SECTION_END_RE = re.compile(
19
+ r"^[ \t]{0,3}<!--\s*/r2p-read-only\s*-->\s*$",
20
+ re.IGNORECASE,
21
+ )
22
+
23
+
24
+ def unfenced_markdown_lines(content: str):
25
+ """Yield (line, start, end) for lines outside Markdown fenced code blocks.
26
+
27
+ `start`/`end` are byte-less character offsets into the original `content`,
28
+ so callers can map a yielded line back to its position in the source.
29
+ """
30
+ fence_char = ""
31
+ fence_len = 0
32
+ offset = 0
33
+ for line in content.splitlines(keepends=True):
34
+ marker = _FENCE_MARKER_RE.match(line)
35
+ if fence_char:
36
+ if (
37
+ marker
38
+ and marker.group(1)[0] == fence_char
39
+ and len(marker.group(1)) >= fence_len
40
+ and not line[marker.end():].strip()
41
+ ):
42
+ fence_char = ""
43
+ fence_len = 0
44
+ offset += len(line)
45
+ continue
46
+
47
+ if marker:
48
+ fence_char = marker.group(1)[0]
49
+ fence_len = len(marker.group(1))
50
+ offset += len(line)
51
+ continue
52
+
53
+ start = offset
54
+ offset += len(line)
55
+ yield line, start, offset
56
+
57
+
58
+ def unfenced_markdown_text(content: str) -> str:
59
+ return "".join(line for line, _, _ in unfenced_markdown_lines(content))
60
+
61
+
62
+ def strip_readonly_sections(content: str) -> str:
63
+ """Remove seeded read-only Markdown sections before structural validation."""
64
+ lines = list(unfenced_markdown_lines(content))
65
+ headings = [
66
+ (start, level)
67
+ for line, start, _ in lines
68
+ if (level := heading_level(line)) is not None
69
+ ]
70
+ markers = [
71
+ (start, len(match.group(1)))
72
+ for line, start, _ in lines
73
+ if (match := _READONLY_SECTION_RE.match(line))
74
+ ]
75
+ removals: list[tuple[int, int]] = []
76
+ for marker_start, marker_level in markers:
77
+ next_marker_start = next(
78
+ (start for start, _ in markers if start > marker_start),
79
+ len(content),
80
+ )
81
+ # Seeded payloads can contain copied documents with their own #/##
82
+ # headings, so prefer the explicit terminator when the seed provides it.
83
+ explicit_end = next(
84
+ (
85
+ line_end
86
+ for line, start, line_end in lines
87
+ if marker_start < start < next_marker_start
88
+ and _READONLY_SECTION_END_RE.match(line)
89
+ ),
90
+ None,
91
+ )
92
+ if explicit_end is not None:
93
+ removals.append((marker_start, explicit_end))
94
+ continue
95
+
96
+ end = len(content)
97
+ for heading_start, heading_level_ in headings:
98
+ if heading_start <= marker_start:
99
+ continue
100
+ if heading_level_ <= marker_level:
101
+ end = heading_start
102
+ break
103
+ removals.append((marker_start, end))
104
+
105
+ if not removals:
106
+ return content
107
+
108
+ merged: list[tuple[int, int]] = []
109
+ for start, end in sorted(removals):
110
+ if not merged or start > merged[-1][1]:
111
+ merged.append((start, end))
112
+ else:
113
+ prev_start, prev_end = merged[-1]
114
+ merged[-1] = (prev_start, max(prev_end, end))
115
+
116
+ pieces: list[str] = []
117
+ cursor = 0
118
+ for start, end in merged:
119
+ pieces.append(content[cursor:start])
120
+ cursor = end
121
+ pieces.append(content[cursor:])
122
+ return "".join(pieces)
123
+
124
+
125
+ def heading_level(line: str) -> int | None:
126
+ """ATX heading level (count of leading '#'), or None when not a heading."""
127
+ stripped = line.lstrip()
128
+ if not stripped.startswith("#"):
129
+ return None
130
+ return len(stripped) - len(stripped.lstrip("#"))
131
+
132
+
133
+ def heading_bounded_bodies(content: str, is_start):
134
+ """Yield each section whose heading line satisfies `is_start(line)`.
135
+
136
+ A section runs from its heading to the next heading at the same or higher
137
+ level, so a later sibling section cannot bleed into it. Headings are
138
+ located outside fenced code; each yielded body is a slice of the original
139
+ `content` (fences within the body are preserved for the caller to handle).
140
+ """
141
+ lines = list(unfenced_markdown_lines(content))
142
+ starts = [
143
+ (start, level)
144
+ for line, start, _ in lines
145
+ if (level := heading_level(line)) is not None and is_start(line)
146
+ ]
147
+ headings = [
148
+ (start, level)
149
+ for line, start, _ in lines
150
+ if (level := heading_level(line)) is not None
151
+ ]
152
+ for start, level in starts:
153
+ end = len(content)
154
+ for heading_start, heading_level_ in headings:
155
+ if heading_start <= start:
156
+ continue
157
+ if heading_level_ <= level:
158
+ end = heading_start
159
+ break
160
+ yield content[start:end]
@@ -0,0 +1,57 @@
1
+ import re
2
+
3
+ from tools.workflow_cli.models import Stage, TierBase
4
+
5
+ # Structural fields every PLAN-TASK anchor must carry (gate + trace share these).
6
+ PLAN_TASK_FIELDS = (
7
+ "Spec References",
8
+ "Change Type",
9
+ "TDD Applicable",
10
+ "Files",
11
+ "Skeleton",
12
+ "Steps",
13
+ "Verification",
14
+ )
15
+ # Matches a line that opens one of those fields, e.g. "Files: src/a.py".
16
+ PLAN_TASK_FIELD_RE = re.compile(r"^(" + "|".join(PLAN_TASK_FIELDS) + r"):")
17
+
18
+ # Headings MUST stay byte-identical to any existing gate regex they overlap.
19
+ # Notably "## External Documentation Checked" matches gates.py:_EXTERNAL_DOCS_RE.
20
+ STAGE_SCHEMA: dict = {
21
+ Stage.REQUIREMENT_BRIEF: {
22
+ TierBase.LIGHT: ["## Goal", "## In-Scope", "## Out-of-Scope", "## Acceptance Criteria"],
23
+ TierBase.STANDARD: [
24
+ "## Goal", "## In-Scope", "## Out-of-Scope", "## Non-Goals",
25
+ "## Assumptions", "## Acceptance Criteria", "## Open Questions", "## Sources",
26
+ ],
27
+ },
28
+ Stage.RISK_DISCOVERY: {
29
+ TierBase.LIGHT: ["## Risks", "## Boundaries"],
30
+ TierBase.STANDARD: ["## Risks", "## Boundaries", "## Scope Overflow Risks", "## Mitigations"],
31
+ },
32
+ Stage.DESIGN: {
33
+ TierBase.LIGHT: ["## Design Summary", "## Chosen Design", "## SPEC Handoff"],
34
+ TierBase.STANDARD: [
35
+ "## Design Summary", "## Current Code Evidence", "## Requirements Coverage",
36
+ "## Options Considered", "## Chosen Design", "## Decision Requests",
37
+ "## Rollback", "## Observability", "## SPEC Handoff",
38
+ ],
39
+ },
40
+ Stage.SPEC: {
41
+ TierBase.LIGHT: ["## Behavior Contracts", "## External Documentation Checked", "## PLAN Handoff"],
42
+ TierBase.STANDARD: [
43
+ "## Behavior Contracts", "## API / Data / Config Contracts",
44
+ "## External Documentation Checked", "## Test Matrix", "## Non-goals", "## PLAN Handoff",
45
+ ],
46
+ },
47
+ Stage.PLAN: {
48
+ # PLAN's substantive checks (task coverage, fields) live in R5, not R2.
49
+ TierBase.LIGHT: ["## Tasks"],
50
+ TierBase.STANDARD: ["## Tasks"],
51
+ },
52
+ }
53
+
54
+
55
+ def required_headings(stage: Stage, tier_base: TierBase) -> list[str]:
56
+ """Required top-level headings for a stage at a tier base; [] if unschema'd."""
57
+ return list(STAGE_SCHEMA.get(stage, {}).get(tier_base, []))
@@ -0,0 +1,66 @@
1
+ """Render STAGE_SCHEMA into per-stage, per-tier seed templates.
2
+
3
+ The CLI writes these into the stage content file so the agent starts from a
4
+ structured skeleton, not a blank page. Templates carry no semantic claims —
5
+ only headings, required gate anchors, example ID shapes, and a static trace-table skeleton.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from tools.workflow_cli.models import Stage, TierBase
10
+ from tools.workflow_cli.stage_schema import required_headings
11
+
12
+ _TRACE_SKELETON = (
13
+ "## Trace\n"
14
+ "<!-- Map this stage's IDs to upstream/downstream. R3 derives & checks closure. -->\n"
15
+ "| This ID | Upstream | Status |\n"
16
+ "|---|---|---|\n"
17
+ )
18
+
19
+
20
+ _HEADING_BODY = {
21
+ (Stage.REQUIREMENT_BRIEF, "## In-Scope"): "- SCOPE-IN-001 <!-- fill in -->\n",
22
+ (Stage.REQUIREMENT_BRIEF, "## Out-of-Scope"): "- SCOPE-OUT-001 <!-- fill in -->\n",
23
+ (Stage.RISK_DISCOVERY, "## Risks"): "### RISK-SEC-001 <!-- fill in -->\nStatus: <!-- fill in -->\n",
24
+ (Stage.DESIGN, "## Chosen Design"): "### DES-ARCH-001 <!-- fill in -->\n",
25
+ (Stage.DESIGN, "## Decision Requests"): (
26
+ "<!-- fill in -->\n"
27
+ "<!-- Write exactly `none` when no human decision is needed; otherwise list one `### DECISION-NNN` block per choice (fenced example below; keep guidance comments single-line). -->\n"
28
+ "```text\n"
29
+ "### DECISION-001 <short title>\n"
30
+ "Question: <what must a human choose?>\n"
31
+ "Options: A) ... / B) ...\n"
32
+ "Recommended: A\n"
33
+ "Status: pending\n"
34
+ "```\n"
35
+ ),
36
+ (Stage.SPEC, "## Behavior Contracts"): "### SPEC-BEHAVIOR-001 <!-- fill in -->\n",
37
+ (Stage.PLAN, "## Tasks"): (
38
+ "### PLAN-TASK-001 <!-- fill in -->\n"
39
+ "Spec References: SPEC-BEHAVIOR-001\n"
40
+ "Change Type: modify\n"
41
+ "TDD Applicable: yes\n"
42
+ "Files:\n"
43
+ "- <!-- fill in -->\n"
44
+ "Skeleton:\n"
45
+ "```python\n"
46
+ "# <!-- fill in -->\n"
47
+ "```\n"
48
+ "Steps:\n"
49
+ "- [ ] <!-- fill in -->\n"
50
+ "Verification: <!-- fill in -->\n"
51
+ ),
52
+ }
53
+
54
+
55
+ def _body_for(stage: Stage, heading: str) -> str:
56
+ return _HEADING_BODY.get((stage, heading), "<!-- fill in -->\n")
57
+
58
+
59
+ def template_for(stage: Stage, tier_base: TierBase) -> str:
60
+ headings = required_headings(stage, tier_base)
61
+ title = stage.value.replace("_", " ").title()
62
+ parts = [f"# {title}\n"]
63
+ for h in headings:
64
+ parts.append(f"{h}\n{_body_for(stage, h)}")
65
+ parts.append(_TRACE_SKELETON)
66
+ return "\n".join(parts)
@@ -0,0 +1,279 @@
1
+ """Cross-stage trace coverage (R3). Derived from artifacts; no stored matrix."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ from tools.workflow_cli.markdown import (
9
+ heading_bounded_bodies,
10
+ heading_level,
11
+ strip_readonly_sections,
12
+ unfenced_markdown_lines,
13
+ unfenced_markdown_text,
14
+ )
15
+ from tools.workflow_cli.models import STAGE_ARTIFACT_MAP, Stage
16
+ from tools.workflow_cli.stage_schema import PLAN_TASK_FIELD_RE
17
+
18
+ # REQ-AUTH-001 / RISK-SEC-001 / DES-AUTH-001 / SPEC-AUTH-001 / SCOPE-IN-001 / SCOPE-OUT-001 / PLAN-TASK-001
19
+ _ID_RE = re.compile(r"(?:REQ|RISK|DES|SPEC)-[A-Z]+-\d+|SCOPE-(?:IN|OUT)-\d+|PLAN-TASK-\d+")
20
+ _PLAN_TASK_HEADING_RE = re.compile(r"^###\s+PLAN-TASK-\d+\b")
21
+ _NATIVE_HEADING_ID_PREFIXES: dict[Stage, tuple[str, ...]] = {
22
+ Stage.RAW_REQUIREMENT: ("REQ-",),
23
+ Stage.RISK_DISCOVERY: ("RISK-",),
24
+ Stage.DESIGN: ("DES-",),
25
+ Stage.SPEC: ("SPEC-",),
26
+ Stage.PLAN: ("PLAN-TASK-",),
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class TraceModel:
32
+ defined: dict = field(default_factory=dict) # id -> stage value where first defined
33
+
34
+
35
+ def _scope_ids_defined_in_brief(stage: Stage, content: str) -> set[str]:
36
+ """SCOPE-* ids are defined by bullet entries in the brief scope sections."""
37
+ if stage != Stage.REQUIREMENT_BRIEF:
38
+ return set()
39
+ ids: set[str] = set()
40
+ capture = False
41
+ capture_level = 0
42
+ for line, _, _ in unfenced_markdown_lines(content):
43
+ stripped = line.strip()
44
+ level = heading_level(line)
45
+ if stripped in {"## In-Scope", "## Out-of-Scope"}:
46
+ capture = True
47
+ capture_level = level or 0
48
+ continue
49
+ if capture and level is not None and level <= capture_level:
50
+ capture = False
51
+ if capture:
52
+ ids.update(m.group(0) for m in _ID_RE.finditer(line) if m.group(0).startswith("SCOPE-"))
53
+ return ids
54
+
55
+
56
+ def _native_heading_ids(stage: Stage, content: str) -> set[str]:
57
+ prefixes = _NATIVE_HEADING_ID_PREFIXES.get(stage, ())
58
+ if not prefixes:
59
+ return set()
60
+ ids: set[str] = set()
61
+ for line, _, _ in unfenced_markdown_lines(content):
62
+ if line.lstrip().startswith("#"):
63
+ for match in _ID_RE.finditer(line):
64
+ id_ = match.group(0)
65
+ if id_.startswith(prefixes):
66
+ ids.add(id_)
67
+ return ids
68
+
69
+
70
+ def _artifact_text(run_dir: Path, stage: Stage) -> str:
71
+ path = run_dir / STAGE_ARTIFACT_MAP[stage]
72
+ return strip_readonly_sections(path.read_text(encoding="utf-8")) if path.exists() else ""
73
+
74
+
75
+ def _plan_task_bodies(plan_content: str):
76
+ return heading_bounded_bodies(plan_content, _PLAN_TASK_HEADING_RE.match)
77
+
78
+
79
+ def _find_plan_task_field(body: str, field: str):
80
+ field_re = re.compile(rf"^{re.escape(field)}:\s*(.*)$")
81
+ for line, start, _ in unfenced_markdown_lines(body):
82
+ m = field_re.match(line)
83
+ if m:
84
+ return m, start
85
+ return None
86
+
87
+
88
+ def _find_next_plan_task_field_start(body: str, after: int) -> int | None:
89
+ for line, start, _ in unfenced_markdown_lines(body):
90
+ if start >= after and PLAN_TASK_FIELD_RE.match(line):
91
+ return start
92
+ return None
93
+
94
+
95
+ def _plan_task_field_value(body: str, field: str) -> str:
96
+ found = _find_plan_task_field(body, field)
97
+ if found is None:
98
+ return ""
99
+ match, line_start = found
100
+ body_start = line_start + match.end()
101
+ next_start = _find_next_plan_task_field_start(body, body_start)
102
+ end = next_start if next_start is not None else len(body)
103
+ return f"{match.group(1)}\n{body[body_start:end]}".strip()
104
+
105
+
106
+ def plan_consumed_spec_ids(run_dir: Path) -> set[str]:
107
+ """SPEC IDs consumed by PLAN-TASK Spec References fields, not merely mentioned."""
108
+ plan = _artifact_text(run_dir, Stage.PLAN)
109
+ consumed: set[str] = set()
110
+ for body in _plan_task_bodies(plan):
111
+ consumed.update(m.group(0) for m in _ID_RE.finditer(_plan_task_field_value(body, "Spec References"))
112
+ if m.group(0).startswith("SPEC-"))
113
+ return consumed
114
+
115
+
116
+ def _heading_blocks(content: str, id_pattern: str) -> dict[str, str]:
117
+ """Map each `id_pattern` ID defined in a heading to its section text.
118
+
119
+ A block runs from its heading to the next same-or-higher heading, so a
120
+ later sibling section cannot bleed its references into the block. Fenced
121
+ code is ignored.
122
+ """
123
+ content = unfenced_markdown_text(content)
124
+ starts = list(re.finditer(rf"(?m)^(#+)\s+.*?\b({id_pattern})\b", content))
125
+ headings = list(re.finditer(r"(?m)^(#+)\s+", content))
126
+ blocks: dict[str, str] = {}
127
+ for match in starts:
128
+ level = len(match.group(1))
129
+ end = len(content)
130
+ for heading in headings:
131
+ if heading.start() <= match.start():
132
+ continue
133
+ if len(heading.group(1)) <= level:
134
+ end = heading.start()
135
+ break
136
+ blocks[match.group(2)] = content[match.start():end]
137
+ return blocks
138
+
139
+
140
+ def _spec_blocks(spec_content: str) -> dict[str, str]:
141
+ return _heading_blocks(spec_content, r"SPEC-[A-Z]+-\d+")
142
+
143
+
144
+ def _risk_blocks(content: str) -> dict[str, str]:
145
+ return _heading_blocks(content, r"RISK-[A-Z]+-\d+")
146
+
147
+
148
+ def scope_in_not_closed(run_dir: Path) -> list[str]:
149
+ """SCOPE-IN closes only when a PLAN-TASK carries it or consumes a SPEC carrying it."""
150
+ model = build_trace(run_dir)
151
+ plan_text = _artifact_text(run_dir, Stage.PLAN)
152
+ plan_task_text = "\n".join(unfenced_markdown_text(body) for body in _plan_task_bodies(plan_text))
153
+ spec_blocks = _spec_blocks(_artifact_text(run_dir, Stage.SPEC))
154
+ consumed_specs = plan_consumed_spec_ids(run_dir)
155
+ issues: list[str] = []
156
+ for id_ in sorted(i for i in model.defined if i.startswith("SCOPE-IN-")):
157
+ if id_ in plan_task_text:
158
+ continue
159
+ if any(id_ in spec_blocks.get(spec_id, "") for spec_id in consumed_specs):
160
+ continue
161
+ issues.append(id_)
162
+ return issues
163
+
164
+
165
+ def risk_ids_not_closed(run_dir: Path) -> list[str]:
166
+ """RISK-* blocks must declare Status: mitigated|deferred|out_of_scope|out-of-scope.
167
+
168
+ Closure is read only from the risk definition block in RISK_DISCOVERY.
169
+ A block stops at the next same-or-higher Markdown heading, so unrelated
170
+ downstream Status fields cannot close an open risk.
171
+ """
172
+ model = build_trace(run_dir)
173
+ blocks = _risk_blocks(_artifact_text(run_dir, Stage.RISK_DISCOVERY))
174
+ open_risks: list[str] = []
175
+ for id_ in sorted(i for i in model.defined if i.startswith("RISK-")):
176
+ if not re.search(r"(?m)^Status:\s*(mitigated|deferred|out(?:_of_scope|-of-scope))\s*$", blocks.get(id_, "")):
177
+ open_risks.append(id_)
178
+ return open_risks
179
+
180
+
181
+ def build_trace(run_dir: Path) -> TraceModel:
182
+ model = TraceModel()
183
+ for stage, filename in STAGE_ARTIFACT_MAP.items():
184
+ path = run_dir / filename
185
+ if not path.exists():
186
+ continue
187
+ content = _artifact_text(run_dir, stage)
188
+ heading_ids = _native_heading_ids(stage, content)
189
+ definition_ids = heading_ids | _scope_ids_defined_in_brief(stage, content)
190
+ for id_ in definition_ids:
191
+ model.defined.setdefault(id_, stage.value)
192
+ return model
193
+
194
+
195
+ def spec_ids_not_consumed(run_dir: Path) -> list[str]:
196
+ """SPEC-* defined but not consumed by PLAN-TASK Spec References fields."""
197
+ model = build_trace(run_dir)
198
+ consumed = plan_consumed_spec_ids(run_dir)
199
+ return sorted(id_ for id_, _ in model.defined.items()
200
+ if id_.startswith("SPEC-") and id_ not in consumed)
201
+
202
+
203
+ _NON_GOALS_TITLE = "non-goals"
204
+
205
+
206
+ def _strip_nested_non_goals(block: str) -> str:
207
+ """Remove Non-goals subsections nested inside a SPEC block (R9).
208
+
209
+ Only headings deeper than the block's own heading qualify; an exempt
210
+ subsection runs to the next same-or-higher heading, so a later sibling
211
+ section still counts toward scope-overflow scanning. The document-level
212
+ `## Non-goals` never enters a SPEC block (see `_heading_blocks`), so it
213
+ needs no handling here.
214
+ """
215
+ headings: list[tuple[int, int, bool]] = [] # (offset, level, is_non_goals)
216
+ block_level: int | None = None
217
+ offset = 0
218
+ for line in block.splitlines(keepends=True):
219
+ level = heading_level(line)
220
+ if level is not None:
221
+ if block_level is None:
222
+ block_level = level # the SPEC block's own heading
223
+ else:
224
+ title = line.strip().strip("#").strip().lower()
225
+ headings.append((offset, level, title == _NON_GOALS_TITLE))
226
+ offset += len(line)
227
+ removals: list[tuple[int, int]] = []
228
+ for i, (start, level, is_non_goals) in enumerate(headings):
229
+ if not is_non_goals or (block_level is not None and level <= block_level):
230
+ continue
231
+ end = len(block)
232
+ for next_start, next_level, _ in headings[i + 1:]:
233
+ if next_level <= level:
234
+ end = next_start
235
+ break
236
+ removals.append((start, end))
237
+ pieces: list[str] = []
238
+ cursor = 0
239
+ for start, end in removals:
240
+ if start < cursor:
241
+ continue # nested inside an already-removed Non-goals section
242
+ pieces.append(block[cursor:start])
243
+ cursor = end
244
+ pieces.append(block[cursor:])
245
+ return "".join(pieces)
246
+
247
+
248
+ def scope_out_violations(run_dir: Path) -> list[str]:
249
+ """SCOPE-OUT-* ids that PLAN-TASK bodies reference, directly or via a
250
+ consumed SPEC block — a scope overflow (R8/R9). Non-goals subsections
251
+ nested inside a consumed SPEC block are exempt (legitimate exclusion
252
+ declarations)."""
253
+ plan_text = _artifact_text(run_dir, Stage.PLAN)
254
+ plan_task_text = "\n".join(unfenced_markdown_text(body) for body in _plan_task_bodies(plan_text))
255
+ violations = {
256
+ m.group(0)
257
+ for m in _ID_RE.finditer(plan_task_text)
258
+ if m.group(0).startswith("SCOPE-OUT-")
259
+ }
260
+ spec_blocks = _spec_blocks(_artifact_text(run_dir, Stage.SPEC))
261
+ for spec_id in plan_consumed_spec_ids(run_dir):
262
+ scanned = _strip_nested_non_goals(spec_blocks.get(spec_id, ""))
263
+ violations.update(
264
+ m.group(0)
265
+ for m in _ID_RE.finditer(scanned)
266
+ if m.group(0).startswith("SCOPE-OUT-")
267
+ )
268
+ return sorted(violations)
269
+
270
+
271
+ def check_trace_closure(run_dir: Path) -> list[str]:
272
+ issues: list[str] = []
273
+ for id_ in spec_ids_not_consumed(run_dir):
274
+ issues.append(f"SPEC {id_} is not consumed by any PLAN-TASK (coverage gap).")
275
+ for id_ in scope_in_not_closed(run_dir):
276
+ issues.append(f"In-scope item {id_} is not carried into PLAN consumption (scope not closed).")
277
+ for id_ in risk_ids_not_closed(run_dir):
278
+ issues.append(f"Risk {id_} is not mitigated, deferred, or marked out-of-scope (risk not closed).")
279
+ return issues
@@ -1 +1 @@
1
- R2P_VERSION = "0.2.3"
1
+ R2P_VERSION = "0.4.0"