Skip to content

Commit 63f46ef

Browse files
authored
refactor(config): centralize indexed item loading
Consolidate validation and duplicate detection for models, use cases, and workflows while preserving loader ordering and error contracts. Add characterization coverage for collection semantics, source paths, duplicate precedence, and workflow extension ordering.
1 parent 4a0cfe9 commit 63f46ef

2 files changed

Lines changed: 203 additions & 31 deletions

File tree

‎src/flightdeck/config.py‎

Lines changed: 57 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
ledger.jsonl) lives under .flightdeck/ and is never committed.
1111
"""
1212

13+
from collections.abc import Callable, Iterable
1314
from dataclasses import dataclass, field
1415
from pathlib import Path
16+
from typing import TypeVar
1517

1618
import yaml
1719
from pydantic import ValidationError
@@ -26,6 +28,8 @@
2628
WORKFLOWS_DIR = "workflows"
2729
STATE_DIR = ".flightdeck"
2830

31+
ConfigItem = TypeVar("ConfigItem", ModelSpec, UseCase, Workflow)
32+
2933

3034
class ConfigError(Exception):
3135
"""A human-actionable configuration problem: message always names the file."""
@@ -100,6 +104,37 @@ def _validation_error(path: Path, exc: ValidationError) -> ConfigError:
100104
return ConfigError(f"{path}: invalid configuration\n" + "\n".join(lines))
101105

102106

107+
def _load_indexed_items(
108+
raw_items: Iterable[object],
109+
*,
110+
path: Path,
111+
item_type: type[ConfigItem],
112+
kind: str,
113+
items: dict[str, ConfigItem] | None = None,
114+
before_index: Callable[[ConfigItem], None] | None = None,
115+
) -> dict[str, ConfigItem]:
116+
"""Validate and index config items, rejecting duplicate IDs at their source."""
117+
indexed = items if items is not None else {}
118+
for raw_item in raw_items:
119+
try:
120+
item = item_type.model_validate(raw_item)
121+
except ValidationError as exc:
122+
raise _validation_error(path, exc) from None
123+
if item.id in indexed:
124+
raise ConfigError(f"{path}: duplicate {kind} id '{item.id}'")
125+
if before_index:
126+
before_index(item)
127+
indexed[item.id] = item
128+
return indexed
129+
130+
131+
def _validate_workflow_use_case(
132+
workflow: Workflow, path: Path, usecases: dict[str, UseCase]
133+
) -> None:
134+
if workflow.use_case and workflow.use_case not in usecases:
135+
raise ConfigError(f"{path}: use_case '{workflow.use_case}' not found in {USECASES_FILE}")
136+
137+
103138
def load_org(root: Path | str) -> Org:
104139
"""Load and cross-validate an org directory. Workflows and use cases are
105140
optional (a fresh org starts empty); the org file and model registry are not."""
@@ -116,45 +151,39 @@ def load_org(root: Path | str) -> Org:
116151
raise _validation_error(org_path, exc) from None
117152

118153
models_path = root / MODELS_FILE
119-
models: dict[str, ModelSpec] = {}
120-
for item in _read_yaml(models_path).get("models") or []:
121-
try:
122-
spec = ModelSpec.model_validate(item)
123-
except ValidationError as exc:
124-
raise _validation_error(models_path, exc) from None
125-
if spec.id in models:
126-
raise ConfigError(f"{models_path}: duplicate model id '{spec.id}'")
127-
models[spec.id] = spec
154+
models = _load_indexed_items(
155+
_read_yaml(models_path).get("models") or [],
156+
path=models_path,
157+
item_type=ModelSpec,
158+
kind="model",
159+
)
128160
if not models:
129161
raise ConfigError(f"{models_path}: the model registry is empty")
130162

131163
usecases: dict[str, UseCase] = {}
132164
usecases_path = root / USECASES_FILE
133165
if usecases_path.exists():
134-
for item in _read_yaml(usecases_path).get("usecases") or []:
135-
try:
136-
case = UseCase.model_validate(item)
137-
except ValidationError as exc:
138-
raise _validation_error(usecases_path, exc) from None
139-
if case.id in usecases:
140-
raise ConfigError(f"{usecases_path}: duplicate use case id '{case.id}'")
141-
usecases[case.id] = case
166+
usecases = _load_indexed_items(
167+
_read_yaml(usecases_path).get("usecases") or [],
168+
path=usecases_path,
169+
item_type=UseCase,
170+
kind="use case",
171+
)
142172

143173
workflows: dict[str, Workflow] = {}
144174
workflows_dir = root / WORKFLOWS_DIR
145175
if workflows_dir.is_dir():
146176
for path in sorted(workflows_dir.glob("*.yaml")) + sorted(workflows_dir.glob("*.yml")):
147-
try:
148-
workflow = Workflow.model_validate(_read_yaml(path))
149-
except ValidationError as exc:
150-
raise _validation_error(path, exc) from None
151-
if workflow.id in workflows:
152-
raise ConfigError(f"{path}: duplicate workflow id '{workflow.id}'")
153-
if workflow.use_case and workflow.use_case not in usecases:
154-
raise ConfigError(
155-
f"{path}: use_case '{workflow.use_case}' not found in {USECASES_FILE}"
156-
)
157-
workflows[workflow.id] = workflow
177+
_load_indexed_items(
178+
[_read_yaml(path)],
179+
path=path,
180+
item_type=Workflow,
181+
kind="workflow",
182+
items=workflows,
183+
before_index=lambda workflow, workflow_path=path: _validate_workflow_use_case(
184+
workflow, workflow_path, usecases
185+
),
186+
)
158187

159188
# Optional SSO directory snapshot (like usecases.yaml, absent is fine).
160189
directory = Directory.from_file(root / DIRECTORY_FILE)

‎tests/test_store_config.py‎

Lines changed: 146 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from flightdeck.config import ConfigError, load_org
77
from flightdeck.schemas import Feedback, Run
8-
from tests.conftest import ORG, SUPPORT_WORKFLOW, write_org
8+
from tests.conftest import MODELS, ORG, SUPPORT_WORKFLOW, write_org
99

1010

1111
def _run(run_id: str, when: datetime, **overrides) -> Run:
@@ -67,6 +67,23 @@ def test_month_cost_sums_only_that_month(store):
6767
# ------------------------------------------------------------------ config loading
6868

6969

70+
def _use_case(**overrides):
71+
fields = {
72+
"id": "ticket-triage",
73+
"name": "Ticket triage",
74+
"department": "Support",
75+
"task_minutes": 8,
76+
"tasks_per_month": 200,
77+
"automation_potential": 0.6,
78+
"data_readiness": 4,
79+
"process_stability": 4,
80+
"risk": 2,
81+
"effort_weeks": 3,
82+
}
83+
fields.update(overrides)
84+
return fields
85+
86+
7087
def test_missing_org_file_suggests_init(tmp_path):
7188
with pytest.raises(ConfigError, match="flightdeck init"):
7289
load_org(tmp_path)
@@ -82,8 +99,13 @@ def test_unknown_keys_fail_loudly(tmp_path):
8299
def test_dangling_use_case_reference_fails(tmp_path):
83100
workflow = dict(SUPPORT_WORKFLOW)
84101
workflow["use_case"] = "does-not-exist"
85-
with pytest.raises(ConfigError, match="does-not-exist"):
86-
load_org(write_org(tmp_path / "org", workflows=[workflow]))
102+
root = write_org(tmp_path / "org", workflows=[workflow])
103+
path = root / "workflows" / "support-reply.yaml"
104+
105+
with pytest.raises(ConfigError) as excinfo:
106+
load_org(root)
107+
108+
assert str(excinfo.value) == f"{path}: use_case 'does-not-exist' not found in usecases.yaml"
87109

88110

89111
def test_empty_model_registry_fails(tmp_path):
@@ -93,6 +115,127 @@ def test_empty_model_registry_fails(tmp_path):
93115
load_org(root)
94116

95117

118+
@pytest.mark.parametrize("registry", [{}, {"models": None}])
119+
def test_missing_or_null_model_collection_is_an_empty_registry(tmp_path, registry):
120+
root = write_org(tmp_path / "org")
121+
(root / "models.yaml").write_text(yaml.safe_dump(registry), encoding="utf-8")
122+
123+
with pytest.raises(ConfigError, match=r"models\.yaml: the model registry is empty"):
124+
load_org(root)
125+
126+
127+
@pytest.mark.parametrize("collection", [{}, {"usecases": None}])
128+
def test_missing_or_null_use_case_collection_loads_empty(tmp_path, collection):
129+
root = write_org(tmp_path / "org", workflows=[])
130+
(root / "usecases.yaml").write_text(yaml.safe_dump(collection), encoding="utf-8")
131+
132+
assert load_org(root).usecases == {}
133+
134+
135+
def test_absent_workflow_directory_loads_empty(tmp_path):
136+
root = write_org(tmp_path / "org", workflows=[])
137+
138+
assert not (root / "workflows").exists()
139+
assert load_org(root).workflows == {}
140+
141+
142+
def test_invalid_model_reports_the_model_registry_path(tmp_path):
143+
model = {**MODELS[0], "tier": "unsupported"}
144+
root = write_org(tmp_path / "org", models=[model])
145+
146+
with pytest.raises(ConfigError) as excinfo:
147+
load_org(root)
148+
149+
message = str(excinfo.value)
150+
assert str(root / "models.yaml") in message
151+
assert "invalid configuration" in message
152+
assert "tier" in message
153+
154+
155+
def test_duplicate_model_id_reports_kind_and_registry_path(tmp_path):
156+
root = write_org(tmp_path / "org", models=[dict(MODELS[0]), dict(MODELS[0])])
157+
158+
with pytest.raises(ConfigError) as excinfo:
159+
load_org(root)
160+
161+
assert str(excinfo.value) == f"{root / 'models.yaml'}: duplicate model id 'mock-fast-eu'"
162+
163+
164+
def test_invalid_use_case_reports_the_use_case_path(tmp_path):
165+
root = write_org(tmp_path / "org", workflows=[])
166+
(root / "usecases.yaml").write_text(
167+
yaml.safe_dump({"usecases": [_use_case(task_minutes=0)]}), encoding="utf-8"
168+
)
169+
170+
with pytest.raises(ConfigError) as excinfo:
171+
load_org(root)
172+
173+
message = str(excinfo.value)
174+
assert str(root / "usecases.yaml") in message
175+
assert "invalid configuration" in message
176+
assert "task_minutes" in message
177+
178+
179+
def test_duplicate_use_case_id_reports_kind_and_use_case_path(tmp_path):
180+
root = write_org(tmp_path / "org", workflows=[])
181+
cases = [_use_case(), _use_case(name="Another triage")]
182+
(root / "usecases.yaml").write_text(yaml.safe_dump({"usecases": cases}), encoding="utf-8")
183+
184+
with pytest.raises(ConfigError) as excinfo:
185+
load_org(root)
186+
187+
assert str(excinfo.value) == f"{root / 'usecases.yaml'}: duplicate use case id 'ticket-triage'"
188+
189+
190+
@pytest.mark.parametrize("suffix", [".yaml", ".yml"])
191+
def test_invalid_workflow_reports_its_workflow_path(tmp_path, suffix):
192+
workflow = {
193+
**SUPPORT_WORKFLOW,
194+
"baseline": {**SUPPORT_WORKFLOW["baseline"], "minutes_per_task": 0},
195+
}
196+
root = write_org(tmp_path / "org", workflows=[])
197+
workflows_dir = root / "workflows"
198+
workflows_dir.mkdir()
199+
path = workflows_dir / f"invalid{suffix}"
200+
path.write_text(yaml.safe_dump(workflow), encoding="utf-8")
201+
202+
with pytest.raises(ConfigError) as excinfo:
203+
load_org(root)
204+
205+
message = str(excinfo.value)
206+
assert str(path) in message
207+
assert "invalid configuration" in message
208+
assert "baseline.minutes_per_task" in message
209+
210+
211+
def test_duplicate_workflow_id_wins_over_dangling_use_case_in_second_file(tmp_path):
212+
root = write_org(tmp_path / "org", workflows=[])
213+
workflows_dir = root / "workflows"
214+
workflows_dir.mkdir()
215+
first = workflows_dir / "first.yaml"
216+
second = workflows_dir / "second.yaml"
217+
duplicate_with_dangling_use_case = {**SUPPORT_WORKFLOW, "use_case": "does-not-exist"}
218+
first.write_text(yaml.safe_dump(SUPPORT_WORKFLOW), encoding="utf-8")
219+
second.write_text(yaml.safe_dump(duplicate_with_dangling_use_case), encoding="utf-8")
220+
221+
with pytest.raises(ConfigError) as excinfo:
222+
load_org(root)
223+
224+
assert str(excinfo.value) == f"{second}: duplicate workflow id 'support-reply'"
225+
226+
227+
def test_mixed_workflow_extensions_use_yaml_then_yml_order(tmp_path):
228+
root = write_org(tmp_path / "org", workflows=[])
229+
workflows_dir = root / "workflows"
230+
workflows_dir.mkdir()
231+
yaml_workflow = {**SUPPORT_WORKFLOW, "id": "yaml-first"}
232+
yml_workflow = {**SUPPORT_WORKFLOW, "id": "yml-second"}
233+
(workflows_dir / "z.yaml").write_text(yaml.safe_dump(yaml_workflow), encoding="utf-8")
234+
(workflows_dir / "a.yml").write_text(yaml.safe_dump(yml_workflow), encoding="utf-8")
235+
236+
assert list(load_org(root).workflows) == ["yaml-first", "yml-second"]
237+
238+
96239
def test_invalid_redact_pattern_is_a_loud_config_error(tmp_path):
97240
# A bad regex must fail at LOAD, naming the org file and the pattern — never
98241
# at run time, inside the redactor, mid-run.

0 commit comments

Comments
 (0)