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.
@@ -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())