Skip to content

Commit 782da36

Browse files
nesonoclaude
andauthored
Implement configurable document types (#224) (#226)
## Summary - Add YAML-driven configuration system for custom document types (field definitions + document type definitions) - Build Pydantic validation models dynamically from config at runtime, with identical behaviour to the static models - Wire optional `config` attribute into `requirement_library()`, `release_report()`, and their Python scripts - Add `generate_format_specification()` Bazel rule to auto-generate FORMAT_SPECIFICATION.md from config - Ship `default_fire_config.yaml` matching the built-in sysreq/swreq/regreq types (including new PL-a through PL-d values) - Document custom document types in README.md Implements the design from #225. ## New files | File | Purpose | |------|---------| | `fire/starlark/default_fire_config.yaml` | Default config matching built-in types | | `fire/starlark/config_models.py` | Pydantic models for config schema + `load_config()` | | `fire/starlark/dynamic_requirement_model.py` | Factory: config → Pydantic model at runtime | | `fire/starlark/generate_format_spec.py` | Generates FORMAT_SPECIFICATION.md from config | | `fire/starlark/format_spec.bzl` | Bazel rule `generate_format_specification()` | ## Test plan - [x] All 24 existing tests pass (backwards compatibility) - [x] New config_models_test validates config parsing and rejection of invalid configs - [x] New dynamic_requirement_model_test proves equivalence with static models (accept/reject same inputs) - [x] New generate_format_spec_test verifies generated output covers all document types and fields - [x] Full `bazel build //...` succeeds - [x] CI passes on all platforms 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6a9af5a commit 782da36

19 files changed

Lines changed: 1806 additions & 21 deletions

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,76 @@ cat bazel-bin/path/to/RELEASE_REPORT.md
291291
Use `release_readiness_test` as a CI gate — it fails the build when the report
292292
status is `NOT READY FOR RELEASE`.
293293

294+
## Custom Document Types
295+
296+
When no config is provided, FIRE uses three built-in document types
297+
(`.sysreq.md`, `.swreq.md`, `.regreq.md`). You can provide a
298+
`fire_config.yaml` to fully define which document types and fields are
299+
available. A custom config replaces the defaults, so include any
300+
built-in types you still need:
301+
302+
```yaml
303+
fire_config_version: 1
304+
305+
field_definitions:
306+
version:
307+
display_name: "Version"
308+
type: int
309+
min_value: 1
310+
311+
sil:
312+
display_name: "SIL"
313+
type: enum
314+
values: ["ASIL-A", "ASIL-B", "ASIL-C", "ASIL-D", "QM"]
315+
allow_todo: true
316+
317+
document_types:
318+
# Include built-in types you need
319+
sysreq:
320+
suffix: ".sysreq.md"
321+
display_name: "System Requirement"
322+
required_fields: [sil, version]
323+
optional_fields: []
324+
325+
# Add your own types
326+
handbook:
327+
suffix: ".handbook.md"
328+
display_name: "Handbook Entry"
329+
description: "Product handbook entries"
330+
required_fields: [version]
331+
optional_fields: [sil]
332+
```
333+
334+
Pass the config to FIRE rules:
335+
336+
```starlark
337+
load("@fire//fire/starlark:requirements.bzl", "requirement_library")
338+
339+
requirement_library(
340+
name = "handbook_entries",
341+
srcs = glob(["docs/*.handbook.md"]),
342+
config = ":fire_config.yaml",
343+
)
344+
```
345+
346+
The default configuration (matching the built-in types) is at
347+
`@fire//fire/starlark:default_fire_config.yaml`.
348+
349+
### Format Specification
350+
351+
Generate a `FORMAT_SPECIFICATION.md` from your config for use as
352+
documentation or LLM context:
353+
354+
```starlark
355+
load("@fire//fire/starlark:format_spec.bzl", "generate_format_specification")
356+
357+
generate_format_specification(
358+
name = "format_spec",
359+
config = ":fire_config.yaml",
360+
out = "FORMAT_SPECIFICATION.md",
361+
)
362+
```
363+
294364
## Windows Support
295365

296366
FIRE supports Windows with the following limitations:

fire/starlark/BUILD.bazel

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")
22

33
exports_files([
4+
"default_fire_config.yaml",
5+
"format_spec.bzl",
46
"parameters.bzl",
57
"requirements.bzl",
68
"reports.bzl",
@@ -111,6 +113,8 @@ py_binary(
111113
main = "validate_cross_references.py",
112114
visibility = ["//visibility:public"],
113115
deps = [
116+
":config_models",
117+
":dynamic_requirement_model",
114118
":file_io_common",
115119
":markdown_common",
116120
":path_common",
@@ -126,6 +130,7 @@ py_binary(
126130
main = "release_report.py",
127131
visibility = ["//visibility:public"],
128132
deps = [
133+
":config_models",
129134
":markdown_common",
130135
":path_common",
131136
":patterns",
@@ -157,6 +162,81 @@ py_test(
157162
],
158163
)
159164

165+
# FIRE configuration models
166+
py_library(
167+
name = "config_models",
168+
srcs = ["config_models.py"],
169+
visibility = ["//visibility:public"],
170+
deps = [
171+
"@pip//pydantic",
172+
"@pip//pyyaml",
173+
],
174+
)
175+
176+
py_test(
177+
name = "config_models_test",
178+
size = "small",
179+
srcs = ["config_models_test.py"],
180+
deps = [
181+
":config_models",
182+
"@pip//pytest",
183+
"@pip//pyyaml",
184+
],
185+
)
186+
187+
# Dynamic requirement model factory
188+
py_library(
189+
name = "dynamic_requirement_model",
190+
srcs = ["dynamic_requirement_model.py"],
191+
visibility = ["//visibility:public"],
192+
deps = [
193+
":config_models",
194+
":markdown_common",
195+
":patterns",
196+
"@pip//pydantic",
197+
],
198+
)
199+
200+
py_test(
201+
name = "dynamic_requirement_model_test",
202+
size = "small",
203+
srcs = ["dynamic_requirement_model_test.py"],
204+
deps = [
205+
":config_models",
206+
":dynamic_requirement_model",
207+
":requirement_models",
208+
"@pip//pydantic",
209+
"@pip//pytest",
210+
],
211+
)
212+
213+
# Format specification generator
214+
py_library(
215+
name = "generate_format_spec_lib",
216+
srcs = ["generate_format_spec.py"],
217+
visibility = ["//visibility:public"],
218+
deps = [":config_models"],
219+
)
220+
221+
py_binary(
222+
name = "generate_format_spec_script",
223+
srcs = ["generate_format_spec.py"],
224+
main = "generate_format_spec.py",
225+
visibility = ["//visibility:public"],
226+
deps = [":config_models"],
227+
)
228+
229+
py_test(
230+
name = "generate_format_spec_test",
231+
size = "small",
232+
srcs = ["generate_format_spec_test.py"],
233+
deps = [
234+
":config_models",
235+
":generate_format_spec_lib",
236+
"@pip//pytest",
237+
],
238+
)
239+
160240
# Python library for requirement models
161241
py_library(
162242
name = "requirement_models",
@@ -177,6 +257,7 @@ py_library(
177257
srcs = ["release_report.py"],
178258
visibility = ["//visibility:public"],
179259
deps = [
260+
":config_models",
180261
":markdown_common",
181262
":path_common",
182263
":patterns",
@@ -191,6 +272,8 @@ py_library(
191272
srcs = ["validate_cross_references.py"],
192273
visibility = ["//visibility:public"],
193274
deps = [
275+
":config_models",
276+
":dynamic_requirement_model",
194277
":file_io_common",
195278
":markdown_common",
196279
":path_common",

fire/starlark/config_models.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
#!/usr/bin/env python3
2+
"""Pydantic models for FIRE configuration schema.
3+
4+
The configuration defines reusable field definitions and document types.
5+
Consumers supply a fire_config.yaml; when absent, the built-in default
6+
(default_fire_config.yaml) is used.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from pathlib import Path
12+
from typing import List, Literal, Optional
13+
14+
import yaml
15+
from pydantic import BaseModel, Field, field_validator, model_validator
16+
17+
18+
class FieldDefinition(BaseModel):
19+
"""A reusable metadata field that can appear in document types."""
20+
21+
display_name: str
22+
type: Literal["enum", "bool", "int", "parent_link"]
23+
description: str = ""
24+
25+
# enum-specific
26+
values: Optional[List[str]] = None
27+
28+
# int-specific
29+
min_value: Optional[int] = None
30+
31+
# todo support
32+
allow_todo: bool = False
33+
34+
# parent_link-specific
35+
allow_multiple: bool = False
36+
37+
@model_validator(mode="after")
38+
def validate_type_specific_fields(self) -> "FieldDefinition":
39+
if self.type == "enum" and not self.values:
40+
raise ValueError("enum field must specify 'values'")
41+
if self.type != "enum" and self.values is not None:
42+
raise ValueError("'values' is only valid for enum fields")
43+
if self.type != "int" and self.min_value is not None:
44+
raise ValueError("'min_value' is only valid for int fields")
45+
if self.type != "parent_link" and self.allow_multiple:
46+
raise ValueError("'allow_multiple' is only valid for parent_link fields")
47+
return self
48+
49+
50+
class DocumentTypeDefinition(BaseModel):
51+
"""A document type with its file suffix and field requirements."""
52+
53+
suffix: str
54+
display_name: str
55+
description: str = ""
56+
required_fields: List[str] = Field(default_factory=list)
57+
optional_fields: List[str] = Field(default_factory=list)
58+
59+
@field_validator("suffix")
60+
@classmethod
61+
def validate_suffix(cls, v: str) -> str:
62+
if not v.endswith(".md"):
63+
raise ValueError("suffix must end with '.md'")
64+
return v
65+
66+
67+
class FireConfig(BaseModel):
68+
"""Top-level FIRE configuration."""
69+
70+
fire_config_version: int = Field(ge=1)
71+
field_definitions: dict[str, FieldDefinition]
72+
document_types: dict[str, DocumentTypeDefinition]
73+
74+
@model_validator(mode="after")
75+
def validate_field_references(self) -> "FireConfig":
76+
"""Ensure every field referenced by a document type is defined."""
77+
for doc_name, doc_type in self.document_types.items():
78+
for field_name in doc_type.required_fields + doc_type.optional_fields:
79+
if field_name not in self.field_definitions:
80+
raise ValueError(
81+
f"document type '{doc_name}' references undefined "
82+
f"field '{field_name}'"
83+
)
84+
return self
85+
86+
def suffix_to_document_type(self) -> dict[str, DocumentTypeDefinition]:
87+
"""Return a mapping from file suffix to document type definition."""
88+
return {dt.suffix: dt for dt in self.document_types.values()}
89+
90+
def known_fields(self) -> list[str]:
91+
"""Return all known field display names (lowercased) for metadata parsing."""
92+
return [fd.display_name.lower() for fd in self.field_definitions.values()]
93+
94+
95+
_DEFAULT_CONFIG_YAML = """\
96+
fire_config_version: 1
97+
98+
field_definitions:
99+
sil:
100+
display_name: "SIL"
101+
type: enum
102+
values:
103+
- "ASIL-A"
104+
- "ASIL-B"
105+
- "ASIL-C"
106+
- "ASIL-D"
107+
- "SIL-1"
108+
- "SIL-2"
109+
- "SIL-3"
110+
- "SIL-4"
111+
- "DAL-A"
112+
- "DAL-B"
113+
- "DAL-C"
114+
- "DAL-D"
115+
- "DAL-E"
116+
- "PL-a"
117+
- "PL-b"
118+
- "PL-c"
119+
- "PL-d"
120+
- "QM"
121+
allow_todo: true
122+
description: "Safety Integrity Level (ISO 26262, IEC 61508, DO-178C/DO-254, ISO 13849, QM)"
123+
124+
sec:
125+
display_name: "Sec"
126+
type: bool
127+
allow_todo: true
128+
description: "Security relevance flag"
129+
130+
version:
131+
display_name: "Version"
132+
type: int
133+
min_value: 1
134+
allow_todo: false
135+
description: "Requirement version, positive integer >= 1"
136+
137+
parent:
138+
display_name: "Parent"
139+
type: parent_link
140+
allow_todo: true
141+
allow_multiple: true
142+
description: "Markdown link(s) to parent requirement(s)"
143+
144+
document_types:
145+
sysreq:
146+
suffix: ".sysreq.md"
147+
display_name: "System Requirement"
148+
description: "High-level system requirements"
149+
required_fields:
150+
- sil
151+
- sec
152+
- version
153+
optional_fields:
154+
- parent
155+
156+
swreq:
157+
suffix: ".swreq.md"
158+
display_name: "Software Requirement"
159+
description: "Implementation-level software requirements"
160+
required_fields:
161+
- sil
162+
- sec
163+
- version
164+
optional_fields:
165+
- parent
166+
167+
regreq:
168+
suffix: ".regreq.md"
169+
display_name: "Regulatory Requirement"
170+
description: "Regulatory obligations (SIL/Sec optional)"
171+
required_fields:
172+
- version
173+
optional_fields:
174+
- sil
175+
- sec
176+
- parent
177+
"""
178+
179+
180+
def load_config(config_path: str | Path | None = None) -> FireConfig:
181+
"""Load and validate a FIRE configuration file.
182+
183+
When *config_path* is ``None``, the built-in default is used.
184+
"""
185+
if config_path is None:
186+
raw = yaml.safe_load(_DEFAULT_CONFIG_YAML)
187+
else:
188+
config_path = Path(config_path)
189+
with open(config_path, encoding="utf-8") as f:
190+
raw = yaml.safe_load(f)
191+
192+
return FireConfig.model_validate(raw)

0 commit comments

Comments
 (0)