okstra 0.152.0 → 0.154.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/okstra +7 -0
- package/docs/cli.md +5 -1
- package/docs/for-ai/skills/okstra-schedule-gen.md +152 -232
- package/docs/project-structure-overview.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra-antigravity-exec.sh +11 -6
- package/runtime/bin/okstra-wrapper-agy-stream.py +61 -0
- package/runtime/prompts/lead/convergence.md +3 -2
- package/runtime/prompts/lead/plan-body-verification.md +30 -1
- package/runtime/python/okstra_ctl/container.py +9 -10
- package/runtime/python/okstra_ctl/convergence_engine.py +2 -1
- package/runtime/python/okstra_ctl/handoff.py +4 -8
- package/runtime/python/okstra_ctl/implementation_outcome.py +10 -56
- package/runtime/python/okstra_ctl/model_discovery.py +22 -1
- package/runtime/python/okstra_ctl/mutation_probe.py +425 -2
- package/runtime/python/okstra_ctl/plan_run_root.py +15 -8
- package/runtime/python/okstra_ctl/run.py +8 -54
- package/runtime/python/okstra_ctl/schedule_semantics.py +1249 -0
- package/runtime/python/okstra_ctl/stage_map.py +288 -0
- package/runtime/python/okstra_ctl/wizard.py +24 -35
- package/runtime/python/okstra_project/state.py +19 -5
- package/runtime/skills/okstra-schedule-gen/SKILL.md +75 -35
- package/runtime/templates/reports/schedule.template.md +9 -9
- package/runtime/validators/detect_self_mock.py +27 -2
- package/runtime/validators/validate-implementation-plan-stages.py +24 -63
- package/runtime/validators/validate-run.py +110 -0
- package/runtime/validators/validate-schedule.py +78 -10
- package/src/commands/inspect/stage-map.mjs +1 -1
|
@@ -0,0 +1,1249 @@
|
|
|
1
|
+
"""Selection-backed schedule effort, Work Breakdown, and Gantt validation."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .md_table import is_separator_row, split_pipe_row
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_ROOT_FIELDS = {"schemaVersion", "tasks"}
|
|
15
|
+
_TASK_FIELDS = {
|
|
16
|
+
"taskKey",
|
|
17
|
+
"taskId",
|
|
18
|
+
"state",
|
|
19
|
+
"sourcePlanPath",
|
|
20
|
+
"selectedStages",
|
|
21
|
+
"doneStages",
|
|
22
|
+
"stages",
|
|
23
|
+
}
|
|
24
|
+
_STAGE_FIELDS = {"stageNumber", "title", "dependsOn", "stepCount"}
|
|
25
|
+
_TASK_HEADING_RE = re.compile(
|
|
26
|
+
r"^###\s+(\d+-\d+)\.\s+(\S+)\s+—(?:\s+.*)?$", re.MULTILINE
|
|
27
|
+
)
|
|
28
|
+
_WORK_BREAKDOWN_HEADER = ["Stage", "Title", "Steps", "Depends On", "Days"]
|
|
29
|
+
_WORK_BREAKDOWN_SEPARATOR = ["---:", "---", "---:", "---", "---:"]
|
|
30
|
+
_AT_A_GLANCE_HEADER = [
|
|
31
|
+
"#", "Task ID", "Title", "Category", "Priority", "Effort", "Days",
|
|
32
|
+
"taskType", "Risk", "Phase",
|
|
33
|
+
]
|
|
34
|
+
_EFFORT_HEADER = ["Size", "Criteria", "Day(s)"]
|
|
35
|
+
_EFFORT_SIZES = {"S", "M", "L", "XL", "XXL"}
|
|
36
|
+
_DAY_NUMBER = r"\d+(?:\.\d+)?"
|
|
37
|
+
_DAY_RANGE_RE = re.compile(
|
|
38
|
+
rf"^\s*({_DAY_NUMBER})\s*(?:~|-)\s*({_DAY_NUMBER})\s*$"
|
|
39
|
+
)
|
|
40
|
+
_EFFORT_TOTAL_RE = re.compile(
|
|
41
|
+
rf"\*\*\d+\s+tasks\s+total\s*/\s*estimated\s+effort:\s*"
|
|
42
|
+
rf"({_DAY_NUMBER})\s*~\s*({_DAY_NUMBER})\s+days?\s*"
|
|
43
|
+
r"\(Effort\s+sum\)\*\*"
|
|
44
|
+
)
|
|
45
|
+
_PLAIN_FENCE_RE = re.compile(
|
|
46
|
+
r"^```[ \t]*$\n(.*?)^```[ \t]*$", re.MULTILINE | re.DOTALL
|
|
47
|
+
)
|
|
48
|
+
_GANTT_ROW_RE = re.compile(
|
|
49
|
+
rf"^\s*(\S+)/S(\d+)\s+.*\bdays=({_DAY_NUMBER})~({_DAY_NUMBER})\s*$"
|
|
50
|
+
)
|
|
51
|
+
_GANTT_STAGE_CANDIDATE_RE = re.compile(r"^\s*(\S+)/S(\d+)(?:\s|$)")
|
|
52
|
+
_XXL_DAY_RANGE_RE = re.compile(rf"^\s*{_DAY_NUMBER}\s*-\s*$")
|
|
53
|
+
_TASK_SUBSECTION_PREFIXES = (
|
|
54
|
+
"**Problem**:",
|
|
55
|
+
"**Solution**:",
|
|
56
|
+
"**Work Breakdown**:",
|
|
57
|
+
"**Verification Commands**:",
|
|
58
|
+
"**Rollback**:",
|
|
59
|
+
)
|
|
60
|
+
HALF_DAY = Decimal("0.5")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ScheduleSemanticError(ValueError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _round_half_day(value: Decimal) -> Decimal:
|
|
68
|
+
return (value / HALF_DAY).quantize(Decimal("1")) * HALF_DAY
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _half_day_units(value: Decimal) -> int:
|
|
72
|
+
units = value / HALF_DAY
|
|
73
|
+
if units != units.to_integral_value():
|
|
74
|
+
raise ScheduleSemanticError(
|
|
75
|
+
"effort: stage totals must use 0.5-day increments"
|
|
76
|
+
)
|
|
77
|
+
return int(units)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _validate_stage_day_allocation(
|
|
81
|
+
total: tuple[Decimal, Decimal], step_counts: tuple[int, ...],
|
|
82
|
+
allocated: list[tuple[Decimal, Decimal]],
|
|
83
|
+
) -> None:
|
|
84
|
+
invalid_range = any(
|
|
85
|
+
lower < 0 or upper < 0 or lower > upper
|
|
86
|
+
for lower, upper in allocated
|
|
87
|
+
)
|
|
88
|
+
lower_total = sum((lower for lower, _ in allocated), Decimal("0"))
|
|
89
|
+
upper_total = sum((upper for _, upper in allocated), Decimal("0"))
|
|
90
|
+
if invalid_range or (lower_total, upper_total) != total:
|
|
91
|
+
raise ScheduleSemanticError(
|
|
92
|
+
"effort: cannot represent proportional half-day allocation "
|
|
93
|
+
f"for step counts {step_counts} within {_format_day_range(total)}"
|
|
94
|
+
)
|
|
95
|
+
for lower, upper in allocated:
|
|
96
|
+
_half_day_units(lower)
|
|
97
|
+
_half_day_units(upper)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def allocate_stage_days(
|
|
101
|
+
total: tuple[Decimal, Decimal], step_counts: tuple[int, ...],
|
|
102
|
+
) -> tuple[tuple[Decimal, Decimal], ...]:
|
|
103
|
+
if not step_counts or any(count <= 0 for count in step_counts):
|
|
104
|
+
raise ValueError("selected stage step counts must be positive")
|
|
105
|
+
if total[0] < 0 or total[0] > total[1]:
|
|
106
|
+
raise ScheduleSemanticError(
|
|
107
|
+
"effort: stage total must satisfy 0 <= lower <= upper"
|
|
108
|
+
)
|
|
109
|
+
_half_day_units(total[0])
|
|
110
|
+
_half_day_units(total[1])
|
|
111
|
+
total_steps = Decimal(sum(step_counts))
|
|
112
|
+
allocated: list[tuple[Decimal, Decimal]] = []
|
|
113
|
+
used_lower = Decimal("0")
|
|
114
|
+
used_upper = Decimal("0")
|
|
115
|
+
for count in step_counts[:-1]:
|
|
116
|
+
ratio = Decimal(count) / total_steps
|
|
117
|
+
current = (
|
|
118
|
+
_round_half_day(total[0] * ratio),
|
|
119
|
+
_round_half_day(total[1] * ratio),
|
|
120
|
+
)
|
|
121
|
+
allocated.append(current)
|
|
122
|
+
used_lower += current[0]
|
|
123
|
+
used_upper += current[1]
|
|
124
|
+
allocated.append((total[0] - used_lower, total[1] - used_upper))
|
|
125
|
+
_validate_stage_day_allocation(total, step_counts, allocated)
|
|
126
|
+
return tuple(allocated)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class SelectionStage:
|
|
131
|
+
stage_number: int
|
|
132
|
+
title: str
|
|
133
|
+
depends_on: tuple[int, ...]
|
|
134
|
+
step_count: int
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class SelectionTask:
|
|
139
|
+
task_key: str
|
|
140
|
+
task_id: str
|
|
141
|
+
state: str
|
|
142
|
+
source_plan_path: str
|
|
143
|
+
selected_stages: tuple[int, ...]
|
|
144
|
+
done_stages: tuple[int, ...]
|
|
145
|
+
stages: tuple[SelectionStage, ...]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass(frozen=True)
|
|
149
|
+
class _BreakdownRow:
|
|
150
|
+
task_id: str
|
|
151
|
+
stage_number: int
|
|
152
|
+
title: str
|
|
153
|
+
step_count: int
|
|
154
|
+
depends_on: tuple[tuple[int, bool], ...]
|
|
155
|
+
days: tuple[Decimal, Decimal] | None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class _AtAGlanceRow:
|
|
160
|
+
task_id: str
|
|
161
|
+
effort: str
|
|
162
|
+
days_text: str
|
|
163
|
+
days: tuple[Decimal, Decimal] | None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class _GanttRow:
|
|
168
|
+
task_id: str
|
|
169
|
+
stage_number: int
|
|
170
|
+
days: tuple[Decimal, Decimal]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class StageMapSelectionError(ValueError):
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _require_object(value: Any, context: str) -> dict[str, Any]:
|
|
178
|
+
if not isinstance(value, dict):
|
|
179
|
+
raise StageMapSelectionError(f"{context} must be an object")
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _require_fields(
|
|
184
|
+
value: dict[str, Any], expected: set[str], context: str,
|
|
185
|
+
) -> None:
|
|
186
|
+
if set(value) == expected:
|
|
187
|
+
return
|
|
188
|
+
missing = sorted(expected - set(value))
|
|
189
|
+
extra = sorted(set(value) - expected)
|
|
190
|
+
raise StageMapSelectionError(
|
|
191
|
+
f"{context} fields mismatch: missing={missing}, extra={extra}"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _require_string(
|
|
196
|
+
value: dict[str, Any], field: str, context: str,
|
|
197
|
+
) -> str:
|
|
198
|
+
result = value.get(field)
|
|
199
|
+
if not isinstance(result, str):
|
|
200
|
+
raise StageMapSelectionError(f"{context}.{field} must be a string")
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _require_positive_int(value: Any, context: str) -> int:
|
|
205
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
|
206
|
+
raise StageMapSelectionError(f"{context} must be a positive integer")
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _require_int_list(
|
|
211
|
+
value: dict[str, Any], field: str, context: str,
|
|
212
|
+
) -> tuple[int, ...]:
|
|
213
|
+
items = value.get(field)
|
|
214
|
+
if not isinstance(items, list):
|
|
215
|
+
raise StageMapSelectionError(f"{context}.{field} must be an array")
|
|
216
|
+
result = tuple(
|
|
217
|
+
_require_positive_int(item, f"{context}.{field}[{index}]")
|
|
218
|
+
for index, item in enumerate(items)
|
|
219
|
+
)
|
|
220
|
+
if len(result) != len(set(result)):
|
|
221
|
+
raise StageMapSelectionError(f"{context}.{field} must be unique")
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_stage(value: Any, context: str) -> SelectionStage:
|
|
226
|
+
record = _require_object(value, context)
|
|
227
|
+
_require_fields(record, _STAGE_FIELDS, context)
|
|
228
|
+
title = _require_string(record, "title", context)
|
|
229
|
+
if not title:
|
|
230
|
+
raise StageMapSelectionError(f"{context}.title must not be empty")
|
|
231
|
+
return SelectionStage(
|
|
232
|
+
stage_number=_require_positive_int(
|
|
233
|
+
record.get("stageNumber"), f"{context}.stageNumber"
|
|
234
|
+
),
|
|
235
|
+
title=title,
|
|
236
|
+
depends_on=_require_int_list(record, "dependsOn", context),
|
|
237
|
+
step_count=_require_positive_int(
|
|
238
|
+
record.get("stepCount"), f"{context}.stepCount"
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _parse_stages(value: Any, context: str) -> tuple[SelectionStage, ...]:
|
|
244
|
+
if not isinstance(value, list):
|
|
245
|
+
raise StageMapSelectionError(f"{context}.stages must be an array")
|
|
246
|
+
stages = tuple(
|
|
247
|
+
_parse_stage(stage, f"{context}.stages[{index}]")
|
|
248
|
+
for index, stage in enumerate(value)
|
|
249
|
+
)
|
|
250
|
+
stage_numbers = [stage.stage_number for stage in stages]
|
|
251
|
+
if len(stage_numbers) != len(set(stage_numbers)):
|
|
252
|
+
raise StageMapSelectionError(f"{context}: duplicate stageNumber")
|
|
253
|
+
return stages
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _validate_stage_graph(
|
|
257
|
+
task_id: str,
|
|
258
|
+
stages_by_number: dict[int, SelectionStage],
|
|
259
|
+
) -> None:
|
|
260
|
+
for stage in stages_by_number.values():
|
|
261
|
+
for dependency in stage.depends_on:
|
|
262
|
+
if dependency not in stages_by_number:
|
|
263
|
+
raise StageMapSelectionError(
|
|
264
|
+
f"task {task_id} stage {stage.stage_number}: "
|
|
265
|
+
f"dependency {dependency} is absent from stages"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
completed: set[int] = set()
|
|
269
|
+
current_path: set[int] = set()
|
|
270
|
+
path: list[int] = []
|
|
271
|
+
|
|
272
|
+
def visit(stage_number: int) -> None:
|
|
273
|
+
if stage_number in completed:
|
|
274
|
+
return
|
|
275
|
+
if stage_number in current_path:
|
|
276
|
+
cycle_start = path.index(stage_number)
|
|
277
|
+
cycle = path[cycle_start:] + [stage_number]
|
|
278
|
+
rendered = " -> ".join(f"S{item}" for item in cycle)
|
|
279
|
+
raise ScheduleSemanticError(
|
|
280
|
+
f"task {task_id}: stage dependency cycle detected: {rendered}"
|
|
281
|
+
)
|
|
282
|
+
current_path.add(stage_number)
|
|
283
|
+
path.append(stage_number)
|
|
284
|
+
for dependency in stages_by_number[stage_number].depends_on:
|
|
285
|
+
visit(dependency)
|
|
286
|
+
path.pop()
|
|
287
|
+
current_path.remove(stage_number)
|
|
288
|
+
completed.add(stage_number)
|
|
289
|
+
|
|
290
|
+
for stage_number in stages_by_number:
|
|
291
|
+
visit(stage_number)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _validate_task_selection(task: SelectionTask) -> None:
|
|
295
|
+
selected = set(task.selected_stages)
|
|
296
|
+
done = set(task.done_stages)
|
|
297
|
+
overlap = sorted(selected & done)
|
|
298
|
+
if overlap:
|
|
299
|
+
raise StageMapSelectionError(
|
|
300
|
+
f"task {task.task_id}: selected/done stage overlap {overlap}"
|
|
301
|
+
)
|
|
302
|
+
stages_by_number = {stage.stage_number: stage for stage in task.stages}
|
|
303
|
+
for stage_number in task.selected_stages:
|
|
304
|
+
if stage_number not in stages_by_number:
|
|
305
|
+
raise StageMapSelectionError(
|
|
306
|
+
f"task {task.task_id}: selected stage {stage_number} is absent"
|
|
307
|
+
)
|
|
308
|
+
_validate_stage_graph(task.task_id, stages_by_number)
|
|
309
|
+
allowed = selected | done
|
|
310
|
+
pending = list(task.selected_stages)
|
|
311
|
+
visited: set[int] = set()
|
|
312
|
+
while pending:
|
|
313
|
+
stage_number = pending.pop()
|
|
314
|
+
if stage_number in visited:
|
|
315
|
+
continue
|
|
316
|
+
visited.add(stage_number)
|
|
317
|
+
for dependency in stages_by_number[stage_number].depends_on:
|
|
318
|
+
if dependency not in allowed:
|
|
319
|
+
raise StageMapSelectionError(
|
|
320
|
+
f"task {task.task_id} stage {stage_number}: "
|
|
321
|
+
f"dependency {dependency} is outside doneStages or selectedStages"
|
|
322
|
+
)
|
|
323
|
+
pending.append(dependency)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _validate_task_state(task: SelectionTask) -> None:
|
|
327
|
+
if task.state != "missing":
|
|
328
|
+
return
|
|
329
|
+
if any((
|
|
330
|
+
task.source_plan_path,
|
|
331
|
+
task.selected_stages,
|
|
332
|
+
task.done_stages,
|
|
333
|
+
task.stages,
|
|
334
|
+
)):
|
|
335
|
+
raise StageMapSelectionError(
|
|
336
|
+
f"task {task.task_id}: missing task must have empty sourcePlanPath, "
|
|
337
|
+
"selectedStages, doneStages, and stages"
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _parse_task(value: Any, index: int) -> SelectionTask:
|
|
342
|
+
context = f"tasks[{index}]"
|
|
343
|
+
record = _require_object(value, context)
|
|
344
|
+
_require_fields(record, _TASK_FIELDS, context)
|
|
345
|
+
state = _require_string(record, "state", context)
|
|
346
|
+
if state not in {"ready", "missing"}:
|
|
347
|
+
raise StageMapSelectionError(
|
|
348
|
+
f"{context}.state must be 'ready' or 'missing'"
|
|
349
|
+
)
|
|
350
|
+
task = SelectionTask(
|
|
351
|
+
task_key=_require_string(record, "taskKey", context),
|
|
352
|
+
task_id=_require_string(record, "taskId", context),
|
|
353
|
+
state=state,
|
|
354
|
+
source_plan_path=_require_string(record, "sourcePlanPath", context),
|
|
355
|
+
selected_stages=_require_int_list(record, "selectedStages", context),
|
|
356
|
+
done_stages=_require_int_list(record, "doneStages", context),
|
|
357
|
+
stages=_parse_stages(record.get("stages"), context),
|
|
358
|
+
)
|
|
359
|
+
if not task.task_key or not task.task_id:
|
|
360
|
+
raise StageMapSelectionError(f"{context} taskKey/taskId must not be empty")
|
|
361
|
+
_validate_task_state(task)
|
|
362
|
+
_validate_task_selection(task)
|
|
363
|
+
return task
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _reject_duplicate_task_identities(tasks: tuple[SelectionTask, ...]) -> None:
|
|
367
|
+
for field in ("task_key", "task_id"):
|
|
368
|
+
values = [getattr(task, field) for task in tasks]
|
|
369
|
+
duplicates = {value for value in values if values.count(value) > 1}
|
|
370
|
+
if duplicates:
|
|
371
|
+
label = "taskKey" if field == "task_key" else "taskId"
|
|
372
|
+
raise StageMapSelectionError(
|
|
373
|
+
f"duplicate {label}: {sorted(duplicates)[0]}"
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _strict_json_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
378
|
+
result: dict[str, Any] = {}
|
|
379
|
+
for key, value in pairs:
|
|
380
|
+
if key in result:
|
|
381
|
+
raise StageMapSelectionError(f"duplicate JSON key {key!r}")
|
|
382
|
+
result[key] = value
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def load_schedule_selection(path: Path) -> tuple[SelectionTask, ...]:
|
|
387
|
+
payload = json.loads(
|
|
388
|
+
path.read_text(encoding="utf-8"),
|
|
389
|
+
object_pairs_hook=_strict_json_object,
|
|
390
|
+
)
|
|
391
|
+
root = _require_object(payload, "selection")
|
|
392
|
+
_require_fields(root, _ROOT_FIELDS, "selection")
|
|
393
|
+
version = root.get("schemaVersion")
|
|
394
|
+
if type(version) is not int or version != 1:
|
|
395
|
+
raise StageMapSelectionError("selection.schemaVersion must be exactly 1")
|
|
396
|
+
values = root.get("tasks")
|
|
397
|
+
if not isinstance(values, list):
|
|
398
|
+
raise StageMapSelectionError("selection.tasks must be an array")
|
|
399
|
+
tasks = tuple(_parse_task(value, index) for index, value in enumerate(values))
|
|
400
|
+
_reject_duplicate_task_identities(tasks)
|
|
401
|
+
return tasks
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _parse_dependencies(
|
|
405
|
+
value: str, task_id: str, stage_number: int,
|
|
406
|
+
) -> tuple[tuple[tuple[int, bool], ...], str | None]:
|
|
407
|
+
if value == "—":
|
|
408
|
+
return (), None
|
|
409
|
+
dependencies: list[tuple[int, bool]] = []
|
|
410
|
+
for token in value.split(","):
|
|
411
|
+
normalized = token.strip()
|
|
412
|
+
match = re.fullmatch(r"(\d+)( \(done\))?", normalized)
|
|
413
|
+
if match is None:
|
|
414
|
+
return (), (
|
|
415
|
+
f"work breakdown: stage {task_id}/S{stage_number} has invalid "
|
|
416
|
+
f"Depends On value {value!r}"
|
|
417
|
+
)
|
|
418
|
+
dependencies.append((int(match.group(1)), match.group(2) is not None))
|
|
419
|
+
return tuple(dependencies), None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _mask_fenced_lines(lines: list[str]) -> list[str | None]:
|
|
423
|
+
masked: list[str | None] = []
|
|
424
|
+
fence_marker = ""
|
|
425
|
+
for line in lines:
|
|
426
|
+
stripped = line.lstrip()
|
|
427
|
+
marker = stripped[:3] if stripped.startswith(("```", "~~~")) else ""
|
|
428
|
+
if fence_marker:
|
|
429
|
+
masked.append(None)
|
|
430
|
+
if marker == fence_marker:
|
|
431
|
+
fence_marker = ""
|
|
432
|
+
elif marker:
|
|
433
|
+
fence_marker = marker
|
|
434
|
+
masked.append(None)
|
|
435
|
+
else:
|
|
436
|
+
masked.append(line)
|
|
437
|
+
return masked
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _mask_fenced_text(text: str) -> str:
|
|
441
|
+
lines = text.splitlines(keepends=True)
|
|
442
|
+
visible_lines = _mask_fenced_lines(lines)
|
|
443
|
+
return "".join(
|
|
444
|
+
line if visible is not None else re.sub(r"[^\r\n]", " ", line)
|
|
445
|
+
for line, visible in zip(lines, visible_lines)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _section_bounds(
|
|
450
|
+
text: str, heading: str, max_level: int,
|
|
451
|
+
) -> tuple[int, int] | None:
|
|
452
|
+
visible_text = _mask_fenced_text(text)
|
|
453
|
+
match = re.search(
|
|
454
|
+
rf"^{re.escape(heading)}[ \t]*$", visible_text, re.MULTILINE
|
|
455
|
+
)
|
|
456
|
+
if match is None:
|
|
457
|
+
return None
|
|
458
|
+
body_start = match.end()
|
|
459
|
+
body = visible_text[body_start:]
|
|
460
|
+
next_heading = re.search(
|
|
461
|
+
rf"^#{{1,{max_level}}}\s", body, re.MULTILINE
|
|
462
|
+
)
|
|
463
|
+
body_end = (
|
|
464
|
+
body_start + next_heading.start() if next_heading else len(text)
|
|
465
|
+
)
|
|
466
|
+
return body_start, body_end
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _section_body(text: str, heading: str, max_level: int) -> str:
|
|
470
|
+
bounds = _section_bounds(text, heading, max_level)
|
|
471
|
+
if bounds is None:
|
|
472
|
+
return ""
|
|
473
|
+
return _mask_fenced_text(text[bounds[0]:bounds[1]])
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _raw_section_body(text: str, heading: str, max_level: int) -> str | None:
|
|
477
|
+
bounds = _section_bounds(text, heading, max_level)
|
|
478
|
+
if bounds is None:
|
|
479
|
+
return None
|
|
480
|
+
return text[bounds[0]:bounds[1]]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _ordered_day_range(
|
|
484
|
+
lower: Decimal, upper: Decimal, source: str,
|
|
485
|
+
) -> tuple[Decimal, Decimal]:
|
|
486
|
+
if lower > upper:
|
|
487
|
+
raise ScheduleSemanticError(
|
|
488
|
+
f"{source}: lower must not exceed upper"
|
|
489
|
+
)
|
|
490
|
+
return lower, upper
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _parse_day_range(
|
|
494
|
+
value: str, source: str,
|
|
495
|
+
) -> tuple[Decimal, Decimal] | None:
|
|
496
|
+
match = _DAY_RANGE_RE.fullmatch(value)
|
|
497
|
+
if match is None:
|
|
498
|
+
return None
|
|
499
|
+
return _ordered_day_range(
|
|
500
|
+
Decimal(match.group(1)), Decimal(match.group(2)), source
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def parse_effort_ranges(
|
|
505
|
+
text: str,
|
|
506
|
+
) -> dict[str, tuple[Decimal, Decimal] | None]:
|
|
507
|
+
section = _section_body(text, "### Effort Sizing Criteria", 3)
|
|
508
|
+
lines = section.splitlines()
|
|
509
|
+
header_indexes = [
|
|
510
|
+
index for index, line in enumerate(lines)
|
|
511
|
+
if split_pipe_row(line) == _EFFORT_HEADER
|
|
512
|
+
]
|
|
513
|
+
if len(header_indexes) != 1:
|
|
514
|
+
raise ScheduleSemanticError(
|
|
515
|
+
"Effort Sizing Criteria requires exactly one canonical table"
|
|
516
|
+
)
|
|
517
|
+
header_index = header_indexes[0]
|
|
518
|
+
separator_index = header_index + 1
|
|
519
|
+
if (
|
|
520
|
+
separator_index >= len(lines)
|
|
521
|
+
or not is_separator_row(lines[separator_index])
|
|
522
|
+
or len(split_pipe_row(lines[separator_index])) != len(_EFFORT_HEADER)
|
|
523
|
+
):
|
|
524
|
+
raise ScheduleSemanticError(
|
|
525
|
+
"Effort Sizing Criteria requires a canonical separator row"
|
|
526
|
+
)
|
|
527
|
+
ranges: dict[str, tuple[Decimal, Decimal] | None] = {}
|
|
528
|
+
for line in lines[header_index + 2:]:
|
|
529
|
+
if not line.strip().startswith("|"):
|
|
530
|
+
break
|
|
531
|
+
cells = split_pipe_row(line)
|
|
532
|
+
size = cells[0].strip().strip("*") if cells else ""
|
|
533
|
+
if len(cells) != 3 or size not in _EFFORT_SIZES:
|
|
534
|
+
raise ScheduleSemanticError(
|
|
535
|
+
f"Effort Sizing Criteria has malformed row {line!r}"
|
|
536
|
+
)
|
|
537
|
+
if size in ranges:
|
|
538
|
+
raise ScheduleSemanticError(
|
|
539
|
+
f"Effort Sizing Criteria has duplicate {size} row"
|
|
540
|
+
)
|
|
541
|
+
day_range = _parse_day_range(
|
|
542
|
+
cells[2], f"Effort Sizing Criteria {size}"
|
|
543
|
+
)
|
|
544
|
+
if size == "XXL":
|
|
545
|
+
if day_range is not None or _XXL_DAY_RANGE_RE.fullmatch(cells[2]) is None:
|
|
546
|
+
raise ScheduleSemanticError(
|
|
547
|
+
"Effort Sizing Criteria XXL has invalid Day(s)"
|
|
548
|
+
)
|
|
549
|
+
ranges[size] = None
|
|
550
|
+
continue
|
|
551
|
+
if day_range is None:
|
|
552
|
+
raise ScheduleSemanticError(
|
|
553
|
+
f"Effort Sizing Criteria {size} has invalid Day(s)"
|
|
554
|
+
)
|
|
555
|
+
ranges[size] = day_range
|
|
556
|
+
missing = sorted(_EFFORT_SIZES - set(ranges))
|
|
557
|
+
if missing:
|
|
558
|
+
raise ScheduleSemanticError(
|
|
559
|
+
"Effort Sizing Criteria is missing size row(s): "
|
|
560
|
+
+ ", ".join(missing)
|
|
561
|
+
)
|
|
562
|
+
return ranges
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _parse_at_a_glance_table(
|
|
566
|
+
lines: list[str], header_index: int,
|
|
567
|
+
) -> tuple[list[_AtAGlanceRow], list[str]]:
|
|
568
|
+
rows: list[_AtAGlanceRow] = []
|
|
569
|
+
violations: list[str] = []
|
|
570
|
+
for line in lines[header_index + 2:]:
|
|
571
|
+
if not line.strip().startswith("|"):
|
|
572
|
+
break
|
|
573
|
+
cells = split_pipe_row(line)
|
|
574
|
+
if cells == _AT_A_GLANCE_HEADER:
|
|
575
|
+
break
|
|
576
|
+
if len(cells) != len(_AT_A_GLANCE_HEADER):
|
|
577
|
+
task_id = cells[1] if len(cells) > 1 else "<unknown>"
|
|
578
|
+
violations.append(
|
|
579
|
+
f"At a Glance: noncanonical row for task {task_id} "
|
|
580
|
+
"requires 10 columns"
|
|
581
|
+
)
|
|
582
|
+
continue
|
|
583
|
+
effort_tokens = cells[5].split()
|
|
584
|
+
rows.append(_AtAGlanceRow(
|
|
585
|
+
task_id=cells[1],
|
|
586
|
+
effort=effort_tokens[0].strip("*") if effort_tokens else "",
|
|
587
|
+
days_text=cells[6],
|
|
588
|
+
days=_parse_day_range(
|
|
589
|
+
cells[6], f"At a Glance {cells[1]}"
|
|
590
|
+
),
|
|
591
|
+
))
|
|
592
|
+
return rows, violations
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _parse_at_a_glance_rows(
|
|
596
|
+
text: str,
|
|
597
|
+
) -> tuple[list[_AtAGlanceRow], list[str]]:
|
|
598
|
+
section = _section_body(text, "## At a Glance", 2)
|
|
599
|
+
lines = section.splitlines()
|
|
600
|
+
header_indexes = [
|
|
601
|
+
index for index, line in enumerate(lines)
|
|
602
|
+
if split_pipe_row(line) == _AT_A_GLANCE_HEADER
|
|
603
|
+
]
|
|
604
|
+
if not header_indexes:
|
|
605
|
+
return [], ["At a Glance: missing canonical 10-column table"]
|
|
606
|
+
rows: list[_AtAGlanceRow] = []
|
|
607
|
+
violations: list[str] = []
|
|
608
|
+
for header_index in header_indexes:
|
|
609
|
+
table_rows, table_violations = _parse_at_a_glance_table(
|
|
610
|
+
lines, header_index
|
|
611
|
+
)
|
|
612
|
+
rows.extend(table_rows)
|
|
613
|
+
violations.extend(table_violations)
|
|
614
|
+
return rows, violations
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _validated_at_a_glance_rows(
|
|
618
|
+
text: str, tasks: tuple[SelectionTask, ...],
|
|
619
|
+
) -> tuple[dict[str, _AtAGlanceRow], list[str]]:
|
|
620
|
+
rows, violations = _parse_at_a_glance_rows(text)
|
|
621
|
+
indexed: dict[str, list[_AtAGlanceRow]] = {}
|
|
622
|
+
for row in rows:
|
|
623
|
+
indexed.setdefault(row.task_id, []).append(row)
|
|
624
|
+
known_task_ids = {task.task_id for task in tasks}
|
|
625
|
+
for task_id, matches in indexed.items():
|
|
626
|
+
if len(matches) > 1:
|
|
627
|
+
violations.append(f"At a Glance: duplicate Task ID {task_id}")
|
|
628
|
+
if task_id not in known_task_ids:
|
|
629
|
+
violations.append(f"At a Glance: unknown Task ID {task_id}")
|
|
630
|
+
for task in tasks:
|
|
631
|
+
if len(indexed.get(task.task_id, [])) != 1:
|
|
632
|
+
violations.append(
|
|
633
|
+
f"At a Glance: task {task.task_id} "
|
|
634
|
+
"requires exactly one canonical row"
|
|
635
|
+
)
|
|
636
|
+
validated = {
|
|
637
|
+
task_id: matches[0]
|
|
638
|
+
for task_id, matches in indexed.items()
|
|
639
|
+
if task_id in known_task_ids and len(matches) == 1
|
|
640
|
+
}
|
|
641
|
+
return validated, violations
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _format_day_range(day_range: tuple[Decimal, Decimal]) -> str:
|
|
645
|
+
return f"{day_range[0]:.1f} ~ {day_range[1]:.1f}"
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _sum_day_ranges(
|
|
649
|
+
ranges: list[tuple[Decimal, Decimal]],
|
|
650
|
+
) -> tuple[Decimal, Decimal]:
|
|
651
|
+
return (
|
|
652
|
+
sum((day_range[0] for day_range in ranges), Decimal("0")),
|
|
653
|
+
sum((day_range[1] for day_range in ranges), Decimal("0")),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _validate_at_a_glance_effort(
|
|
658
|
+
effort_ranges: dict[str, tuple[Decimal, Decimal] | None],
|
|
659
|
+
tasks: tuple[SelectionTask, ...],
|
|
660
|
+
glance_rows: dict[str, _AtAGlanceRow],
|
|
661
|
+
) -> list[str]:
|
|
662
|
+
missing_task_ids = {
|
|
663
|
+
task.task_id for task in tasks if task.state == "missing"
|
|
664
|
+
}
|
|
665
|
+
violations: list[str] = []
|
|
666
|
+
for row in glance_rows.values():
|
|
667
|
+
needs_planning = (
|
|
668
|
+
row.effort == "XXL" or row.task_id in missing_task_ids
|
|
669
|
+
)
|
|
670
|
+
if needs_planning:
|
|
671
|
+
if row.days_text != "[NEEDS-PLANNING]":
|
|
672
|
+
violations.append(
|
|
673
|
+
f"effort: {row.task_id} effort {row.effort} "
|
|
674
|
+
"requires [NEEDS-PLANNING]"
|
|
675
|
+
)
|
|
676
|
+
continue
|
|
677
|
+
if row.effort not in effort_ranges:
|
|
678
|
+
violations.append(
|
|
679
|
+
f"effort: {row.task_id} effort {row.effort} is absent from "
|
|
680
|
+
"Effort Sizing Criteria"
|
|
681
|
+
)
|
|
682
|
+
continue
|
|
683
|
+
expected = effort_ranges[row.effort]
|
|
684
|
+
if expected is None or row.days == expected:
|
|
685
|
+
continue
|
|
686
|
+
violations.append(
|
|
687
|
+
f"effort: {row.task_id} effort {row.effort} requires "
|
|
688
|
+
f"{_format_day_range(expected)} days"
|
|
689
|
+
)
|
|
690
|
+
return violations
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def _work_breakdown_lines(block: str) -> list[str | None] | None:
|
|
694
|
+
lines = _mask_fenced_lines(block.splitlines())
|
|
695
|
+
label_index = next(
|
|
696
|
+
(
|
|
697
|
+
index for index, line in enumerate(lines)
|
|
698
|
+
if line == "**Work Breakdown**:"
|
|
699
|
+
),
|
|
700
|
+
None,
|
|
701
|
+
)
|
|
702
|
+
if label_index is None:
|
|
703
|
+
return None
|
|
704
|
+
end = next(
|
|
705
|
+
(
|
|
706
|
+
index for index in range(label_index + 1, len(lines))
|
|
707
|
+
if lines[index] is not None
|
|
708
|
+
and lines[index].startswith(_TASK_SUBSECTION_PREFIXES)
|
|
709
|
+
),
|
|
710
|
+
len(lines),
|
|
711
|
+
)
|
|
712
|
+
return lines[label_index + 1:end]
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _parse_breakdown_table(
|
|
716
|
+
task_id: str, block: str,
|
|
717
|
+
) -> tuple[list[_BreakdownRow], list[str]]:
|
|
718
|
+
lines = _work_breakdown_lines(block)
|
|
719
|
+
if lines is None:
|
|
720
|
+
return [], []
|
|
721
|
+
violations: list[str] = []
|
|
722
|
+
header_indices = [
|
|
723
|
+
index for index, line in enumerate(lines)
|
|
724
|
+
if line is not None and split_pipe_row(line) == _WORK_BREAKDOWN_HEADER
|
|
725
|
+
]
|
|
726
|
+
if len(header_indices) != 1:
|
|
727
|
+
violations.append(
|
|
728
|
+
f"work breakdown: task {task_id} must contain exactly one canonical header"
|
|
729
|
+
)
|
|
730
|
+
return [], violations
|
|
731
|
+
header_index = header_indices[0]
|
|
732
|
+
if (
|
|
733
|
+
header_index + 1 >= len(lines)
|
|
734
|
+
or lines[header_index + 1] is None
|
|
735
|
+
or split_pipe_row(lines[header_index + 1]) != _WORK_BREAKDOWN_SEPARATOR
|
|
736
|
+
):
|
|
737
|
+
violations.append(
|
|
738
|
+
f"work breakdown: task {task_id} must use the canonical separator"
|
|
739
|
+
)
|
|
740
|
+
return [], violations
|
|
741
|
+
rows: list[_BreakdownRow] = []
|
|
742
|
+
for line in lines[header_index + 2:]:
|
|
743
|
+
if line is None or not line.strip().startswith("|"):
|
|
744
|
+
break
|
|
745
|
+
cells = split_pipe_row(line)
|
|
746
|
+
if len(cells) != 5 or not cells[0].isdigit() or not cells[2].isdigit():
|
|
747
|
+
violations.append(
|
|
748
|
+
f"work breakdown: malformed stage row for task {task_id}: {line!r}"
|
|
749
|
+
)
|
|
750
|
+
continue
|
|
751
|
+
stage_number = int(cells[0])
|
|
752
|
+
depends_on, error = _parse_dependencies(cells[3], task_id, stage_number)
|
|
753
|
+
if error:
|
|
754
|
+
violations.append(error)
|
|
755
|
+
rows.append(_BreakdownRow(
|
|
756
|
+
task_id=task_id,
|
|
757
|
+
stage_number=stage_number,
|
|
758
|
+
title=cells[1],
|
|
759
|
+
step_count=int(cells[2]),
|
|
760
|
+
depends_on=depends_on,
|
|
761
|
+
days=_parse_day_range(
|
|
762
|
+
cells[4], f"work breakdown {task_id}/S{stage_number}"
|
|
763
|
+
),
|
|
764
|
+
))
|
|
765
|
+
return rows, violations
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _duplicate_heading_violations(
|
|
769
|
+
headings: list[re.Match[str]],
|
|
770
|
+
) -> list[str]:
|
|
771
|
+
violations: list[str] = []
|
|
772
|
+
for group, label in ((1, "phase-index"), (2, "task")):
|
|
773
|
+
values = [heading.group(group) for heading in headings]
|
|
774
|
+
duplicates = sorted({value for value in values if values.count(value) > 1})
|
|
775
|
+
for value in duplicates:
|
|
776
|
+
violations.append(
|
|
777
|
+
f"work breakdown: duplicate {label} heading {value}"
|
|
778
|
+
)
|
|
779
|
+
return violations
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _task_section_blocks(
|
|
783
|
+
text: str,
|
|
784
|
+
) -> tuple[list[tuple[str, str]], list[str]]:
|
|
785
|
+
visible_text = _mask_fenced_text(text)
|
|
786
|
+
headings = list(_TASK_HEADING_RE.finditer(visible_text))
|
|
787
|
+
blocks: list[tuple[str, str]] = []
|
|
788
|
+
for index, heading in enumerate(headings):
|
|
789
|
+
end = (
|
|
790
|
+
headings[index + 1].start()
|
|
791
|
+
if index + 1 < len(headings)
|
|
792
|
+
else len(visible_text)
|
|
793
|
+
)
|
|
794
|
+
next_section = re.search(
|
|
795
|
+
r"^##\s", visible_text[heading.end():end], re.MULTILINE
|
|
796
|
+
)
|
|
797
|
+
if next_section is not None:
|
|
798
|
+
end = heading.end() + next_section.start()
|
|
799
|
+
blocks.append((heading.group(2), visible_text[heading.end():end]))
|
|
800
|
+
return blocks, _duplicate_heading_violations(headings)
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def _validate_nonforward_task_section(
|
|
804
|
+
task: SelectionTask, block: str,
|
|
805
|
+
) -> list[str]:
|
|
806
|
+
if task.selected_stages:
|
|
807
|
+
return []
|
|
808
|
+
marker = (
|
|
809
|
+
"[NEEDS-PLANNING]"
|
|
810
|
+
if task.state == "missing"
|
|
811
|
+
else "_Complete — no remaining stage_"
|
|
812
|
+
)
|
|
813
|
+
marker_count = sum(line.strip() == marker for line in block.splitlines())
|
|
814
|
+
violations: list[str] = []
|
|
815
|
+
if marker_count != 1:
|
|
816
|
+
violations.append(
|
|
817
|
+
f"task section: task {task.task_id} requires exact marker {marker}"
|
|
818
|
+
)
|
|
819
|
+
if "**Work Breakdown**:" in block:
|
|
820
|
+
violations.append(
|
|
821
|
+
f"task section: task {task.task_id} with no selected stages "
|
|
822
|
+
"must not contain Work Breakdown"
|
|
823
|
+
)
|
|
824
|
+
return violations
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _validate_task_section_coverage(
|
|
828
|
+
blocks: list[tuple[str, str]], tasks: tuple[SelectionTask, ...],
|
|
829
|
+
) -> list[str]:
|
|
830
|
+
indexed: dict[str, list[str]] = {}
|
|
831
|
+
for task_id, block in blocks:
|
|
832
|
+
indexed.setdefault(task_id, []).append(block)
|
|
833
|
+
known = {task.task_id for task in tasks}
|
|
834
|
+
violations: list[str] = []
|
|
835
|
+
for task in tasks:
|
|
836
|
+
matches = indexed.get(task.task_id, [])
|
|
837
|
+
if len(matches) != 1:
|
|
838
|
+
violations.append(
|
|
839
|
+
f"task section: task {task.task_id} requires exactly one task section"
|
|
840
|
+
)
|
|
841
|
+
continue
|
|
842
|
+
violations.extend(_validate_nonforward_task_section(task, matches[0]))
|
|
843
|
+
for task_id in sorted(set(indexed) - known):
|
|
844
|
+
violations.append(f"task section: unknown task {task_id}")
|
|
845
|
+
return violations
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _extract_breakdown_rows(
|
|
849
|
+
text: str,
|
|
850
|
+
) -> tuple[list[_BreakdownRow], list[str], list[tuple[str, str]]]:
|
|
851
|
+
blocks, violations = _task_section_blocks(text)
|
|
852
|
+
if violations:
|
|
853
|
+
return [], violations, blocks
|
|
854
|
+
rows: list[_BreakdownRow] = []
|
|
855
|
+
for task_id, block in blocks:
|
|
856
|
+
parsed, errors = _parse_breakdown_table(
|
|
857
|
+
task_id, block
|
|
858
|
+
)
|
|
859
|
+
rows.extend(parsed)
|
|
860
|
+
violations.extend(errors)
|
|
861
|
+
return rows, violations, blocks
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _validate_row_fields(
|
|
865
|
+
row: _BreakdownRow, task: SelectionTask, stage: SelectionStage,
|
|
866
|
+
) -> list[str]:
|
|
867
|
+
prefix = f"work breakdown: stage {task.task_id}/S{stage.stage_number}"
|
|
868
|
+
violations: list[str] = []
|
|
869
|
+
if row.title != stage.title:
|
|
870
|
+
violations.append(
|
|
871
|
+
f"{prefix} Title {row.title!r} does not match {stage.title!r}"
|
|
872
|
+
)
|
|
873
|
+
if row.step_count != stage.step_count:
|
|
874
|
+
violations.append(
|
|
875
|
+
f"{prefix} Steps {row.step_count} does not match {stage.step_count}"
|
|
876
|
+
)
|
|
877
|
+
done = set(task.done_stages)
|
|
878
|
+
expected_dependencies: list[tuple[int, bool]] = []
|
|
879
|
+
for dependency in stage.depends_on:
|
|
880
|
+
expected_dependencies.append((dependency, dependency in done))
|
|
881
|
+
expected = tuple(expected_dependencies)
|
|
882
|
+
if row.depends_on != expected:
|
|
883
|
+
violations.append(
|
|
884
|
+
f"{prefix} Depends On {row.depends_on!r} does not match {expected!r}"
|
|
885
|
+
)
|
|
886
|
+
return violations
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _validate_task_rows(
|
|
890
|
+
task: SelectionTask,
|
|
891
|
+
indexed: dict[tuple[str, int], list[tuple[int, _BreakdownRow]]],
|
|
892
|
+
) -> list[str]:
|
|
893
|
+
violations: list[str] = []
|
|
894
|
+
stages = {stage.stage_number: stage for stage in task.stages}
|
|
895
|
+
selected = set(task.selected_stages)
|
|
896
|
+
done = set(task.done_stages)
|
|
897
|
+
for stage_number in task.selected_stages:
|
|
898
|
+
entries = indexed.get((task.task_id, stage_number), [])
|
|
899
|
+
if not entries:
|
|
900
|
+
violations.append(
|
|
901
|
+
f"work breakdown: missing selected stage {task.task_id}/S{stage_number}"
|
|
902
|
+
)
|
|
903
|
+
elif len(entries) > 1:
|
|
904
|
+
violations.append(
|
|
905
|
+
f"work breakdown: duplicate stage {task.task_id}/S{stage_number}"
|
|
906
|
+
)
|
|
907
|
+
for _, row in entries:
|
|
908
|
+
violations.extend(_validate_row_fields(row, task, stages[stage_number]))
|
|
909
|
+
for stage_number in done:
|
|
910
|
+
if indexed.get((task.task_id, stage_number)):
|
|
911
|
+
violations.append(
|
|
912
|
+
f"work breakdown: done stage {task.task_id}/S{stage_number} must be excluded"
|
|
913
|
+
)
|
|
914
|
+
task_rows = {
|
|
915
|
+
stage_number for task_id, stage_number in indexed if task_id == task.task_id
|
|
916
|
+
}
|
|
917
|
+
for stage_number in sorted(task_rows - selected - done):
|
|
918
|
+
violations.append(
|
|
919
|
+
f"work breakdown: stage {task.task_id}/S{stage_number} is not selected"
|
|
920
|
+
)
|
|
921
|
+
return violations
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def _validate_extra_rows(
|
|
925
|
+
tasks: tuple[SelectionTask, ...],
|
|
926
|
+
indexed: dict[tuple[str, int], list[tuple[int, _BreakdownRow]]],
|
|
927
|
+
) -> list[str]:
|
|
928
|
+
known_task_ids = {task.task_id for task in tasks}
|
|
929
|
+
violations: list[str] = []
|
|
930
|
+
for task_id, stage_number in sorted(indexed):
|
|
931
|
+
if task_id not in known_task_ids:
|
|
932
|
+
violations.append(
|
|
933
|
+
f"work breakdown: stage {task_id}/S{stage_number} is not selected"
|
|
934
|
+
)
|
|
935
|
+
return violations
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _validate_topological_order(
|
|
939
|
+
tasks: tuple[SelectionTask, ...],
|
|
940
|
+
indexed: dict[tuple[str, int], list[tuple[int, _BreakdownRow]]],
|
|
941
|
+
) -> list[str]:
|
|
942
|
+
violations: list[str] = []
|
|
943
|
+
for task in tasks:
|
|
944
|
+
selected = set(task.selected_stages)
|
|
945
|
+
stages = {stage.stage_number: stage for stage in task.stages}
|
|
946
|
+
for stage_number in task.selected_stages:
|
|
947
|
+
positions = indexed.get((task.task_id, stage_number), [])
|
|
948
|
+
if not positions:
|
|
949
|
+
continue
|
|
950
|
+
position = positions[0][0]
|
|
951
|
+
for dependency in stages[stage_number].depends_on:
|
|
952
|
+
dependency_rows = indexed.get((task.task_id, dependency), [])
|
|
953
|
+
if dependency in selected and dependency_rows:
|
|
954
|
+
if dependency_rows[0][0] >= position:
|
|
955
|
+
violations.append(
|
|
956
|
+
f"work breakdown: dependency {task.task_id}/S{dependency} "
|
|
957
|
+
f"must precede {task.task_id}/S{stage_number}"
|
|
958
|
+
)
|
|
959
|
+
return violations
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _validate_work_breakdowns(
|
|
963
|
+
text: str, tasks: tuple[SelectionTask, ...],
|
|
964
|
+
) -> tuple[list[_BreakdownRow], list[str]]:
|
|
965
|
+
rows, violations, blocks = _extract_breakdown_rows(text)
|
|
966
|
+
violations.extend(_validate_task_section_coverage(blocks, tasks))
|
|
967
|
+
indexed: dict[tuple[str, int], list[tuple[int, _BreakdownRow]]] = {}
|
|
968
|
+
for position, row in enumerate(rows):
|
|
969
|
+
indexed.setdefault((row.task_id, row.stage_number), []).append(
|
|
970
|
+
(position, row)
|
|
971
|
+
)
|
|
972
|
+
for task in tasks:
|
|
973
|
+
violations.extend(_validate_task_rows(task, indexed))
|
|
974
|
+
violations.extend(_validate_extra_rows(tasks, indexed))
|
|
975
|
+
violations.extend(_validate_topological_order(tasks, indexed))
|
|
976
|
+
return rows, violations
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _expected_selected_stage_days(
|
|
980
|
+
effort_ranges: dict[str, tuple[Decimal, Decimal] | None],
|
|
981
|
+
tasks: tuple[SelectionTask, ...],
|
|
982
|
+
glance_rows: dict[str, _AtAGlanceRow],
|
|
983
|
+
) -> dict[tuple[str, int], tuple[Decimal, Decimal]]:
|
|
984
|
+
expected: dict[tuple[str, int], tuple[Decimal, Decimal]] = {}
|
|
985
|
+
for task in tasks:
|
|
986
|
+
glance_row = glance_rows.get(task.task_id)
|
|
987
|
+
if glance_row is None or not task.selected_stages:
|
|
988
|
+
continue
|
|
989
|
+
total = effort_ranges.get(glance_row.effort)
|
|
990
|
+
if total is None:
|
|
991
|
+
continue
|
|
992
|
+
stages = {stage.stage_number: stage for stage in task.stages}
|
|
993
|
+
step_counts = tuple(
|
|
994
|
+
stages[number].step_count for number in task.selected_stages
|
|
995
|
+
)
|
|
996
|
+
allocated = allocate_stage_days(total, step_counts)
|
|
997
|
+
expected.update(zip(
|
|
998
|
+
((task.task_id, number) for number in task.selected_stages),
|
|
999
|
+
allocated,
|
|
1000
|
+
))
|
|
1001
|
+
return expected
|
|
1002
|
+
|
|
1003
|
+
|
|
1004
|
+
def _validate_stage_effort(
|
|
1005
|
+
expected: dict[tuple[str, int], tuple[Decimal, Decimal]],
|
|
1006
|
+
rows: list[_BreakdownRow],
|
|
1007
|
+
) -> list[str]:
|
|
1008
|
+
violations: list[str] = []
|
|
1009
|
+
for row in rows:
|
|
1010
|
+
day_range = expected.get((row.task_id, row.stage_number))
|
|
1011
|
+
if day_range is None or row.days == day_range:
|
|
1012
|
+
continue
|
|
1013
|
+
violations.append(
|
|
1014
|
+
f"effort: {row.task_id}/S{row.stage_number} days require "
|
|
1015
|
+
f"{_format_day_range(day_range)}"
|
|
1016
|
+
)
|
|
1017
|
+
return violations
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def _validate_task_stage_sums(
|
|
1021
|
+
tasks: tuple[SelectionTask, ...],
|
|
1022
|
+
rows: list[_BreakdownRow],
|
|
1023
|
+
expected: dict[tuple[str, int], tuple[Decimal, Decimal]],
|
|
1024
|
+
) -> list[str]:
|
|
1025
|
+
indexed: dict[tuple[str, int], list[_BreakdownRow]] = {}
|
|
1026
|
+
for row in rows:
|
|
1027
|
+
indexed.setdefault((row.task_id, row.stage_number), []).append(row)
|
|
1028
|
+
violations: list[str] = []
|
|
1029
|
+
for task in tasks:
|
|
1030
|
+
keys = [(task.task_id, number) for number in task.selected_stages]
|
|
1031
|
+
if not keys or any(key not in expected for key in keys):
|
|
1032
|
+
continue
|
|
1033
|
+
entries = [indexed.get(key, []) for key in keys]
|
|
1034
|
+
if any(len(items) != 1 or items[0].days is None for items in entries):
|
|
1035
|
+
continue
|
|
1036
|
+
actual = _sum_day_ranges([items[0].days for items in entries])
|
|
1037
|
+
required = _sum_day_ranges([expected[key] for key in keys])
|
|
1038
|
+
if actual != required:
|
|
1039
|
+
violations.append(
|
|
1040
|
+
f"effort: {task.task_id} stage days sum "
|
|
1041
|
+
f"{_format_day_range(actual)} requires "
|
|
1042
|
+
f"{_format_day_range(required)}"
|
|
1043
|
+
)
|
|
1044
|
+
return violations
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _parse_effort_total(text: str) -> tuple[Decimal, Decimal] | None:
|
|
1048
|
+
match = _EFFORT_TOTAL_RE.search(_section_body(text, "## At a Glance", 2))
|
|
1049
|
+
if match is None:
|
|
1050
|
+
return None
|
|
1051
|
+
return _ordered_day_range(
|
|
1052
|
+
Decimal(match.group(1)), Decimal(match.group(2)), "Effort sum"
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _finite_task_ranges(
|
|
1057
|
+
effort_ranges: dict[str, tuple[Decimal, Decimal] | None],
|
|
1058
|
+
tasks: tuple[SelectionTask, ...],
|
|
1059
|
+
glance_rows: dict[str, _AtAGlanceRow],
|
|
1060
|
+
) -> list[tuple[Decimal, Decimal]]:
|
|
1061
|
+
ranges: list[tuple[Decimal, Decimal]] = []
|
|
1062
|
+
for task in tasks:
|
|
1063
|
+
if task.state == "missing" or not task.selected_stages:
|
|
1064
|
+
continue
|
|
1065
|
+
glance_row = glance_rows.get(task.task_id)
|
|
1066
|
+
if glance_row is None:
|
|
1067
|
+
continue
|
|
1068
|
+
day_range = effort_ranges.get(glance_row.effort)
|
|
1069
|
+
if day_range is not None:
|
|
1070
|
+
ranges.append(day_range)
|
|
1071
|
+
return ranges
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _validate_effort_total(
|
|
1075
|
+
text: str,
|
|
1076
|
+
effort_ranges: dict[str, tuple[Decimal, Decimal] | None],
|
|
1077
|
+
tasks: tuple[SelectionTask, ...],
|
|
1078
|
+
glance_rows: dict[str, _AtAGlanceRow],
|
|
1079
|
+
) -> list[str]:
|
|
1080
|
+
actual = _parse_effort_total(text)
|
|
1081
|
+
if actual is None:
|
|
1082
|
+
return ["effort: missing finite Effort sum"]
|
|
1083
|
+
required = _sum_day_ranges(_finite_task_ranges(
|
|
1084
|
+
effort_ranges, tasks, glance_rows
|
|
1085
|
+
))
|
|
1086
|
+
if actual == required:
|
|
1087
|
+
return []
|
|
1088
|
+
return [
|
|
1089
|
+
f"effort: Effort sum requires {_format_day_range(required)} days"
|
|
1090
|
+
]
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _validate_decomposition_notice(
|
|
1094
|
+
text: str,
|
|
1095
|
+
tasks: tuple[SelectionTask, ...],
|
|
1096
|
+
glance_rows: dict[str, _AtAGlanceRow],
|
|
1097
|
+
) -> list[str]:
|
|
1098
|
+
has_unplanned_work = (
|
|
1099
|
+
any(task.state == "missing" for task in tasks)
|
|
1100
|
+
or any(row.effort == "XXL" for row in glance_rows.values())
|
|
1101
|
+
)
|
|
1102
|
+
if not has_unplanned_work:
|
|
1103
|
+
return []
|
|
1104
|
+
executive_summary = _section_body(
|
|
1105
|
+
text, "## Executive Summary", 2
|
|
1106
|
+
).partition("### Effort Sizing Criteria")[0]
|
|
1107
|
+
if "requires further decomposition" in executive_summary.lower():
|
|
1108
|
+
return []
|
|
1109
|
+
return [
|
|
1110
|
+
"effort: Executive Summary requires further decomposition notice"
|
|
1111
|
+
]
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _validate_effort_semantics(
|
|
1115
|
+
text: str,
|
|
1116
|
+
tasks: tuple[SelectionTask, ...],
|
|
1117
|
+
breakdown_rows: list[_BreakdownRow],
|
|
1118
|
+
) -> list[str]:
|
|
1119
|
+
effort_ranges = parse_effort_ranges(text)
|
|
1120
|
+
glance_rows, violations = _validated_at_a_glance_rows(text, tasks)
|
|
1121
|
+
violations.extend(_validate_at_a_glance_effort(
|
|
1122
|
+
effort_ranges, tasks, glance_rows
|
|
1123
|
+
))
|
|
1124
|
+
expected = _expected_selected_stage_days(
|
|
1125
|
+
effort_ranges, tasks, glance_rows
|
|
1126
|
+
)
|
|
1127
|
+
violations.extend(_validate_stage_effort(expected, breakdown_rows))
|
|
1128
|
+
violations.extend(_validate_task_stage_sums(
|
|
1129
|
+
tasks, breakdown_rows, expected
|
|
1130
|
+
))
|
|
1131
|
+
violations.extend(_validate_effort_total(
|
|
1132
|
+
text, effort_ranges, tasks, glance_rows
|
|
1133
|
+
))
|
|
1134
|
+
violations.extend(_validate_decomposition_notice(
|
|
1135
|
+
text, tasks, glance_rows
|
|
1136
|
+
))
|
|
1137
|
+
return violations
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _parse_gantt_rows(
|
|
1141
|
+
text: str,
|
|
1142
|
+
) -> tuple[list[_GanttRow] | None, list[str]]:
|
|
1143
|
+
visible_text = _mask_fenced_text(text)
|
|
1144
|
+
headings = list(re.finditer(
|
|
1145
|
+
r"^## Gantt Chart[ \t]*$", visible_text, re.MULTILINE
|
|
1146
|
+
))
|
|
1147
|
+
if not headings:
|
|
1148
|
+
return None, []
|
|
1149
|
+
if len(headings) > 1:
|
|
1150
|
+
return None, [
|
|
1151
|
+
"Gantt: Gantt Chart section must appear at most once"
|
|
1152
|
+
]
|
|
1153
|
+
section = _raw_section_body(text, "## Gantt Chart", 2)
|
|
1154
|
+
if section is None:
|
|
1155
|
+
return None, []
|
|
1156
|
+
rows: list[_GanttRow] = []
|
|
1157
|
+
violations: list[str] = []
|
|
1158
|
+
for fence in _PLAIN_FENCE_RE.finditer(section):
|
|
1159
|
+
for line in fence.group(1).splitlines():
|
|
1160
|
+
match = _GANTT_ROW_RE.fullmatch(line)
|
|
1161
|
+
if match is None:
|
|
1162
|
+
candidate = _GANTT_STAGE_CANDIDATE_RE.match(line)
|
|
1163
|
+
if candidate is not None:
|
|
1164
|
+
violations.append(
|
|
1165
|
+
"Gantt: malformed Gantt stage row "
|
|
1166
|
+
f"{candidate.group(1)}/S{candidate.group(2)} requires "
|
|
1167
|
+
"canonical days=<lower>~<upper> syntax"
|
|
1168
|
+
)
|
|
1169
|
+
continue
|
|
1170
|
+
task_id = match.group(1)
|
|
1171
|
+
stage_number = int(match.group(2))
|
|
1172
|
+
rows.append(_GanttRow(
|
|
1173
|
+
task_id=task_id,
|
|
1174
|
+
stage_number=stage_number,
|
|
1175
|
+
days=_ordered_day_range(
|
|
1176
|
+
Decimal(match.group(3)),
|
|
1177
|
+
Decimal(match.group(4)),
|
|
1178
|
+
f"Gantt {task_id}/S{stage_number}",
|
|
1179
|
+
),
|
|
1180
|
+
))
|
|
1181
|
+
return rows, violations
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def _validate_gantt_coverage(
|
|
1185
|
+
rows: list[_GanttRow], tasks: tuple[SelectionTask, ...],
|
|
1186
|
+
) -> list[str]:
|
|
1187
|
+
expected = {
|
|
1188
|
+
(task.task_id, number)
|
|
1189
|
+
for task in tasks
|
|
1190
|
+
for number in task.selected_stages
|
|
1191
|
+
}
|
|
1192
|
+
counts: dict[tuple[str, int], int] = {}
|
|
1193
|
+
for row in rows:
|
|
1194
|
+
key = (row.task_id, row.stage_number)
|
|
1195
|
+
counts[key] = counts.get(key, 0) + 1
|
|
1196
|
+
violations: list[str] = []
|
|
1197
|
+
for task_id, stage_number in sorted(expected):
|
|
1198
|
+
count = counts.get((task_id, stage_number), 0)
|
|
1199
|
+
if count == 0:
|
|
1200
|
+
violations.append(f"Gantt: missing Gantt row {task_id}/S{stage_number}")
|
|
1201
|
+
elif count > 1:
|
|
1202
|
+
violations.append(f"Gantt: duplicate Gantt row {task_id}/S{stage_number}")
|
|
1203
|
+
for task_id, stage_number in sorted(set(counts) - expected):
|
|
1204
|
+
violations.append(
|
|
1205
|
+
f"Gantt: Gantt row {task_id}/S{stage_number} is not selected"
|
|
1206
|
+
)
|
|
1207
|
+
return violations
|
|
1208
|
+
|
|
1209
|
+
|
|
1210
|
+
def _validate_gantt_days(
|
|
1211
|
+
gantt_rows: list[_GanttRow],
|
|
1212
|
+
breakdown_rows: list[_BreakdownRow],
|
|
1213
|
+
) -> list[str]:
|
|
1214
|
+
breakdown: dict[tuple[str, int], list[_BreakdownRow]] = {}
|
|
1215
|
+
for row in breakdown_rows:
|
|
1216
|
+
key = (row.task_id, row.stage_number)
|
|
1217
|
+
breakdown.setdefault(key, []).append(row)
|
|
1218
|
+
violations: list[str] = []
|
|
1219
|
+
for row in gantt_rows:
|
|
1220
|
+
key = (row.task_id, row.stage_number)
|
|
1221
|
+
matches = breakdown.get(key, [])
|
|
1222
|
+
if len(matches) != 1 or matches[0].days is None:
|
|
1223
|
+
continue
|
|
1224
|
+
if row.days != matches[0].days:
|
|
1225
|
+
violations.append(
|
|
1226
|
+
f"Gantt: Gantt days for {row.task_id}/S{row.stage_number} "
|
|
1227
|
+
f"require {_format_day_range(matches[0].days)}"
|
|
1228
|
+
)
|
|
1229
|
+
return violations
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def validate_schedule_semantics(
|
|
1233
|
+
text: str, selection_path: Path,
|
|
1234
|
+
) -> list[str]:
|
|
1235
|
+
try:
|
|
1236
|
+
tasks = load_schedule_selection(selection_path)
|
|
1237
|
+
except (OSError, ValueError, json.JSONDecodeError) as exc:
|
|
1238
|
+
return [f"selection: {exc}"]
|
|
1239
|
+
try:
|
|
1240
|
+
rows, violations = _validate_work_breakdowns(text, tasks)
|
|
1241
|
+
violations.extend(_validate_effort_semantics(text, tasks, rows))
|
|
1242
|
+
gantt_rows, gantt_violations = _parse_gantt_rows(text)
|
|
1243
|
+
violations.extend(gantt_violations)
|
|
1244
|
+
if gantt_rows is not None:
|
|
1245
|
+
violations.extend(_validate_gantt_coverage(gantt_rows, tasks))
|
|
1246
|
+
violations.extend(_validate_gantt_days(gantt_rows, rows))
|
|
1247
|
+
return violations
|
|
1248
|
+
except ScheduleSemanticError as exc:
|
|
1249
|
+
return [str(exc)]
|