Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,76 @@ cat bazel-bin/path/to/RELEASE_REPORT.md
Use `release_readiness_test` as a CI gate — it fails the build when the report
status is `NOT READY FOR RELEASE`.

## Custom Document Types

When no config is provided, FIRE uses three built-in document types
(`.sysreq.md`, `.swreq.md`, `.regreq.md`). You can provide a
`fire_config.yaml` to fully define which document types and fields are
available. A custom config replaces the defaults, so include any
built-in types you still need:

```yaml
fire_config_version: 1

field_definitions:
version:
display_name: "Version"
type: int
min_value: 1

sil:
display_name: "SIL"
type: enum
values: ["ASIL-A", "ASIL-B", "ASIL-C", "ASIL-D", "QM"]
allow_todo: true

document_types:
# Include built-in types you need
sysreq:
suffix: ".sysreq.md"
display_name: "System Requirement"
required_fields: [sil, version]
optional_fields: []

# Add your own types
handbook:
suffix: ".handbook.md"
display_name: "Handbook Entry"
description: "Product handbook entries"
required_fields: [version]
optional_fields: [sil]
```

Pass the config to FIRE rules:

```starlark
load("@fire//fire/starlark:requirements.bzl", "requirement_library")

requirement_library(
name = "handbook_entries",
srcs = glob(["docs/*.handbook.md"]),
config = ":fire_config.yaml",
)
```

The default configuration (matching the built-in types) is at
`@fire//fire/starlark:default_fire_config.yaml`.

### Format Specification

Generate a `FORMAT_SPECIFICATION.md` from your config for use as
documentation or LLM context:

```starlark
load("@fire//fire/starlark:format_spec.bzl", "generate_format_specification")

generate_format_specification(
name = "format_spec",
config = ":fire_config.yaml",
out = "FORMAT_SPECIFICATION.md",
)
```

## Windows Support

FIRE supports Windows with the following limitations:
Expand Down
83 changes: 83 additions & 0 deletions fire/starlark/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
load("@rules_python//python:defs.bzl", "py_binary", "py_library", "py_test")

exports_files([
"default_fire_config.yaml",
"format_spec.bzl",
"parameters.bzl",
"requirements.bzl",
"reports.bzl",
Expand Down Expand Up @@ -111,6 +113,8 @@ py_binary(
main = "validate_cross_references.py",
visibility = ["//visibility:public"],
deps = [
":config_models",
":dynamic_requirement_model",
":file_io_common",
":markdown_common",
":path_common",
Expand All @@ -126,6 +130,7 @@ py_binary(
main = "release_report.py",
visibility = ["//visibility:public"],
deps = [
":config_models",
":markdown_common",
":path_common",
":patterns",
Expand Down Expand Up @@ -157,6 +162,81 @@ py_test(
],
)

# FIRE configuration models
py_library(
name = "config_models",
srcs = ["config_models.py"],
visibility = ["//visibility:public"],
deps = [
"@pip//pydantic",
"@pip//pyyaml",
],
)

py_test(
name = "config_models_test",
size = "small",
srcs = ["config_models_test.py"],
deps = [
":config_models",
"@pip//pytest",
"@pip//pyyaml",
],
)

# Dynamic requirement model factory
py_library(
name = "dynamic_requirement_model",
srcs = ["dynamic_requirement_model.py"],
visibility = ["//visibility:public"],
deps = [
":config_models",
":markdown_common",
":patterns",
"@pip//pydantic",
],
)

py_test(
name = "dynamic_requirement_model_test",
size = "small",
srcs = ["dynamic_requirement_model_test.py"],
deps = [
":config_models",
":dynamic_requirement_model",
":requirement_models",
"@pip//pydantic",
"@pip//pytest",
],
)

# Format specification generator
py_library(
name = "generate_format_spec_lib",
srcs = ["generate_format_spec.py"],
visibility = ["//visibility:public"],
deps = [":config_models"],
)

py_binary(
name = "generate_format_spec_script",
srcs = ["generate_format_spec.py"],
main = "generate_format_spec.py",
visibility = ["//visibility:public"],
deps = [":config_models"],
)

py_test(
name = "generate_format_spec_test",
size = "small",
srcs = ["generate_format_spec_test.py"],
deps = [
":config_models",
":generate_format_spec_lib",
"@pip//pytest",
],
)

# Python library for requirement models
py_library(
name = "requirement_models",
Expand All @@ -177,6 +257,7 @@ py_library(
srcs = ["release_report.py"],
visibility = ["//visibility:public"],
deps = [
":config_models",
":markdown_common",
":path_common",
":patterns",
Expand All @@ -191,6 +272,8 @@ py_library(
srcs = ["validate_cross_references.py"],
visibility = ["//visibility:public"],
deps = [
":config_models",
":dynamic_requirement_model",
":file_io_common",
":markdown_common",
":path_common",
Expand Down
192 changes: 192 additions & 0 deletions fire/starlark/config_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""Pydantic models for FIRE configuration schema.

The configuration defines reusable field definitions and document types.
Consumers supply a fire_config.yaml; when absent, the built-in default
(default_fire_config.yaml) is used.
"""

from __future__ import annotations

from pathlib import Path
from typing import List, Literal, Optional

import yaml
from pydantic import BaseModel, Field, field_validator, model_validator


class FieldDefinition(BaseModel):
"""A reusable metadata field that can appear in document types."""

display_name: str
type: Literal["enum", "bool", "int", "parent_link"]
description: str = ""

# enum-specific
values: Optional[List[str]] = None

# int-specific
min_value: Optional[int] = None

# todo support
allow_todo: bool = False

# parent_link-specific
allow_multiple: bool = False

@model_validator(mode="after")
def validate_type_specific_fields(self) -> "FieldDefinition":
if self.type == "enum" and not self.values:
raise ValueError("enum field must specify 'values'")
if self.type != "enum" and self.values is not None:
raise ValueError("'values' is only valid for enum fields")
if self.type != "int" and self.min_value is not None:
raise ValueError("'min_value' is only valid for int fields")
if self.type != "parent_link" and self.allow_multiple:
raise ValueError("'allow_multiple' is only valid for parent_link fields")
return self


class DocumentTypeDefinition(BaseModel):
"""A document type with its file suffix and field requirements."""

suffix: str
display_name: str
description: str = ""
required_fields: List[str] = Field(default_factory=list)
optional_fields: List[str] = Field(default_factory=list)

@field_validator("suffix")
@classmethod
def validate_suffix(cls, v: str) -> str:
if not v.endswith(".md"):
raise ValueError("suffix must end with '.md'")
return v


class FireConfig(BaseModel):
"""Top-level FIRE configuration."""

fire_config_version: int = Field(ge=1)
field_definitions: dict[str, FieldDefinition]
document_types: dict[str, DocumentTypeDefinition]

@model_validator(mode="after")
def validate_field_references(self) -> "FireConfig":
"""Ensure every field referenced by a document type is defined."""
for doc_name, doc_type in self.document_types.items():
for field_name in doc_type.required_fields + doc_type.optional_fields:
if field_name not in self.field_definitions:
raise ValueError(
f"document type '{doc_name}' references undefined "
f"field '{field_name}'"
)
return self

def suffix_to_document_type(self) -> dict[str, DocumentTypeDefinition]:
"""Return a mapping from file suffix to document type definition."""
return {dt.suffix: dt for dt in self.document_types.values()}

def known_fields(self) -> list[str]:
"""Return all known field display names (lowercased) for metadata parsing."""
return [fd.display_name.lower() for fd in self.field_definitions.values()]


_DEFAULT_CONFIG_YAML = """\
fire_config_version: 1

field_definitions:
sil:
display_name: "SIL"
type: enum
values:
- "ASIL-A"
- "ASIL-B"
- "ASIL-C"
- "ASIL-D"
- "SIL-1"
- "SIL-2"
- "SIL-3"
- "SIL-4"
- "DAL-A"
- "DAL-B"
- "DAL-C"
- "DAL-D"
- "DAL-E"
- "PL-a"
- "PL-b"
- "PL-c"
- "PL-d"
- "QM"
allow_todo: true
description: "Safety Integrity Level (ISO 26262, IEC 61508, DO-178C/DO-254, ISO 13849, QM)"

sec:
display_name: "Sec"
type: bool
allow_todo: true
description: "Security relevance flag"

version:
display_name: "Version"
type: int
min_value: 1
allow_todo: false
description: "Requirement version, positive integer >= 1"

parent:
display_name: "Parent"
type: parent_link
allow_todo: true
allow_multiple: true
description: "Markdown link(s) to parent requirement(s)"

document_types:
sysreq:
suffix: ".sysreq.md"
display_name: "System Requirement"
description: "High-level system requirements"
required_fields:
- sil
- sec
- version
optional_fields:
- parent

swreq:
suffix: ".swreq.md"
display_name: "Software Requirement"
description: "Implementation-level software requirements"
required_fields:
- sil
- sec
- version
optional_fields:
- parent

regreq:
suffix: ".regreq.md"
display_name: "Regulatory Requirement"
description: "Regulatory obligations (SIL/Sec optional)"
required_fields:
- version
optional_fields:
- sil
- sec
- parent
"""


def load_config(config_path: str | Path | None = None) -> FireConfig:
"""Load and validate a FIRE configuration file.

When *config_path* is ``None``, the built-in default is used.
"""
if config_path is None:
raw = yaml.safe_load(_DEFAULT_CONFIG_YAML)
else:
config_path = Path(config_path)
with open(config_path, encoding="utf-8") as f:
raw = yaml.safe_load(f)

return FireConfig.model_validate(raw)
Loading
Loading