specfuse-loop 0.2.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.
- specfuse/loop/__init__.py +5 -0
- specfuse/loop/_miniyaml.py +466 -0
- specfuse/loop/adopt_feature.py +217 -0
- specfuse/loop/gate_eval.py +503 -0
- specfuse/loop/gh_backend.py +82 -0
- specfuse/loop/gh_features.py +98 -0
- specfuse/loop/lint_plan.py +616 -0
- specfuse/loop/loop.py +3504 -0
- specfuse/loop/validate_event.py +282 -0
- specfuse_loop-0.2.0.dist-info/METADATA +192 -0
- specfuse_loop-0.2.0.dist-info/RECORD +16 -0
- specfuse_loop-0.2.0.dist-info/WHEEL +5 -0
- specfuse_loop-0.2.0.dist-info/entry_points.txt +3 -0
- specfuse_loop-0.2.0.dist-info/licenses/LICENSE +201 -0
- specfuse_loop-0.2.0.dist-info/licenses/NOTICE +6 -0
- specfuse_loop-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 Specfuse contributors
|
|
4
|
+
# Licensed under the Apache License, Version 2.0. See LICENSE.
|
|
5
|
+
#
|
|
6
|
+
"""
|
|
7
|
+
Specfuse plan linter.
|
|
8
|
+
|
|
9
|
+
Validates a feature folder's structural integrity:
|
|
10
|
+
- PLAN.md has the required feature frontmatter and a parseable graph,
|
|
11
|
+
- every WU referenced in the graph has a file that exists with valid frontmatter,
|
|
12
|
+
- every dependency edge points at a WU that exists in the graph,
|
|
13
|
+
- every gate carries the mandatory closing sequence in order
|
|
14
|
+
(retrospective -> lessons -> docs -> plan-next),
|
|
15
|
+
- any WU in `draft` (i.e. just produced by plan-next) has the five mandatory
|
|
16
|
+
prompt sections, so it is actually dispatchable.
|
|
17
|
+
|
|
18
|
+
Two jobs:
|
|
19
|
+
1. plan-next's verification gate calls this (a malformed next-gate draft fails
|
|
20
|
+
HERE, where you are already reviewing — far cheaper than failing mid-dispatch
|
|
21
|
+
three gates later).
|
|
22
|
+
2. a human integrity check you can run any time.
|
|
23
|
+
|
|
24
|
+
Exit 0 = clean, 1 = problems (printed).
|
|
25
|
+
|
|
26
|
+
Usage: python .specfuse/scripts/lint_plan.py .specfuse/features/FEAT-XXXX-slug
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from . import _miniyaml
|
|
35
|
+
from .loop import VERDICT_VALUES
|
|
36
|
+
|
|
37
|
+
FM = re.compile(r"^---\s*$")
|
|
38
|
+
REQUIRED_FEATURE_KEYS = {"feature_id", "title", "branch", "roadmap_goal", "status"}
|
|
39
|
+
VALID_TYPES = {"implementation", "retrospective", "lessons", "docs", "plan-next", "close",
|
|
40
|
+
"close-intermediate"}
|
|
41
|
+
VALID_STATUS = {"draft", "pending", "ready", "in_progress", "in_review", "done",
|
|
42
|
+
"blocked_human", "abandoned"}
|
|
43
|
+
CLOSING_SEQUENCE = ["retrospective", "lessons", "docs", "plan-next"]
|
|
44
|
+
# New compact closing shapes (FEAT-2026-0015):
|
|
45
|
+
# non-terminal gate: close-intermediate → plan-next
|
|
46
|
+
# terminal gate: close (any feature size)
|
|
47
|
+
# Legacy 4-WU CLOSING_SEQUENCE still accepted on any gate but emits a WARN.
|
|
48
|
+
NEW_INTERMEDIATE_SEQUENCE = ["close-intermediate", "plan-next"]
|
|
49
|
+
# All WU types that count as closing work.
|
|
50
|
+
_CLOSING_TYPES = frozenset(CLOSING_SEQUENCE) | {"close", "close-intermediate"}
|
|
51
|
+
# Correlation-ID pattern — canonical, mirroring `.specfuse/rules/correlation-ids.md`.
|
|
52
|
+
# Two namespaces:
|
|
53
|
+
# Component-local: FEAT-YYYY-NNNN, optional /(T<NN>[H[N*]] | G<n>-<CLOSE>).
|
|
54
|
+
# Orchestrated: INIT-YYYY-NNNN/F<NN>, optional /(T<NN>[H[N*]] | G<n>-<CLOSE>).
|
|
55
|
+
# A bare INIT-YYYY-NNNN (no /FNN segment) is NOT a loop feature ID.
|
|
56
|
+
MODEL_ALIASES = frozenset({"sonnet", "opus", "haiku"})
|
|
57
|
+
VALID_EFFORT = frozenset({"low", "medium", "high", "xhigh", "max"})
|
|
58
|
+
FULL_MODEL_ID_RE = re.compile(r"^claude-\w[\w.-]*$")
|
|
59
|
+
|
|
60
|
+
CORRELATION_ID_RE = re.compile(
|
|
61
|
+
r"^(FEAT-\d{4}-\d{4}(/(T\d{2}(H\d*)?|G\d+-(RETRO|LESSONS|DOCS|PLAN|CLOSE-INTERMEDIATE|CLOSE)))?|"
|
|
62
|
+
r"INIT-\d{4}-\d{4}/F\d{2}(/(T\d{2}(H\d*)?|G\d+-(RETRO|LESSONS|DOCS|PLAN|CLOSE-INTERMEDIATE|CLOSE)))?)$"
|
|
63
|
+
)
|
|
64
|
+
# The five mandatory sections (architecture §8). 'Objective' is recommended in the
|
|
65
|
+
# template but not hard-required here.
|
|
66
|
+
REQUIRED_SECTIONS = ["Context", "Acceptance criteria", "Do not touch",
|
|
67
|
+
"Verification", "Escalation triggers"]
|
|
68
|
+
SECTION_CHECK_STATUSES = {"draft", "pending", "ready"}
|
|
69
|
+
|
|
70
|
+
# Oracle-env lint (FEAT-2026-0015/T05).
|
|
71
|
+
_ORACLE_EXEMPT_TYPES = frozenset({"lessons", "docs", "retrospective"})
|
|
72
|
+
|
|
73
|
+
# Driver-wiring keyword detector (FEAT-2026-0017/T02).
|
|
74
|
+
_DRIVER_WIRING_PATTERNS = [
|
|
75
|
+
re.compile(r"\bloop\.py\b", re.IGNORECASE),
|
|
76
|
+
re.compile(r"\bdriver-side\b", re.IGNORECASE),
|
|
77
|
+
re.compile(r"\bMODEL_BY_TYPE\b", re.IGNORECASE),
|
|
78
|
+
re.compile(r"\bEFFORT_BY_TYPE\b", re.IGNORECASE),
|
|
79
|
+
re.compile(r"\bGATES_FOR_TYPE\b", re.IGNORECASE),
|
|
80
|
+
re.compile(r"\bCLOSING_ASSERTIONS_BY_TYPE\b", re.IGNORECASE),
|
|
81
|
+
re.compile(r"\bPOST_PASS_INVARIANTS_BY_TYPE\b", re.IGNORECASE),
|
|
82
|
+
re.compile(r"\bfire_terminal_flips\b", re.IGNORECASE),
|
|
83
|
+
re.compile(r"\bassert_terminal_flips_fired\b", re.IGNORECASE),
|
|
84
|
+
re.compile(r"\bsquash_commit\b", re.IGNORECASE),
|
|
85
|
+
re.compile(r"\breset_preserving_events\b", re.IGNORECASE),
|
|
86
|
+
re.compile(r"\bcommit_bookkeeping\b", re.IGNORECASE),
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def detect_driver_wiring(wu_body: str) -> list[str]:
|
|
91
|
+
"""Return matched wiring-keyword strings found in wu_body."""
|
|
92
|
+
found = []
|
|
93
|
+
for pat in _DRIVER_WIRING_PATTERNS:
|
|
94
|
+
m = pat.search(wu_body)
|
|
95
|
+
if m:
|
|
96
|
+
found.append(m.group(0))
|
|
97
|
+
return found
|
|
98
|
+
ORACLE_VERB_PATTERNS = (
|
|
99
|
+
re.compile(r"\btest\s+loops?\b", re.IGNORECASE),
|
|
100
|
+
re.compile(r"\bloops?\s+of\s+tests?\b", re.IGNORECASE),
|
|
101
|
+
re.compile(r"\baudit\b", re.IGNORECASE),
|
|
102
|
+
re.compile(r"\brecursive\s+run\b", re.IGNORECASE),
|
|
103
|
+
re.compile(r"\brun\s+\d+\s+times\b", re.IGNORECASE),
|
|
104
|
+
re.compile(r"\b\d+\s+consecutive\s+runs?\b", re.IGNORECASE),
|
|
105
|
+
re.compile(r"\bsmoke[-\s]tests?\b", re.IGNORECASE),
|
|
106
|
+
re.compile(r"\boracle\b", re.IGNORECASE),
|
|
107
|
+
re.compile(r"\bintegration\s+tests?\b", re.IGNORECASE),
|
|
108
|
+
re.compile(r"\be2e\b", re.IGNORECASE),
|
|
109
|
+
re.compile(r"for\s+i\s+in\s+\$\(seq\b", re.IGNORECASE),
|
|
110
|
+
re.compile(r"\brepeat\s+\d+\s+times\b", re.IGNORECASE),
|
|
111
|
+
)
|
|
112
|
+
_AC_START_RE = re.compile(
|
|
113
|
+
r"(?mi)^\*\*Acceptance criteria[^\n*]*\*\*\.?|^#{1,6}\s+Acceptance criteria"
|
|
114
|
+
)
|
|
115
|
+
_AC_END_RE = re.compile(r"(?m)^(?:\*\*|#{1,6}\s)")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _slice_ac_section(body: str) -> str:
|
|
119
|
+
"""Return the text of the Acceptance criteria section only (bold-preamble or ATX)."""
|
|
120
|
+
m = _AC_START_RE.search(body)
|
|
121
|
+
if not m:
|
|
122
|
+
return ""
|
|
123
|
+
nl = body.find("\n", m.end())
|
|
124
|
+
after = body[nl + 1:] if nl != -1 else ""
|
|
125
|
+
em = _AC_END_RE.search(after)
|
|
126
|
+
return after[:em.start()] if em else after
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _slice_section(body: str, section_name: str) -> str:
|
|
130
|
+
"""Return content between a named section heading and the next heading."""
|
|
131
|
+
start_re = re.compile(rf"(?mi)^(?:#+\s*|\**){re.escape(section_name)}")
|
|
132
|
+
m = start_re.search(body)
|
|
133
|
+
if not m:
|
|
134
|
+
return ""
|
|
135
|
+
nl = body.find("\n", m.end())
|
|
136
|
+
after = body[nl + 1:] if nl != -1 else ""
|
|
137
|
+
em = _AC_END_RE.search(after)
|
|
138
|
+
return after[:em.start()] if em else after
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def detect_oracle_verbs(ac_section_text: str) -> list[str]:
|
|
142
|
+
"""Return matched oracle-verb strings found in the AC section text."""
|
|
143
|
+
found = []
|
|
144
|
+
for pat in ORACLE_VERB_PATTERNS:
|
|
145
|
+
m = pat.search(ac_section_text)
|
|
146
|
+
if m:
|
|
147
|
+
found.append(m.group(0))
|
|
148
|
+
return found
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def read_frontmatter(path: Path) -> tuple[dict, str]:
|
|
152
|
+
lines = path.read_text().splitlines()
|
|
153
|
+
if not lines or not FM.match(lines[0]):
|
|
154
|
+
return {}, path.read_text()
|
|
155
|
+
j = 1
|
|
156
|
+
while j < len(lines) and not FM.match(lines[j]):
|
|
157
|
+
j += 1
|
|
158
|
+
return _miniyaml.parse("\n".join(lines[1:j])) or {}, "\n".join(lines[j + 1:])
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _find_task_graph_block(body: str) -> dict | None:
|
|
162
|
+
"""Find the YAML block in PLAN.md that contains the task graph (issue #21).
|
|
163
|
+
|
|
164
|
+
PLAN.md may include multiple ```yaml fenced blocks (e.g. frontmatter
|
|
165
|
+
schema examples, type catalogs) before the actual task graph. Identify
|
|
166
|
+
the task-graph block by its top-level `gates:` key, scanning every
|
|
167
|
+
yaml block in order and returning the first one whose parsed value
|
|
168
|
+
contains `gates`.
|
|
169
|
+
|
|
170
|
+
Returns the parsed dict (with `gates` key) on success, or None when no
|
|
171
|
+
yaml block in the body contains a `gates` key.
|
|
172
|
+
"""
|
|
173
|
+
for m in re.finditer(r"```ya?ml\s*\n(.*?)\n```", body, re.DOTALL):
|
|
174
|
+
parsed = _miniyaml.parse(m.group(1)) or {}
|
|
175
|
+
if "gates" in parsed:
|
|
176
|
+
return parsed
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def check_planned_cost(feature_dir: Path, plan_fm: dict, gates: list) -> None:
|
|
181
|
+
"""Emit WARN for missing planned_cost_usd on WUs and PLAN.md.
|
|
182
|
+
|
|
183
|
+
Sealed WUs (wu status=done AND plan status=done) are skipped silently —
|
|
184
|
+
backfilling cost estimates on history is pointless. Active or draft WUs
|
|
185
|
+
get the WARN. PLAN.md is compared against the sum of WU planned costs;
|
|
186
|
+
delta > 10% emits a separate WARN naming the delta. Never raises or
|
|
187
|
+
appends to an errors list — all findings are WARN-only (exit code 0).
|
|
188
|
+
"""
|
|
189
|
+
plan_status = plan_fm.get("status", "")
|
|
190
|
+
wu_sum = 0.0
|
|
191
|
+
|
|
192
|
+
for g in gates:
|
|
193
|
+
units = g.get("work_units") or []
|
|
194
|
+
for ref in units:
|
|
195
|
+
wfile = ref.get("file")
|
|
196
|
+
if not wfile:
|
|
197
|
+
continue
|
|
198
|
+
wpath = feature_dir / wfile
|
|
199
|
+
if not wpath.exists():
|
|
200
|
+
continue
|
|
201
|
+
wfm, _ = read_frontmatter(wpath)
|
|
202
|
+
wu_status = wfm.get("status", "")
|
|
203
|
+
planned = wfm.get("planned_cost_usd")
|
|
204
|
+
|
|
205
|
+
# Sealed: feature done AND this WU done — nothing useful to backfill.
|
|
206
|
+
is_sealed = (wu_status == "done" and plan_status == "done")
|
|
207
|
+
if not is_sealed and planned is None:
|
|
208
|
+
print(
|
|
209
|
+
f"WARN: {wfile}: missing 'planned_cost_usd' frontmatter "
|
|
210
|
+
f"(optional but recommended for cost-variance calibration). "
|
|
211
|
+
f"See PLAN.md roadmap_goal § Planned-cost capture."
|
|
212
|
+
)
|
|
213
|
+
if planned is not None:
|
|
214
|
+
wu_sum += float(planned)
|
|
215
|
+
|
|
216
|
+
wu_sum = round(wu_sum, 2)
|
|
217
|
+
|
|
218
|
+
plan_cost = plan_fm.get("planned_cost_usd")
|
|
219
|
+
if plan_cost is None:
|
|
220
|
+
print(
|
|
221
|
+
f"WARN: {feature_dir}/PLAN.md: missing 'planned_cost_usd' frontmatter "
|
|
222
|
+
f"(optional but recommended for cost-variance calibration). "
|
|
223
|
+
f"See PLAN.md roadmap_goal § Planned-cost capture."
|
|
224
|
+
)
|
|
225
|
+
else:
|
|
226
|
+
plan_cost_f = round(float(plan_cost), 2)
|
|
227
|
+
if plan_cost_f > 0 or wu_sum > 0:
|
|
228
|
+
denom = plan_cost_f if plan_cost_f > 0 else wu_sum
|
|
229
|
+
delta_pct = abs(plan_cost_f - wu_sum) / denom * 100
|
|
230
|
+
else:
|
|
231
|
+
delta_pct = 0.0
|
|
232
|
+
if delta_pct > 10:
|
|
233
|
+
print(
|
|
234
|
+
f"WARN: {feature_dir}/PLAN.md: planned_cost_usd "
|
|
235
|
+
f"${plan_cost_f:.2f} differs from sum of WU planned costs "
|
|
236
|
+
f"${wu_sum:.2f} (delta {delta_pct:.0f}%, threshold 10%). "
|
|
237
|
+
f"Review estimates."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def lint(feature_dir: Path) -> list[str]:
|
|
242
|
+
errs: list[str] = []
|
|
243
|
+
plan = feature_dir / "PLAN.md"
|
|
244
|
+
if not plan.exists():
|
|
245
|
+
return [f"missing {plan}"]
|
|
246
|
+
|
|
247
|
+
fm, body = read_frontmatter(plan)
|
|
248
|
+
missing = REQUIRED_FEATURE_KEYS - set(fm)
|
|
249
|
+
if missing:
|
|
250
|
+
errs.append(f"PLAN.md frontmatter missing keys: {sorted(missing)}")
|
|
251
|
+
|
|
252
|
+
graph = _find_task_graph_block(body)
|
|
253
|
+
if graph is None:
|
|
254
|
+
return errs + ["PLAN.md has no ```yaml graph block"]
|
|
255
|
+
gates = graph.get("gates", [])
|
|
256
|
+
all_ids = {wu["id"] for g in gates for wu in (g.get("work_units") or [])}
|
|
257
|
+
# Last non-empty gate is the terminal gate; `close` is only valid there.
|
|
258
|
+
terminal_gate_gnum = next(
|
|
259
|
+
(g.get("gate", "?") for g in reversed(gates) if g.get("work_units")), None
|
|
260
|
+
)
|
|
261
|
+
# Track closing shape per gate for cross-gate mixed-shape detection.
|
|
262
|
+
_gate_closing_shapes: dict = {} # gnum -> "NEW" | "LEGACY" | "INVALID"
|
|
263
|
+
|
|
264
|
+
for g in gates:
|
|
265
|
+
gnum = g.get("gate", "?")
|
|
266
|
+
is_terminal = (gnum == terminal_gate_gnum)
|
|
267
|
+
units = g.get("work_units") or []
|
|
268
|
+
|
|
269
|
+
# GATE.md cost_budget_usd: optional, must be numeric when present.
|
|
270
|
+
# Validated independently of work-unit presence so a drafted-but-empty
|
|
271
|
+
# gate can still declare a budget for its eventual WUs.
|
|
272
|
+
gate_file_rel = g.get("file")
|
|
273
|
+
if gate_file_rel:
|
|
274
|
+
gate_path = feature_dir / gate_file_rel
|
|
275
|
+
if gate_path.exists():
|
|
276
|
+
gfm, _ = read_frontmatter(gate_path)
|
|
277
|
+
if "cost_budget_usd" in gfm:
|
|
278
|
+
val = gfm["cost_budget_usd"]
|
|
279
|
+
if isinstance(val, bool) or not isinstance(val, (int, float)):
|
|
280
|
+
errs.append(
|
|
281
|
+
f"{gate_file_rel}: cost_budget_usd must be numeric "
|
|
282
|
+
f"(int or float), got {val!r}"
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
# An un-drafted future gate (empty) is fine — it just hasn't been planned yet.
|
|
286
|
+
if not units:
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
# WU files + frontmatter + dependency edges.
|
|
290
|
+
types_in_order: list[str] = []
|
|
291
|
+
for ref in units:
|
|
292
|
+
wid, wfile = ref.get("id"), ref.get("file")
|
|
293
|
+
if not wid or not wfile:
|
|
294
|
+
errs.append(f"gate {gnum}: work unit missing id/file: {ref}")
|
|
295
|
+
continue
|
|
296
|
+
if not CORRELATION_ID_RE.match(wid):
|
|
297
|
+
errs.append(f"gate {gnum}: malformed correlation id '{wid}' — "
|
|
298
|
+
f"must match {CORRELATION_ID_RE.pattern}")
|
|
299
|
+
for dep in ref.get("depends_on") or []:
|
|
300
|
+
if dep not in all_ids:
|
|
301
|
+
errs.append(f"gate {gnum}: {wid} depends on unknown WU '{dep}'")
|
|
302
|
+
wpath = feature_dir / wfile
|
|
303
|
+
if not wpath.exists():
|
|
304
|
+
errs.append(f"gate {gnum}: {wid} -> file not found: {wfile}")
|
|
305
|
+
continue
|
|
306
|
+
wfm, wbody = read_frontmatter(wpath)
|
|
307
|
+
fm_id = wfm.get("id")
|
|
308
|
+
if fm_id != wid:
|
|
309
|
+
errs.append(f"{wfile}: frontmatter id '{fm_id}' != graph id '{wid}'")
|
|
310
|
+
# Only flag the frontmatter id separately when it disagrees with the graph
|
|
311
|
+
# id (otherwise the graph-id check above already covers it).
|
|
312
|
+
if fm_id and fm_id != wid and not CORRELATION_ID_RE.match(fm_id):
|
|
313
|
+
errs.append(f"{wfile}: malformed frontmatter id '{fm_id}' — "
|
|
314
|
+
f"must match {CORRELATION_ID_RE.pattern}")
|
|
315
|
+
if wfm.get("type") not in VALID_TYPES:
|
|
316
|
+
errs.append(f"{wfile}: invalid type '{wfm.get('type')}'")
|
|
317
|
+
if "model" in wfm:
|
|
318
|
+
_model = wfm["model"]
|
|
319
|
+
if not _model:
|
|
320
|
+
errs.append(
|
|
321
|
+
f"{wfile}: model present but has no value — must be a family alias "
|
|
322
|
+
f"({sorted(MODEL_ALIASES)}) or a full model ID (claude-*)"
|
|
323
|
+
)
|
|
324
|
+
elif _model not in MODEL_ALIASES and not FULL_MODEL_ID_RE.match(_model):
|
|
325
|
+
errs.append(
|
|
326
|
+
f"{wfile}: invalid model '{_model}' — must be a family alias "
|
|
327
|
+
f"({sorted(MODEL_ALIASES)}) or a full model ID (claude-*)"
|
|
328
|
+
)
|
|
329
|
+
# model absent: valid — load_wu applies MODEL_BY_TYPE[type] at dispatch time
|
|
330
|
+
if wfm.get("status") not in VALID_STATUS:
|
|
331
|
+
errs.append(f"{wfile}: invalid status '{wfm.get('status')}'")
|
|
332
|
+
_effort = wfm.get("effort")
|
|
333
|
+
if _effort is not None and _effort not in VALID_EFFORT:
|
|
334
|
+
errs.append(
|
|
335
|
+
f"{wfile}: invalid effort '{_effort}' — must be one of "
|
|
336
|
+
f"{sorted(VALID_EFFORT)}"
|
|
337
|
+
)
|
|
338
|
+
types_in_order.append(wfm.get("type"))
|
|
339
|
+
|
|
340
|
+
# Dispatchable WUs must have the five mandatory prompt sections.
|
|
341
|
+
if wfm.get("status") in SECTION_CHECK_STATUSES:
|
|
342
|
+
for sec in REQUIRED_SECTIONS:
|
|
343
|
+
if not re.search(rf"(?mi)^(?:#+\s*|\**){re.escape(sec)}", wbody):
|
|
344
|
+
errs.append(f"{wfile}: {wfm.get('status')} WU missing "
|
|
345
|
+
f"section '{sec}'")
|
|
346
|
+
|
|
347
|
+
# Verdict frontmatter validation.
|
|
348
|
+
wu_verdict = wfm.get("verdict")
|
|
349
|
+
wu_status = wfm.get("status")
|
|
350
|
+
wu_type_val = wfm.get("type")
|
|
351
|
+
if wu_type_val in {"close", "close-intermediate"}:
|
|
352
|
+
# draft/pending: verdict written at execution time, not before dispatch.
|
|
353
|
+
# done/abandoned/blocked_human: legacy fixtures without verdict are valid.
|
|
354
|
+
if wu_status not in {"draft", "pending", "done", "abandoned", "blocked_human"}:
|
|
355
|
+
if wu_verdict is None or wu_verdict not in VERDICT_VALUES:
|
|
356
|
+
errs.append(
|
|
357
|
+
f"ERROR: {wfile}: close-type WU missing or invalid 'verdict' "
|
|
358
|
+
f"frontmatter (must be one of: "
|
|
359
|
+
f"met, met_locally, partially_met, not_met)."
|
|
360
|
+
)
|
|
361
|
+
else:
|
|
362
|
+
if wu_verdict is not None:
|
|
363
|
+
errs.append(
|
|
364
|
+
f"ERROR: {wfile}: 'verdict' frontmatter is only meaningful for "
|
|
365
|
+
f"closing types (close, close-intermediate); remove it from "
|
|
366
|
+
f"this {wu_type_val!r} WU."
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# Oracle-env WARN (FEAT-2026-0015/T05).
|
|
370
|
+
if wu_type_val not in _ORACLE_EXEMPT_TYPES:
|
|
371
|
+
ac_text = _slice_ac_section(wbody)
|
|
372
|
+
oracle_matches = detect_oracle_verbs(ac_text)
|
|
373
|
+
if oracle_matches and "oracle_env" not in wfm:
|
|
374
|
+
print(
|
|
375
|
+
f"WARN: {wfile}: AC mentions oracle-like work "
|
|
376
|
+
f"(matched: {oracle_matches}) but frontmatter has no "
|
|
377
|
+
f"'oracle_env' field. "
|
|
378
|
+
f"See LEARNINGS [FEAT-2026-0013/G1-CLOSE]."
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Driver-wiring declaration WARN (FEAT-2026-0017/T02).
|
|
382
|
+
if wu_type_val == "implementation":
|
|
383
|
+
wiring_matches = detect_driver_wiring(wbody)
|
|
384
|
+
pdh = wfm.get("produces_driver_helper")
|
|
385
|
+
pdh_empty = not pdh # None, [], "", or missing all count as empty
|
|
386
|
+
if wiring_matches and pdh_empty:
|
|
387
|
+
print(
|
|
388
|
+
f"WARN: {wfile}: implementation WU mentions driver wiring "
|
|
389
|
+
f"({wiring_matches}) but `produces_driver_helper` frontmatter "
|
|
390
|
+
f"is empty. Declare the symbol(s) this WU produces in the "
|
|
391
|
+
f"driver. See authoring-work-units §9 + FEAT-2026-0017."
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# Deliverable-presence declaration WARN (FEAT-2026-0022/T01).
|
|
395
|
+
# Advisory: an implementation WU should declare the file path(s) it
|
|
396
|
+
# is contracted to yield via `produces:`, which T02's presence gate
|
|
397
|
+
# enforces against disk. Closing types are exempt (gated on
|
|
398
|
+
# implementation above). Non-blocking; never appends to errs.
|
|
399
|
+
if wu_type_val == "implementation":
|
|
400
|
+
produces = wfm.get("produces")
|
|
401
|
+
produces_empty = not produces # None, [], "", or missing all count
|
|
402
|
+
if produces_empty:
|
|
403
|
+
print(
|
|
404
|
+
f"WARN: {wfile}: implementation WU declares no "
|
|
405
|
+
f"'produces:' deliverable list. See FEAT-2026-0022."
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Closing shape check.
|
|
409
|
+
closing_found = [t for t in types_in_order if t in _CLOSING_TYPES]
|
|
410
|
+
if closing_found == ["close"]:
|
|
411
|
+
if is_terminal:
|
|
412
|
+
_gate_closing_shapes[gnum] = "NEW"
|
|
413
|
+
else:
|
|
414
|
+
errs.append(
|
|
415
|
+
f"gate {gnum}: `close` WU is only valid on a terminal gate; "
|
|
416
|
+
f"non-terminal gates must use {NEW_INTERMEDIATE_SEQUENCE} "
|
|
417
|
+
f"(new) or {CLOSING_SEQUENCE} (legacy)"
|
|
418
|
+
)
|
|
419
|
+
_gate_closing_shapes[gnum] = "INVALID"
|
|
420
|
+
elif closing_found == NEW_INTERMEDIATE_SEQUENCE:
|
|
421
|
+
if not is_terminal:
|
|
422
|
+
_gate_closing_shapes[gnum] = "NEW"
|
|
423
|
+
else:
|
|
424
|
+
errs.append(
|
|
425
|
+
f"gate {gnum}: `close-intermediate → plan-next` is for "
|
|
426
|
+
f"non-terminal gates; terminal gate must use a single `close` WU "
|
|
427
|
+
f"(new) or {CLOSING_SEQUENCE} (legacy)"
|
|
428
|
+
)
|
|
429
|
+
_gate_closing_shapes[gnum] = "INVALID"
|
|
430
|
+
elif closing_found == CLOSING_SEQUENCE:
|
|
431
|
+
gate_file_for_warn = gate_file_rel or f"GATE-{gnum:02d}.md"
|
|
432
|
+
print(
|
|
433
|
+
f"WARN: {feature_dir}/{gate_file_for_warn} uses legacy 4-WU closing "
|
|
434
|
+
f"sequence; new contract is 2-WU (close-intermediate + plan-next) for "
|
|
435
|
+
f"intermediate or 1-WU (close) for terminal. See FEAT-2026-0015."
|
|
436
|
+
)
|
|
437
|
+
_gate_closing_shapes[gnum] = "LEGACY"
|
|
438
|
+
elif "close-intermediate" in closing_found:
|
|
439
|
+
errs.append(
|
|
440
|
+
f"gate {gnum}: close-intermediate must be immediately followed by "
|
|
441
|
+
f"plan-next; found closing sequence {closing_found}"
|
|
442
|
+
)
|
|
443
|
+
_gate_closing_shapes[gnum] = "INVALID"
|
|
444
|
+
else:
|
|
445
|
+
errs.append(
|
|
446
|
+
f"gate {gnum}: closing sequence must be {CLOSING_SEQUENCE} (legacy), "
|
|
447
|
+
f"{NEW_INTERMEDIATE_SEQUENCE} (non-terminal new), or a single `close` "
|
|
448
|
+
f"WU (terminal new); found {closing_found}"
|
|
449
|
+
)
|
|
450
|
+
_gate_closing_shapes[gnum] = "INVALID"
|
|
451
|
+
|
|
452
|
+
# Planned-cost capture: WARN on missing/divergent planned_cost_usd fields.
|
|
453
|
+
check_planned_cost(feature_dir, fm, gates)
|
|
454
|
+
|
|
455
|
+
# Cross-gate mixed-shape check. Two directions of mix:
|
|
456
|
+
#
|
|
457
|
+
# - FORWARD MIGRATION (legacy on earlier gates + NEW on terminal):
|
|
458
|
+
# ALLOWED with WARN. This is the documented dogfood-inversion pattern
|
|
459
|
+
# FEAT-2026-0015 uses on itself (gate 1 closed under the legacy 4-WU
|
|
460
|
+
# sequence; gate 2 ships + dogfoods the NEW close contract). Operators
|
|
461
|
+
# migrating an in-flight feature mid-stream land here naturally.
|
|
462
|
+
#
|
|
463
|
+
# - BACKWARD DRIFT (NEW on earlier gates + legacy on terminal): ERROR.
|
|
464
|
+
# The new contract is the canonical target; sliding back to legacy on
|
|
465
|
+
# the terminal gate after using NEW earlier is methodology drift the
|
|
466
|
+
# author owes a deliberate explanation for. Don't soft-fail it.
|
|
467
|
+
new_gnums = sorted(n for n, s in _gate_closing_shapes.items() if s == "NEW")
|
|
468
|
+
legacy_gnums = sorted(n for n, s in _gate_closing_shapes.items() if s == "LEGACY")
|
|
469
|
+
if new_gnums and legacy_gnums:
|
|
470
|
+
terminal_gnum = max(new_gnums + legacy_gnums)
|
|
471
|
+
if terminal_gnum in new_gnums:
|
|
472
|
+
# Forward migration: legacy earlier, NEW terminal.
|
|
473
|
+
print(
|
|
474
|
+
f"WARN: {feature_dir}: forward-mixed closing-shape contracts — "
|
|
475
|
+
f"gate(s) {legacy_gnums} use LEGACY 4-WU, terminal gate "
|
|
476
|
+
f"{terminal_gnum} uses NEW. This is allowed as a dogfood / "
|
|
477
|
+
f"migration pattern (see FEAT-2026-0015 LEARNINGS). Future "
|
|
478
|
+
f"features should consistently use NEW from the start."
|
|
479
|
+
)
|
|
480
|
+
else:
|
|
481
|
+
errs.append(
|
|
482
|
+
f"ERROR: {feature_dir}: backward-mixed closing-shape contracts — "
|
|
483
|
+
f"gate(s) {new_gnums} use NEW but terminal gate {terminal_gnum} "
|
|
484
|
+
f"uses LEGACY. The new contract is canonical; reverting to "
|
|
485
|
+
f"legacy on the terminal gate is methodology drift. Pick NEW "
|
|
486
|
+
f"on the terminal gate, or use LEGACY consistently."
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
return errs
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def lint_plan_next_draft(feature_dir: Path, just_closed_gate: int) -> list[str]:
|
|
493
|
+
"""Warn-only lint over draft WUs produced by the just-completed plan-next.
|
|
494
|
+
|
|
495
|
+
Walks gate (just_closed_gate+1) in PLAN.md and applies focused checks to
|
|
496
|
+
each WU with status=='draft'. Returns WARN strings; empty = clean.
|
|
497
|
+
Callers must not raise on non-empty return.
|
|
498
|
+
"""
|
|
499
|
+
warns: list[str] = []
|
|
500
|
+
plan = feature_dir / "PLAN.md"
|
|
501
|
+
if not plan.exists():
|
|
502
|
+
return warns
|
|
503
|
+
|
|
504
|
+
_, body = read_frontmatter(plan)
|
|
505
|
+
graph = _find_task_graph_block(body)
|
|
506
|
+
if graph is None:
|
|
507
|
+
return warns
|
|
508
|
+
|
|
509
|
+
gates = graph.get("gates", [])
|
|
510
|
+
|
|
511
|
+
next_gate_num = just_closed_gate + 1
|
|
512
|
+
next_gate = next((g for g in gates if g.get("gate") == next_gate_num), None)
|
|
513
|
+
if next_gate is None:
|
|
514
|
+
return warns # Terminal gate: no N+1 — clean
|
|
515
|
+
|
|
516
|
+
units = next_gate.get("work_units") or []
|
|
517
|
+
for ref in units:
|
|
518
|
+
wfile = ref.get("file")
|
|
519
|
+
if not wfile:
|
|
520
|
+
continue
|
|
521
|
+
wpath = feature_dir / wfile
|
|
522
|
+
if not wpath.exists():
|
|
523
|
+
continue
|
|
524
|
+
wfm, wbody = read_frontmatter(wpath)
|
|
525
|
+
if wfm.get("status") != "draft":
|
|
526
|
+
continue
|
|
527
|
+
|
|
528
|
+
wid = ref.get("id", wfile)
|
|
529
|
+
|
|
530
|
+
# Correlation-ID format check.
|
|
531
|
+
if not CORRELATION_ID_RE.match(wid):
|
|
532
|
+
warns.append(f"{wfile}: malformed correlation ID '{wid}'")
|
|
533
|
+
|
|
534
|
+
# planned_cost_usd: present and parses as a positive float.
|
|
535
|
+
planned = wfm.get("planned_cost_usd")
|
|
536
|
+
if planned is None:
|
|
537
|
+
warns.append(f"{wfile}: missing 'planned_cost_usd' frontmatter")
|
|
538
|
+
else:
|
|
539
|
+
try:
|
|
540
|
+
if float(planned) <= 0:
|
|
541
|
+
warns.append(
|
|
542
|
+
f"{wfile}: 'planned_cost_usd' must be a positive float, "
|
|
543
|
+
f"got {planned!r}"
|
|
544
|
+
)
|
|
545
|
+
except (TypeError, ValueError):
|
|
546
|
+
warns.append(
|
|
547
|
+
f"{wfile}: 'planned_cost_usd' is not a valid float: {planned!r}"
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# type must be in VALID_TYPES.
|
|
551
|
+
wu_type = wfm.get("type")
|
|
552
|
+
if wu_type not in VALID_TYPES:
|
|
553
|
+
warns.append(
|
|
554
|
+
f"{wfile}: invalid 'type' {wu_type!r} — must be one of "
|
|
555
|
+
f"{sorted(VALID_TYPES)}"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# Five mandatory sections: presence + non-empty content.
|
|
559
|
+
for sec in REQUIRED_SECTIONS:
|
|
560
|
+
if not re.search(rf"(?mi)^(?:#+\s*|\**){re.escape(sec)}", wbody):
|
|
561
|
+
warns.append(f"{wfile}: draft WU missing section '{sec}'")
|
|
562
|
+
continue
|
|
563
|
+
if not _slice_section(wbody, sec).strip():
|
|
564
|
+
warns.append(f"{wfile}: section '{sec}' is empty")
|
|
565
|
+
|
|
566
|
+
# Implementation + driver-wiring + empty produces_driver_helper → WARN.
|
|
567
|
+
if wu_type == "implementation":
|
|
568
|
+
wiring = detect_driver_wiring(wbody)
|
|
569
|
+
pdh = wfm.get("produces_driver_helper")
|
|
570
|
+
if wiring and not pdh:
|
|
571
|
+
warns.append(
|
|
572
|
+
f"{wfile}: implementation draft WU mentions driver wiring "
|
|
573
|
+
f"({wiring}) but 'produces_driver_helper' frontmatter is empty"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
return warns
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def main() -> int:
|
|
580
|
+
import argparse
|
|
581
|
+
parser = argparse.ArgumentParser(
|
|
582
|
+
description="Specfuse plan linter.",
|
|
583
|
+
usage="lint_plan.py <feature_dir> [--just-closed-gate N]",
|
|
584
|
+
)
|
|
585
|
+
parser.add_argument("feature_dir", type=Path)
|
|
586
|
+
parser.add_argument(
|
|
587
|
+
"--just-closed-gate",
|
|
588
|
+
type=int,
|
|
589
|
+
dest="just_closed_gate",
|
|
590
|
+
default=None,
|
|
591
|
+
metavar="N",
|
|
592
|
+
help="Also run plan-next-draft lint for gate N+1 draft WUs (warn-only).",
|
|
593
|
+
)
|
|
594
|
+
args = parser.parse_args()
|
|
595
|
+
feature_dir = args.feature_dir
|
|
596
|
+
errs = lint(feature_dir)
|
|
597
|
+
if errs:
|
|
598
|
+
print(f"FAIL — {len(errs)} issue(s) in {feature_dir}:")
|
|
599
|
+
for e in errs:
|
|
600
|
+
print(f" - {e}")
|
|
601
|
+
else:
|
|
602
|
+
print(f"OK — {feature_dir} is structurally valid.")
|
|
603
|
+
if args.just_closed_gate is not None:
|
|
604
|
+
_draft_warns = lint_plan_next_draft(feature_dir, args.just_closed_gate)
|
|
605
|
+
for _w in _draft_warns:
|
|
606
|
+
print(f"WARN (plan-next-draft lint): {_w}")
|
|
607
|
+
if _draft_warns:
|
|
608
|
+
print(
|
|
609
|
+
f"plan-next-draft lint: {len(_draft_warns)} warning(s) for gate "
|
|
610
|
+
f"{args.just_closed_gate + 1} draft WUs."
|
|
611
|
+
)
|
|
612
|
+
return 1 if errs else 0
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
if __name__ == "__main__":
|
|
616
|
+
raise SystemExit(main())
|