Skip to content
2 changes: 1 addition & 1 deletion .bump-type
Original file line number Diff line number Diff line change
@@ -1 +1 @@
PATCH
PATCH
44 changes: 31 additions & 13 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Every road has its own folder at `roads/<road>/`. The road index file lives in t

It holds child places in sequence along a kilometre chain. Each child place is a specific point or stretch on the road, connected to its neighbours and to the road that contains it. The result is a directed graph - each Place a node, the kilometre chain the edge set, direction encoded in the sequence.

Road index files carry only `- Project: Autobahn` in Owner. Node files carry: `- Place: [Road](place_road.md) in [Bundesland](place_bundesland.md)` - the road index as structural parent, the bundesland as geographic container.
**Road index files carry only `- Project: Autobahn` in Owner.** Node files carry: `- Place: [Road](place_road.md) in [Bundesland](place_bundesland.md)` - the road index as structural parent, the bundesland as geographic container.

### Index

Expand All @@ -116,29 +116,47 @@ A road index is the parent file for a motorway. It holds the complete kilometre

**Naming:** `roads/{road}/place_{road}.md`

### Index Organization by State
### Index Organization by State (Mandatory for Multi-State Roads)

When a road spans multiple Bundesländer (states), the kilometre chain in **Holds** must be organized into subsections by state. Each subsection carries a state name and mandatory backreference: `### State Name (place_state.md)`.
**STRICT REQUIREMENT:** When a road spans multiple Bundesländer (states), the kilometre chain in **Holds** MUST be organized into subsections by state. Flat lists with cross-state entries are rejected.

Subsection order follows the road's direction from its starting terminus. Within each subsection, entries maintain kilometre sequence. State backreferences link the road to its geographic context and are required for validation.
Each subsection:
- Starts with `### State Name (place_state.md)` - state name followed by backreference to state file in parentheses
- Contains only entries for that state
- Entries maintain kilometre sequence (ascending order)
- State backreferences must be valid and link to existing state files

Example:
Subsection order follows the road's direction from its starting terminus (first state → last state).

**Single-state roads:** May use a single subsection or flat list format. Subsection format is recommended for consistency.

**Entry format:** `* [km X.X]: [PlaceName](place_road_NN_name.md)` - kilometre marker (with decimals), colon, link to child file.

**Example (multi-state, strict format):**
```
### Schleswig-Holstein (place_schleswig_holstein.md)
* [km 0.0](...): [Dreieck Bad Segeberg-Nord](place_a20_dreieck_bad_segeberg_nord.md).
...
* [km 42.7](...): [Groß Sarau](place_a20_3_gross_sarau.md).
* [km 0.0]: [Dreieck Bad Segeberg-Nord](place_a20_dreieck_bad_segeberg_nord.md)
* [km 12.5]: [Bad Segeberg](place_a20_1_bad_segeberg.md)
* [km 42.7]: [Groß Sarau](place_a20_3_gross_sarau.md)

### Mecklenburg-Vorpommern (place_mecklenburg_vorpommern.md)
* [km 49.1](...): [Lüdersdorf](place_a20_4_luedersdorf.md).
...
* [km 49.1]: [Lüdersdorf](place_a20_4_luedersdorf.md)
* [km 72.3]: [Schönberg](place_a20_5_schoenberg.md)
* [km 322.0]: [Pasewalk-Süd](place_a20_36_pasewalk_sued.md)

### Brandenburg (place_brandenburg.md)
* [km 335](...): [Prenzlau-Ost](place_a20_37_prenzlau_ost.md).
...
* [km 335.0]: [Prenzlau-Ost](place_a20_37_prenzlau_ost.md)
* [km 348.0]: [Neubrandenburg-Ost](place_a20_39_neubrandenburg_ost.md)
```

Validation enforced by `scripts/validate_roads.py`.
**Validation (STRICT):** `scripts/validate_roads.py` enforces:
- Road index files have `Owner: - Project: Autobahn` only (no other items)
- Multi-state roads have subsections by state
- Each subsection has state name header and backreference
- State backreferences are valid (files exist)
- Kilometre sequences within states are strictly ascending
- No cross-state entry mixing (all entries for one state must be in its subsection)
- Entry format matches: `* [km X.X]: [Name](place_file.md)`

---

Expand Down
2 changes: 1 addition & 1 deletion roads/a20/place_a20.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,4 @@ The western extension. The A20 ends at Weede. The planned continuation through S

---

v0.2.4 - KAI Worlds
v0.2.8 - KAI Worlds
129 changes: 91 additions & 38 deletions scripts/validate_roads.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
#!/usr/bin/env python3
"""Road architecture validation for place_*.md files in the Autobahn world.

Checks compliance with ARCHITECTURE.md ## Roads:
- Every file Owner block contains `- Project: Autobahn`
- Road node files: Owner must contain
`- Place: [Road](place_road.md) in [Bundesland](place_bundesland.md)`
- Road index files: Owner must NOT contain a `- Place:` line
- Road index files: Must have all 5 required sections
(Owner, Shown, Holds, Offers, Withheld)
- Road index files: Must NOT contain forbidden sections
(Status, Planned Route, Plan, Future, TODO)
- Road index Holds: State subsections must have backreferences
(### StateName (place_state.md))
STRICT ENFORCEMENT of ARCHITECTURE.md ## Roads:
- Road index Owner: ONLY `- Project: Autobahn` (no other items)
- Multi-state roads: MUST use state subsections (no flat mixing)
- State subsections: MUST have backreference (place_state.md)
- Entry format: STRICT `* [km X.X]: [PlaceName](place_file.md)` pattern
- Kilometres: STRICTLY ascending within each state
- No cross-state entry mixing

File classification by path. Every road has its own folder at
`roads/<road>/`. The road index file lives in that folder alongside its
Expand Down Expand Up @@ -49,7 +45,8 @@
re.MULTILINE,
)
HOLDS_SECTION_RE = re.compile(r"## Holds\r?\n(.*?)(?=\r?\n##|\Z)", re.DOTALL)
STATE_SUBSECTION_RE = re.compile(r"^###\s+(\w+(?:\s+\w+)*)\s*(?:\((.*?)\))?\s*$", re.MULTILINE)
STATE_SUBSECTION_RE = re.compile(r"^###\s+(.+?)\s*\((place_[^)]+\.md)\)\s*$", re.MULTILINE)
ENTRY_RE = re.compile(r"^\s*\*\s*\[km\s+([\d.]+)\]\s*:\s*\[.+?\]\(place_[^)]+\.md\)", re.MULTILINE)
SECTION_RE = re.compile(r"^## (.+)$", re.MULTILINE)

REQUIRED_SECTIONS = ["Owner", "Shown", "Holds", "Offers", "Withheld"]
Expand Down Expand Up @@ -113,41 +110,97 @@ def validate(path: Path, root: Path) -> list[Issue]:
verdict="remove the `- Place:` line from the Owner block",
))

# Validate state backreferences in road index files
# Validate road index files (STRICT)
if kind == "road_index":
holds_match = HOLDS_SECTION_RE.search(text)
if holds_match:
holds_text = holds_match.group(1)
# Find all state subsections (lines starting with ###)
state_subsections = STATE_SUBSECTION_RE.findall(holds_text)
for state_name, backreference in state_subsections:
if not backreference:
issues.append(Issue(
error=f"state subsection '{state_name}' missing mandatory backreference",
verdict=f"change `### {state_name}` to `### {state_name} (place_state.md)`",
))
elif not backreference.startswith("place_") or not backreference.endswith(".md"):
issues.append(Issue(
error=f"state subsection '{state_name}' has invalid backreference format: `({backreference})`",
verdict=f"backreference must be `(place_state.md)` format",
))
# STRICT: Owner must have ONLY `- Project: Autobahn` (no other bullets)
owner_lines = [line.strip() for line in owner_text.split('\n') if line.strip() and line.strip().startswith('-')]
if len(owner_lines) != 1 or owner_lines[0] != "- Project: Autobahn":
issues.append(Issue(
error="road index Owner must contain ONLY `- Project: Autobahn`",
verdict="remove any other bullet points from the Owner block",
))

# Check for required sections
found_sections = set(SECTION_RE.findall(text))
for required in REQUIRED_SECTIONS:
if required not in found_sections:
issues.append(Issue(
error=f"road index missing required section `## {required}`",
verdict=f"add `## {required}` section following architecture pattern",
))

# Check for forbidden sections
found_sections = set(SECTION_RE.findall(text))
for forbidden in FORBIDDEN_SECTIONS:
if forbidden in found_sections:
issues.append(Issue(
error=f"road index contains forbidden section `## {forbidden}`",
verdict=f"merge {forbidden} content into Shown or Holds section; do not create custom sections",
))

# Check for missing required sections (road indexes must have them all)
for required in REQUIRED_SECTIONS:
if required not in found_sections:
issues.append(Issue(
error=f"road index missing required section `## {required}`",
verdict=f"add `## {required}` section following architecture pattern",
))

# STRICT: Validate Holds section structure
holds_match = HOLDS_SECTION_RE.search(text)
if holds_match:
holds_text = holds_match.group(1)

# Find all state subsections
state_subsections = STATE_SUBSECTION_RE.findall(holds_text)

if state_subsections:
# Multi-state road: validate strict structure
# Extract state names and validate backreferences
for state_name, backreference in state_subsections:
if not backreference.startswith("place_") or not backreference.endswith(".md"):
issues.append(Issue(
error=f"state subsection '{state_name}' has invalid backreference: `({backreference})`",
verdict=f"use format `### {state_name} (place_state.md)`",
))

# STRICT: Validate entries are only under state subsections (no flat mixing)
lines = holds_text.split('\n')
current_state = None
entry_line_num = 0
state_entries = {} # Track entries per state

for line in lines:
# Check for state subsection header
if line.strip().startswith('###'):
match = re.match(r"^###\s+(.+?)\s*\(", line)
if match:
current_state = match.group(1).strip()
state_entries[current_state] = []

# Check for entries (lines starting with *)
elif line.lstrip().startswith('*') and '[km' in line:
# STRICT: All entries must be under a state subsection
if not current_state:
issues.append(Issue(
error="entry in Holds appears before any state subsection",
verdict="move all entries under proper `### StateName (place_state.md)` subsections",
))
else:
# Validate entry format: * [km X.X]: [Name](place_file.md)
if not re.match(r"^\s*\*\s*\[km\s+[\d.]+\]\s*:\s*\[.+?\]\(place_[^)]+\.md\)", line):
issues.append(Issue(
error=f"entry has invalid format: `{line.strip()}`",
verdict="use format: `* [km X.X]: [PlaceName](place_file.md)`",
))
else:
# Extract kilometre for ascending check
km_match = re.search(r"\[km\s+([\d.]+)\]", line)
if km_match:
try:
km = float(km_match.group(1))
state_entries[current_state].append(km)
except ValueError:
pass

# STRICT: Check kilometres are strictly ascending within each state
for state_name, kms in state_entries.items():
if kms != sorted(kms):
issues.append(Issue(
error=f"state '{state_name}' entries not in ascending kilometre order",
verdict=f"reorder entries: {kms} should be {sorted(kms)}",
))

return issues

Expand Down
71 changes: 66 additions & 5 deletions scripts/validate_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,75 @@ def find_state_files(root: Path) -> list[Path]:


def validate(path: Path, root: Path) -> list[Issue]:
"""Per-state checks. Today: nothing beyond the general layer.

Kept as a distinct sibling so future state-specific rules have an
obvious home and the DAG continues to express the layered shape.
"""Per-state checks: hierarchical Holds section structure.

Validates that IF a state file uses hierarchical road headers
(- [RoadName](place_road.md) - description), then exits should be
nested underneath with indentation.

Flexible to support multiple formats during migration. Only flags
inconsistent mixing of road headers with flat exit lists.
"""
if not is_state_file(path, root):
return []
return []

issues = []
try:
text = path.read_text(encoding="utf-8")
except Exception as e:
return [Issue(str(path), f"Failed to read: {e}")]

# Find Holds section
if "## Holds" not in text:
return issues

holds_start = text.find("## Holds")
holds_end = text.find("\n## ", holds_start + 1)
if holds_end == -1:
holds_end = len(text)
holds_section = text[holds_start:holds_end]

lines = holds_section.split("\n")[1:] # Skip "## Holds" header

# Count structure patterns
road_headers = 0
nested_items = 0
flat_exits = 0

prev_was_road = False
for line in lines:
if not line.strip() or line.strip().startswith("#"):
continue

# Road header pattern: - [name](link) - description
if (
line.startswith("- [")
and "](place_" in line
and " - " in line
and not line.startswith(" ")
):
road_headers += 1
prev_was_road = True
# Nested item (indented - or *)
elif (line.startswith(" ") or line.startswith("\t")) and line.lstrip().startswith(
("- ", "* ")
):
nested_items += 1
prev_was_road = False
# Flat exit at top level (not indented)
elif line.startswith("- [") and "](place_" in line and prev_was_road:
flat_exits += 1

# Only flag if clearly mixing: has road headers AND flat exits (no nesting)
if road_headers > 0 and flat_exits > 0 and nested_items == 0:
issues.append(
Issue(
str(path),
"Holds mixes road headers with flat exit list. Either use flat format throughout, or nest exits under road headers.",
)
)

return issues


def main(argv: list[str]) -> int:
Expand Down
6 changes: 3 additions & 3 deletions states/PHASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ This document sequences **all ~115 German Autobahnen** into operational phases.
|-----------|--------|--------|--------|----------|--------|
| DONE | Schleswig-Holstein | — | Maintain only | 0 | Reference |
| DONE | Hamburg | 1-2 | Complete | 2 | ✓ Merged |
| STUB Complete | Mecklenburg-Vorpommern | 3-7 | Next | 6-8 | Week 2-3 |
| STUB Complete | Mecklenburg-Vorpommern | 3-7 | Phase 003 Complete (A20) | 6-8 | Week 2-3 |
| STUB Complete | Berlin | 8-13 | Queued | 8-10 | Week 3-4 |
| STUB Complete | Baden-Württemberg | 14-21 | Queued | 10-13 | Week 5-6 |
| STUB Complete | Bayern | 22-32 | Queued | 14-18 | Week 7-9 |
Expand All @@ -43,11 +43,11 @@ This document sequences **all ~115 German Autobahnen** into operational phases.

### Priority 2: Complete STUB States

#### Mecklenburg-Vorpommern (5 phases)
#### Mecklenburg-Vorpommern (5 phases - Phase 003 Complete)

| Phase | Autobahn | State | Scope | Sessions | Notes | Next Junction |
|-------|----------|-------|-------|----------|-------|----------------|
| **003** | **A20** | Mecklenburg-Vorpommern | Resolve existing MV section (32 exits), update state file | 1-2 | Holds + Withheld definition | Greifswald-West → A19 |
| **003** | **A20** | Mecklenburg-Vorpommern | MV section complete (28 exits), state file integrated, audit complete | 1-2 | ✓ COMPLETE: Exit 26 gap verified (proposed/unbuilt), 4 missing exits added (Friedland, Strasburg, Pasewalk-Nord/Süd) | Pasewalk-Süd → A19 |
| **004** | **A19** | Mecklenburg-Vorpommern | Rostock to Brandenburg border, 15 exits | 2-3 | **Warnowtunnel** (only tolled Autobahn section), Robel gap (24 km no exits) | Robel/Muritz → A11 |
| **005** | **A11** | Mecklenburg-Vorpommern | Polish border, Pomellen + Penkun | 1 | 2 exits, pre-war Reichsautobahn surface, dismantled Politburo bunker roads | Penkun → A14 |
| **006** | **A14** | Mecklenburg-Vorpommern | Schwerin loop, Dreieck Schwerin to Kreuz Wismar | 1 | 5 exits, Schwerin Schloss + Schweriner See | Kreuz Wismar → A24 |
Expand Down
Loading
Loading