fde-workflow-cli 0.1.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.
- fde_workflow_cli/__init__.py +1 -0
- fde_workflow_cli/__main__.py +5 -0
- fde_workflow_cli/clock.py +7 -0
- fde_workflow_cli/commands/__init__.py +1 -0
- fde_workflow_cli/commands/common.py +87 -0
- fde_workflow_cli/commands/decision.py +31 -0
- fde_workflow_cli/commands/hook.py +52 -0
- fde_workflow_cli/commands/kpi.py +210 -0
- fde_workflow_cli/commands/northstar.py +90 -0
- fde_workflow_cli/commands/path.py +257 -0
- fde_workflow_cli/commands/project.py +123 -0
- fde_workflow_cli/commands/stage.py +155 -0
- fde_workflow_cli/detect.py +123 -0
- fde_workflow_cli/doctor.py +45 -0
- fde_workflow_cli/errors.py +5 -0
- fde_workflow_cli/main.py +62 -0
- fde_workflow_cli/model.py +226 -0
- fde_workflow_cli/recipes.py +79 -0
- fde_workflow_cli/rules.py +168 -0
- fde_workflow_cli/store.py +142 -0
- fde_workflow_cli/summary.py +104 -0
- fde_workflow_cli-0.1.0.dist-info/METADATA +48 -0
- fde_workflow_cli-0.1.0.dist-info/RECORD +26 -0
- fde_workflow_cli-0.1.0.dist-info/WHEEL +4 -0
- fde_workflow_cli-0.1.0.dist-info/entry_points.txt +3 -0
- fde_workflow_cli-0.1.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Readiness checks behind `fde-workflow doctor`. Add one function per check to CHECKS."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fde_workflow_cli.errors import FdeError
|
|
9
|
+
from fde_workflow_cli.model import SCHEMA_VERSION
|
|
10
|
+
from fde_workflow_cli.store import Store, locate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class CheckResult:
|
|
15
|
+
ok: bool
|
|
16
|
+
message: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MINIMUM_PYTHON = (3, 13)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_python() -> CheckResult:
|
|
23
|
+
found = ".".join(str(part) for part in sys.version_info[:3])
|
|
24
|
+
if sys.version_info >= MINIMUM_PYTHON:
|
|
25
|
+
return CheckResult(ok=True, message=f"Python {found}")
|
|
26
|
+
return CheckResult(ok=False, message=f"Python {found} is too old: install Python 3.13 or newer")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check_direction() -> CheckResult:
|
|
30
|
+
store = Store(locate(Path.cwd()))
|
|
31
|
+
if not store.exists():
|
|
32
|
+
return CheckResult(ok=True, message="No .fde/ here yet: the adopt skill sets one up")
|
|
33
|
+
try:
|
|
34
|
+
store.load()
|
|
35
|
+
except FdeError as error:
|
|
36
|
+
first_line = str(error).splitlines()[0]
|
|
37
|
+
return CheckResult(ok=False, message=f".fde/ cannot be read: {first_line}")
|
|
38
|
+
return CheckResult(ok=True, message=f".fde/ uses schema {SCHEMA_VERSION}: {store.dir}")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
CHECKS: list[Callable[[], CheckResult]] = [check_python, check_direction]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_checks() -> list[CheckResult]:
|
|
45
|
+
return [check() for check in CHECKS]
|
fde_workflow_cli/main.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Commands of the fde-workflow command line tool."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from fde_workflow_cli import doctor as checks
|
|
9
|
+
from fde_workflow_cli.commands import decision, hook, kpi, northstar, path, project, stage
|
|
10
|
+
|
|
11
|
+
COMMAND = "fde-workflow"
|
|
12
|
+
DIST_NAME = "fde-workflow-cli"
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(no_args_is_help=True, add_completion=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _print_version(value: bool) -> None:
|
|
18
|
+
if value:
|
|
19
|
+
typer.echo(f"{COMMAND} {version(DIST_NAME)}")
|
|
20
|
+
raise typer.Exit()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback()
|
|
24
|
+
def main(
|
|
25
|
+
show_version: Annotated[
|
|
26
|
+
bool,
|
|
27
|
+
typer.Option(
|
|
28
|
+
"--version",
|
|
29
|
+
callback=_print_version,
|
|
30
|
+
is_eager=True,
|
|
31
|
+
help="Show the version and exit.",
|
|
32
|
+
),
|
|
33
|
+
] = False,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Command line tool of the fde-workflow Claude Code plugin."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@app.command()
|
|
39
|
+
def doctor() -> None:
|
|
40
|
+
"""Check that fde-workflow is ready to use."""
|
|
41
|
+
results = checks.run_checks()
|
|
42
|
+
for result in results:
|
|
43
|
+
typer.echo(f"{'✓' if result.ok else '✗'} {result.message}")
|
|
44
|
+
problems = sum(not result.ok for result in results)
|
|
45
|
+
if problems:
|
|
46
|
+
typer.echo(f"Not ready: {problems} problem(s).")
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
typer.echo("Ready.")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
app.command()(project.init)
|
|
52
|
+
app.command("detect")(project.detect_command)
|
|
53
|
+
app.command()(project.validate)
|
|
54
|
+
app.command()(project.status)
|
|
55
|
+
app.command()(project.context)
|
|
56
|
+
app.add_typer(project.app, name="project")
|
|
57
|
+
app.add_typer(kpi.app, name="kpi")
|
|
58
|
+
app.add_typer(northstar.app, name="northstar")
|
|
59
|
+
app.add_typer(path.app, name="path")
|
|
60
|
+
app.add_typer(stage.app, name="stage")
|
|
61
|
+
app.add_typer(decision.app, name="decision")
|
|
62
|
+
app.add_typer(hook.app, name="hook")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""The direction framework's data model: what the files in `.fde/` contain."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from pathlib import PurePosixPath
|
|
6
|
+
from typing import Annotated, Self
|
|
7
|
+
|
|
8
|
+
from pydantic import (
|
|
9
|
+
BaseModel,
|
|
10
|
+
ConfigDict,
|
|
11
|
+
Field,
|
|
12
|
+
FiniteFloat,
|
|
13
|
+
StringConstraints,
|
|
14
|
+
field_validator,
|
|
15
|
+
model_validator,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
SCHEMA_VERSION = 1
|
|
19
|
+
|
|
20
|
+
Identifier = Annotated[str, StringConstraints(pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$")]
|
|
21
|
+
Window = Annotated[str, StringConstraints(pattern=r"^[1-9][0-9]*d$")]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StageName(StrEnum):
|
|
25
|
+
DISCOVERY = "discovery"
|
|
26
|
+
POC = "poc"
|
|
27
|
+
MVP = "mvp"
|
|
28
|
+
EARLY_ACCESS = "early-access"
|
|
29
|
+
PRODUCTION = "production"
|
|
30
|
+
CLOSED = "closed"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
STAGE_ORDER: list[StageName] = list(StageName)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Outcome(StrEnum):
|
|
37
|
+
COMPLETED = "completed"
|
|
38
|
+
DISCONTINUED = "discontinued"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GateDecision(StrEnum):
|
|
42
|
+
GO = "go"
|
|
43
|
+
RECYCLE = "recycle"
|
|
44
|
+
HOLD = "hold"
|
|
45
|
+
KILL = "kill"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class KpiKind(StrEnum):
|
|
49
|
+
PRIMARY = "primary"
|
|
50
|
+
SECONDARY = "secondary"
|
|
51
|
+
GUARDRAIL = "guardrail"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Better(StrEnum):
|
|
55
|
+
INCREASE = "increase"
|
|
56
|
+
DECREASE = "decrease"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RecipeType(StrEnum):
|
|
60
|
+
SCRIPT = "script"
|
|
61
|
+
QUERY = "query"
|
|
62
|
+
MANUAL = "manual"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PathStatus(StrEnum):
|
|
66
|
+
PROPOSED = "proposed"
|
|
67
|
+
ACTIVE = "active"
|
|
68
|
+
MEASURING = "measuring"
|
|
69
|
+
VALIDATED = "validated"
|
|
70
|
+
DISCARDED = "discarded"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class BaselineType(StrEnum):
|
|
74
|
+
HISTORICAL = "historical"
|
|
75
|
+
LATE = "late"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DecisionType(StrEnum):
|
|
79
|
+
NORTH_STAR_CHANGE = "north-star-change"
|
|
80
|
+
TARGET_CHANGE = "target-change"
|
|
81
|
+
PATH_OPEN = "path-open"
|
|
82
|
+
PATH_VALIDATE = "path-validate"
|
|
83
|
+
PATH_DISCARD = "path-discard"
|
|
84
|
+
STAGE_GATE = "stage-gate"
|
|
85
|
+
STAGE_SET = "stage-set"
|
|
86
|
+
SCOPE_CHANGE = "scope-change"
|
|
87
|
+
OTHER = "other"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Record(BaseModel):
|
|
91
|
+
"""Base of every model: unknown keys are errors, so typos in hand-edited files surface."""
|
|
92
|
+
|
|
93
|
+
model_config = ConfigDict(extra="forbid")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Project(Record):
|
|
97
|
+
name: str = Field(min_length=1)
|
|
98
|
+
customer: str = ""
|
|
99
|
+
sponsor: str = ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class NorthStar(Record):
|
|
103
|
+
statement: str = Field(min_length=1)
|
|
104
|
+
beneficiary: str = ""
|
|
105
|
+
problem: str = ""
|
|
106
|
+
non_goals: list[str] = Field(default_factory=list)
|
|
107
|
+
primary_kpi: Identifier | None = None
|
|
108
|
+
confirmed: bool = True
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class StageState(Record):
|
|
112
|
+
current: StageName = StageName.DISCOVERY
|
|
113
|
+
entered: dt.date
|
|
114
|
+
outcome: Outcome | None = None
|
|
115
|
+
on_hold: bool = False
|
|
116
|
+
confirmed: bool = True
|
|
117
|
+
gate_criteria: list[str] = Field(default_factory=list)
|
|
118
|
+
|
|
119
|
+
@model_validator(mode="after")
|
|
120
|
+
def _outcome_only_when_closed(self) -> Self:
|
|
121
|
+
if (self.current is StageName.CLOSED) != (self.outcome is not None):
|
|
122
|
+
raise ValueError("outcome must be set when, and only when, the stage is closed")
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Recipe(Record):
|
|
127
|
+
type: RecipeType
|
|
128
|
+
file: str = Field(min_length=1)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Kpi(Record):
|
|
132
|
+
id: Identifier
|
|
133
|
+
name: str = Field(min_length=1)
|
|
134
|
+
kind: KpiKind
|
|
135
|
+
definition: str = Field(min_length=1)
|
|
136
|
+
source: str = Field(min_length=1)
|
|
137
|
+
unit: str = ""
|
|
138
|
+
direction: Better
|
|
139
|
+
target: FiniteFloat
|
|
140
|
+
window: Window = "28d"
|
|
141
|
+
recipe: Recipe
|
|
142
|
+
|
|
143
|
+
def window_ending(self, end: dt.date) -> tuple[dt.date, dt.date]:
|
|
144
|
+
"""The measurement window (first day, last day) that ends on `end`."""
|
|
145
|
+
days = int(self.window.removesuffix("d"))
|
|
146
|
+
return end - dt.timedelta(days=days - 1), end
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class DirectionPath(Record):
|
|
150
|
+
id: Identifier
|
|
151
|
+
hypothesis: str = Field(min_length=1)
|
|
152
|
+
expects: dict[Identifier, FiniteFloat]
|
|
153
|
+
status: PathStatus = PathStatus.PROPOSED
|
|
154
|
+
activated: dt.date | None = None
|
|
155
|
+
maturity: StageName | None = None
|
|
156
|
+
confirmed: bool = True
|
|
157
|
+
links: list[str] = Field(default_factory=list)
|
|
158
|
+
learning: str | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Artifacts(Record):
|
|
162
|
+
globs: list[str] = Field(default_factory=list)
|
|
163
|
+
pre_framework: list[str] = Field(default_factory=list)
|
|
164
|
+
|
|
165
|
+
@field_validator("globs")
|
|
166
|
+
@classmethod
|
|
167
|
+
def _globs_stay_inside_the_repository(cls, globs: list[str]) -> list[str]:
|
|
168
|
+
for pattern in globs:
|
|
169
|
+
path = PurePosixPath(pattern)
|
|
170
|
+
if not pattern or path.is_absolute() or ".." in path.parts:
|
|
171
|
+
raise ValueError(f"{pattern!r} must be a relative pattern inside the repository")
|
|
172
|
+
return globs
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class Direction(Record):
|
|
176
|
+
schema_version: int = SCHEMA_VERSION
|
|
177
|
+
project: Project
|
|
178
|
+
north_star: NorthStar | None = None
|
|
179
|
+
stage: StageState
|
|
180
|
+
kpis: list[Kpi] = Field(default_factory=list)
|
|
181
|
+
paths: list[DirectionPath] = Field(default_factory=list)
|
|
182
|
+
artifacts: Artifacts = Field(default_factory=Artifacts)
|
|
183
|
+
|
|
184
|
+
@model_validator(mode="after")
|
|
185
|
+
def _ids_are_unique(self) -> Self:
|
|
186
|
+
for label, ids in (
|
|
187
|
+
("KPI", [k.id for k in self.kpis]),
|
|
188
|
+
("path", [p.id for p in self.paths]),
|
|
189
|
+
):
|
|
190
|
+
duplicates = sorted({i for i in ids if ids.count(i) > 1})
|
|
191
|
+
if duplicates:
|
|
192
|
+
raise ValueError(f"duplicate {label} id(s): {', '.join(duplicates)}")
|
|
193
|
+
return self
|
|
194
|
+
|
|
195
|
+
def kpi(self, kpi_id: str) -> Kpi | None:
|
|
196
|
+
return next((k for k in self.kpis if k.id == kpi_id), None)
|
|
197
|
+
|
|
198
|
+
def path(self, path_id: str) -> DirectionPath | None:
|
|
199
|
+
return next((p for p in self.paths if p.id == path_id), None)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class Measurement(Record):
|
|
203
|
+
kpi: Identifier
|
|
204
|
+
taken: dt.date
|
|
205
|
+
window_start: dt.date
|
|
206
|
+
window_end: dt.date
|
|
207
|
+
value: FiniteFloat
|
|
208
|
+
baseline: BaselineType | None = None
|
|
209
|
+
source: str = Field(min_length=1)
|
|
210
|
+
external_factors: str = ""
|
|
211
|
+
recipe_sha256: str
|
|
212
|
+
|
|
213
|
+
@model_validator(mode="after")
|
|
214
|
+
def _window_is_ordered(self) -> Self:
|
|
215
|
+
if self.window_start > self.window_end:
|
|
216
|
+
raise ValueError("window_start must not be after window_end")
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Decision(Record):
|
|
221
|
+
date: dt.date
|
|
222
|
+
type: DecisionType
|
|
223
|
+
summary: str = Field(min_length=1)
|
|
224
|
+
reason: str = Field(min_length=1)
|
|
225
|
+
approved_by: str = ""
|
|
226
|
+
refs: list[str] = Field(default_factory=list)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Measurement recipes: where they live, their fingerprint, and running script recipes."""
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import hashlib
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fde_workflow_cli.errors import FdeError
|
|
11
|
+
from fde_workflow_cli.model import Recipe
|
|
12
|
+
|
|
13
|
+
SCRIPT_TIMEOUT_SECONDS = 600
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def recipe_path(fde_dir: Path, recipe: Recipe) -> Path:
|
|
17
|
+
"""Return the recipe's file, which must stay inside `.fde/`."""
|
|
18
|
+
path = (fde_dir / recipe.file).resolve()
|
|
19
|
+
if not path.is_relative_to(fde_dir.resolve()):
|
|
20
|
+
raise FdeError(f"Recipe {recipe.file!r} must be inside .fde/ (e.g. measures/<kpi>.sql).")
|
|
21
|
+
return path
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def fingerprint(fde_dir: Path, recipe: Recipe) -> str | None:
|
|
25
|
+
"""SHA-256 of the recipe file, or None when the file does not exist."""
|
|
26
|
+
path = recipe_path(fde_dir, recipe)
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
return None
|
|
29
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render(fde_dir: Path, recipe: Recipe, start: dt.date, end: dt.date) -> str:
|
|
33
|
+
"""The recipe's text with its window placeholders filled in."""
|
|
34
|
+
text = recipe_path(fde_dir, recipe).read_text(encoding="utf-8")
|
|
35
|
+
return text.replace("{{window_start}}", start.isoformat()).replace(
|
|
36
|
+
"{{window_end}}", end.isoformat()
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def run_script(fde_dir: Path, recipe: Recipe, start: dt.date, end: dt.date) -> float:
|
|
41
|
+
"""Run a script recipe from the repository root and return the number it prints."""
|
|
42
|
+
path = recipe_path(fde_dir, recipe)
|
|
43
|
+
if not os.access(path, os.X_OK):
|
|
44
|
+
raise FdeError(f"{path} is not executable: run `chmod +x {path}`.")
|
|
45
|
+
env = {**os.environ, "FDE_WINDOW_START": start.isoformat(), "FDE_WINDOW_END": end.isoformat()}
|
|
46
|
+
try:
|
|
47
|
+
result = subprocess.run( # noqa: S603 - the project's own recipe, run only with --run
|
|
48
|
+
[str(path)],
|
|
49
|
+
cwd=fde_dir.parent,
|
|
50
|
+
env=env,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
timeout=SCRIPT_TIMEOUT_SECONDS,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
except OSError as error:
|
|
57
|
+
raise FdeError(
|
|
58
|
+
f"Recipe {recipe.file} cannot be executed ({error.strerror}): start it with a "
|
|
59
|
+
"shebang such as #!/bin/sh."
|
|
60
|
+
) from error
|
|
61
|
+
except subprocess.TimeoutExpired as error:
|
|
62
|
+
raise FdeError(
|
|
63
|
+
f"Recipe {recipe.file} did not finish in {SCRIPT_TIMEOUT_SECONDS} s."
|
|
64
|
+
) from error
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
raise FdeError(
|
|
67
|
+
f"Recipe {recipe.file} failed with exit code {result.returncode}:\n"
|
|
68
|
+
f"{result.stderr.strip()}"
|
|
69
|
+
)
|
|
70
|
+
output = result.stdout.strip()
|
|
71
|
+
try:
|
|
72
|
+
value = float(output)
|
|
73
|
+
except ValueError as error:
|
|
74
|
+
raise FdeError(
|
|
75
|
+
f"Recipe {recipe.file} must print one number; it printed {output!r}."
|
|
76
|
+
) from error
|
|
77
|
+
if not math.isfinite(value):
|
|
78
|
+
raise FdeError(f"Recipe {recipe.file} must print a finite number; it printed {output!r}.")
|
|
79
|
+
return value
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""The framework's rules (spec §2), checked over a whole `.fde/`."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
from fde_workflow_cli import recipes
|
|
9
|
+
from fde_workflow_cli.errors import FdeError
|
|
10
|
+
from fde_workflow_cli.model import (
|
|
11
|
+
Direction,
|
|
12
|
+
DirectionPath,
|
|
13
|
+
KpiKind,
|
|
14
|
+
Measurement,
|
|
15
|
+
PathStatus,
|
|
16
|
+
StageName,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
STAGES_THAT_MEASURE = (StageName.MVP, StageName.EARLY_ACCESS, StageName.PRODUCTION)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Issue:
|
|
24
|
+
level: Literal["error", "warning"]
|
|
25
|
+
rule: str
|
|
26
|
+
message: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def check(direction: Direction, measurements: list[Measurement], fde_dir: Path) -> list[Issue]:
|
|
30
|
+
"""Every broken rule, errors first. Errors block writes; warnings are reported."""
|
|
31
|
+
issues = [
|
|
32
|
+
*_north_star(direction),
|
|
33
|
+
*_kpis(direction, measurements, fde_dir),
|
|
34
|
+
*_paths(direction, measurements),
|
|
35
|
+
*_stage(direction, measurements),
|
|
36
|
+
]
|
|
37
|
+
return sorted(issues, key=lambda issue: issue.level != "error")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def latest_baseline(measurements: list[Measurement], kpi_id: str) -> Measurement | None:
|
|
41
|
+
"""The KPI's most recently recorded baseline (files keep recording order)."""
|
|
42
|
+
baselines = [m for m in measurements if m.kpi == kpi_id and m.baseline is not None]
|
|
43
|
+
return baselines[-1] if baselines else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def has_evidence(path: DirectionPath, measurements: list[Measurement]) -> bool:
|
|
47
|
+
"""P2: a non-baseline measurement of a targeted KPI whose whole window follows activation."""
|
|
48
|
+
activated = path.activated
|
|
49
|
+
if activated is None:
|
|
50
|
+
return False
|
|
51
|
+
return any(
|
|
52
|
+
m.kpi in path.expects and m.baseline is None and m.window_start > activated
|
|
53
|
+
for m in measurements
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _north_star(direction: Direction) -> Iterator[Issue]:
|
|
58
|
+
north_star = direction.north_star
|
|
59
|
+
if north_star is None:
|
|
60
|
+
yield Issue("warning", "N1", "No North Star yet: define it with the discovery skill.")
|
|
61
|
+
return
|
|
62
|
+
if not north_star.confirmed:
|
|
63
|
+
yield Issue(
|
|
64
|
+
"warning",
|
|
65
|
+
"C1",
|
|
66
|
+
"The North Star was inferred and is not confirmed: "
|
|
67
|
+
"`fde-workflow northstar confirm` once the FDE and the sponsor agree.",
|
|
68
|
+
)
|
|
69
|
+
if north_star.primary_kpi is None:
|
|
70
|
+
yield Issue("warning", "N1", "The North Star has no primary KPI yet.")
|
|
71
|
+
return
|
|
72
|
+
kpi = direction.kpi(north_star.primary_kpi)
|
|
73
|
+
if kpi is None:
|
|
74
|
+
yield Issue(
|
|
75
|
+
"error", "N1", f"The North Star's primary KPI {north_star.primary_kpi} does not exist."
|
|
76
|
+
)
|
|
77
|
+
elif kpi.kind is not KpiKind.PRIMARY:
|
|
78
|
+
yield Issue(
|
|
79
|
+
"error",
|
|
80
|
+
"K2",
|
|
81
|
+
f"The North Star's primary KPI {kpi.id} has kind {kpi.kind}, not primary.",
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _kpis(direction: Direction, measurements: list[Measurement], fde_dir: Path) -> Iterator[Issue]:
|
|
86
|
+
primaries = [kpi.id for kpi in direction.kpis if kpi.kind is KpiKind.PRIMARY]
|
|
87
|
+
if len(primaries) > 1:
|
|
88
|
+
yield Issue("error", "K2", f"Only one KPI can be primary; found {', '.join(primaries)}.")
|
|
89
|
+
for kpi in direction.kpis:
|
|
90
|
+
try:
|
|
91
|
+
current = recipes.fingerprint(fde_dir, kpi.recipe)
|
|
92
|
+
except FdeError as error:
|
|
93
|
+
yield Issue("error", "M1", f"KPI {kpi.id}: {error}")
|
|
94
|
+
continue
|
|
95
|
+
if current is None:
|
|
96
|
+
yield Issue(
|
|
97
|
+
"error",
|
|
98
|
+
"M1",
|
|
99
|
+
f"KPI {kpi.id}: recipe .fde/{kpi.recipe.file} does not exist; write it first.",
|
|
100
|
+
)
|
|
101
|
+
continue
|
|
102
|
+
baseline = latest_baseline(measurements, kpi.id)
|
|
103
|
+
if baseline is None:
|
|
104
|
+
yield Issue(
|
|
105
|
+
"warning",
|
|
106
|
+
"K3",
|
|
107
|
+
f"KPI {kpi.id} has no baseline: measure it before work that should move it starts.",
|
|
108
|
+
)
|
|
109
|
+
elif baseline.recipe_sha256 != current:
|
|
110
|
+
yield Issue(
|
|
111
|
+
"warning",
|
|
112
|
+
"K4",
|
|
113
|
+
f"KPI {kpi.id}: the recipe changed after the baseline, so comparisons are not "
|
|
114
|
+
"valid; measure the baseline again with --baseline.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _paths(direction: Direction, measurements: list[Measurement]) -> Iterator[Issue]:
|
|
119
|
+
for path in direction.paths:
|
|
120
|
+
if not path.expects:
|
|
121
|
+
yield Issue("error", "P1", f"Path {path.id} targets no KPI.")
|
|
122
|
+
for kpi_id in path.expects:
|
|
123
|
+
if direction.kpi(kpi_id) is None:
|
|
124
|
+
yield Issue("error", "P1", f"Path {path.id} targets unknown KPI {kpi_id}.")
|
|
125
|
+
if path.status is PathStatus.VALIDATED and not has_evidence(path, measurements):
|
|
126
|
+
yield Issue(
|
|
127
|
+
"error",
|
|
128
|
+
"P2",
|
|
129
|
+
f"Path {path.id} cannot be validated without evidence: it needs a measurement of "
|
|
130
|
+
f"{', '.join(path.expects)} whose window starts after it became active.",
|
|
131
|
+
)
|
|
132
|
+
if not path.confirmed:
|
|
133
|
+
yield Issue(
|
|
134
|
+
"warning",
|
|
135
|
+
"C1",
|
|
136
|
+
f"Path {path.id} was inferred and is not confirmed: `fde-workflow path confirm`.",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _stage(direction: Direction, measurements: list[Measurement]) -> Iterator[Issue]:
|
|
141
|
+
stage = direction.stage
|
|
142
|
+
if not stage.confirmed:
|
|
143
|
+
yield Issue(
|
|
144
|
+
"warning",
|
|
145
|
+
"C1",
|
|
146
|
+
"The stage was inferred and is not confirmed: `fde-workflow stage confirm`.",
|
|
147
|
+
)
|
|
148
|
+
if stage.current is StageName.CLOSED:
|
|
149
|
+
return
|
|
150
|
+
if not stage.gate_criteria:
|
|
151
|
+
yield Issue(
|
|
152
|
+
"warning",
|
|
153
|
+
"S1",
|
|
154
|
+
f"Stage {stage.current} has no gate criteria: agree them with the sponsor now, "
|
|
155
|
+
"not when leaving the stage.",
|
|
156
|
+
)
|
|
157
|
+
primary = direction.north_star.primary_kpi if direction.north_star else None
|
|
158
|
+
if (
|
|
159
|
+
stage.current in STAGES_THAT_MEASURE
|
|
160
|
+
and primary is not None
|
|
161
|
+
and not any(m.kpi == primary and m.window_end >= stage.entered for m in measurements)
|
|
162
|
+
):
|
|
163
|
+
yield Issue(
|
|
164
|
+
"warning",
|
|
165
|
+
"S3",
|
|
166
|
+
f"Primary KPI {primary} has no measurement since entering {stage.current} "
|
|
167
|
+
f"on {stage.entered}.",
|
|
168
|
+
)
|