feat: make crosswalk and stop-line offsets solvable

Step 1 of the control-marking task, and deliberately server-only: no handle is
drawn yet. This project already shipped a range handle for `profile.interval`,
which compileGeometry ignores, so the control dragged and changed nothing. The
consumer comes first now.

Two kinds join the taxonomy — `junction-crosswalk-inset` and
`junction-stop-line-offset`, both on the existing `junction-approach` anchor. The
solver writes them onto the approach entry, `applyDirectJunctionPlans` carries
them onto the compiled approach, and `compileControlMarkings` reads them in place
of the module constants it used for every junction. They move markings without
reshaping the junction, so unlike width and cutback they deliberately do not
trigger a boundary recompute.

`applyJunctionConstraint` becomes an explicit switch. Its trailing `else` had
meant every kind that was not approach-width fell through to the cutback
validator, so a new kind would have been silently validated and written as a
cutback. The same non-exhaustive shape in the test fixture's `valueFor` is fixed
the same way, and now throws for an unnamed kind rather than answering with a
corner radius.

design.md's taxonomy is updated with it — a test asserts the two cannot drift,
which is what caught the omission.

Measured on a 41-road workspace with 8 crossings: both constraints change their
marking geometry, neither drags the other, and out-of-range blocks instead of
clamping. That measurement is not in the suite: the synthetic junction resolves
`junction_inset_m` to 0 because its crossing never binds to a plan, and the
committed OSM fixture has no crossings at all. The tests assert the wiring the
handles will depend on — values reaching the approach entry, distinct branches,
blocking diagnostics — and the gap is recorded in the test itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 15:01:36 +08:00
parent 813809d25e
commit c7425f5ed4
47 changed files with 10496 additions and 28 deletions

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
Standalone reader for .trellis/config.yaml.
Mirrors a minimal subset of common.config so callers (hooks, workflow_phase)
can read configuration without importing the full task/repo helpers. Returns
an empty dict on missing/malformed files so callers stay simple.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
CONFIG_REL_PATH = ".trellis/config.yaml"
def _unquote(value: str) -> str:
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
return value[1:-1]
return value
def _strip_inline_comment(value: str) -> str:
"""Strip ` # …` inline comments while preserving `#` inside quoted strings.
YAML treats ` #` (space-hash) as a comment opener; bare `#` inside a token
is part of the value. Quoted strings are immune.
"""
in_quote: str | None = None
for idx, ch in enumerate(value):
if in_quote:
if ch == in_quote:
in_quote = None
continue
if ch in ('"', "'"):
in_quote = ch
continue
if ch == "#" and (idx == 0 or value[idx - 1].isspace()):
return value[:idx]
return value
def _next_content_line(lines: list[str], start: int) -> tuple[int, str]:
i = start
while i < len(lines):
stripped = lines[i].strip()
if stripped and not stripped.startswith("#"):
return i, lines[i]
i += 1
return i, ""
def _parse_yaml_block(
lines: list[str], start: int, min_indent: int, target: dict
) -> int:
i = start
current_list: list | None = None
while i < len(lines):
line = lines[i]
stripped = line.strip()
if not stripped or stripped.startswith("#"):
i += 1
continue
indent = len(line) - len(line.lstrip())
if indent < min_indent:
break
if stripped.startswith("- "):
if current_list is not None:
current_list.append(_unquote(stripped[2:].strip()))
i += 1
elif ":" in stripped:
key, _, value = stripped.partition(":")
key = key.strip()
value = _strip_inline_comment(value).strip()
was_quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
value = _unquote(value)
current_list = None
if value or was_quoted:
target[key] = value
i += 1
else:
next_i, next_line = _next_content_line(lines, i + 1)
if next_i >= len(lines):
target[key] = {}
i = next_i
elif next_line.strip().startswith("- "):
current_list = []
target[key] = current_list
i += 1
else:
next_indent = len(next_line) - len(next_line.lstrip())
if next_indent > indent:
nested: dict = {}
target[key] = nested
i = _parse_yaml_block(lines, i + 1, next_indent, nested)
else:
target[key] = {}
i += 1
else:
i += 1
return i
def parse_simple_yaml(content: str) -> dict:
"""Parse a small subset of YAML. See common.config for full doc."""
lines = content.splitlines()
result: dict = {}
_parse_yaml_block(lines, 0, 0, result)
return result
def read_trellis_config(repo_root: Optional[Path] = None) -> dict:
"""Read .trellis/config.yaml. Returns {} on missing or malformed file."""
root = repo_root or Path.cwd()
config_file = root / CONFIG_REL_PATH
try:
content = config_file.read_text(encoding="utf-8")
except (FileNotFoundError, OSError):
return {}
try:
parsed = parse_simple_yaml(content)
except Exception:
return {}
return parsed if isinstance(parsed, dict) else {}