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>
113 lines
3.1 KiB
Python
Executable File
113 lines
3.1 KiB
Python
Executable File
"""
|
|
Task data access layer.
|
|
|
|
Single source of truth for loading and iterating task directories.
|
|
Replaces scattered task.json parsing across 9+ files.
|
|
|
|
Provides:
|
|
load_task — Load a single task by directory path
|
|
iter_active_tasks — Iterate all non-archived tasks (sorted)
|
|
get_all_statuses — Get {dir_name: status} map for children progress
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
from .io import read_json
|
|
from .paths import FILE_TASK_JSON
|
|
from .types import TaskInfo
|
|
|
|
|
|
def load_task(task_dir: Path) -> TaskInfo | None:
|
|
"""Load task from a directory containing task.json.
|
|
|
|
Args:
|
|
task_dir: Absolute path to the task directory.
|
|
|
|
Returns:
|
|
TaskInfo if task.json exists and is valid, None otherwise.
|
|
"""
|
|
task_json = task_dir / FILE_TASK_JSON
|
|
if not task_json.is_file():
|
|
return None
|
|
|
|
data = read_json(task_json)
|
|
if not data:
|
|
return None
|
|
|
|
return TaskInfo(
|
|
dir_name=task_dir.name,
|
|
directory=task_dir,
|
|
title=data.get("title") or data.get("name") or "unknown",
|
|
status=data.get("status", "unknown"),
|
|
assignee=data.get("assignee", ""),
|
|
priority=data.get("priority", "P2"),
|
|
children=tuple(data.get("children", [])),
|
|
parent=data.get("parent"),
|
|
package=data.get("package"),
|
|
raw=data,
|
|
)
|
|
|
|
|
|
def iter_active_tasks(tasks_dir: Path) -> Iterator[TaskInfo]:
|
|
"""Iterate all active (non-archived) tasks, sorted by directory name.
|
|
|
|
Skips the "archive" directory and directories without valid task.json.
|
|
|
|
Args:
|
|
tasks_dir: Path to the tasks directory.
|
|
|
|
Yields:
|
|
TaskInfo for each valid task.
|
|
"""
|
|
if not tasks_dir.is_dir():
|
|
return
|
|
|
|
for d in sorted(tasks_dir.iterdir()):
|
|
if not d.is_dir() or d.name == "archive":
|
|
continue
|
|
info = load_task(d)
|
|
if info is not None:
|
|
yield info
|
|
|
|
|
|
def get_all_statuses(tasks_dir: Path) -> dict[str, str]:
|
|
"""Get a {dir_name: status} mapping for all active tasks.
|
|
|
|
Useful for computing children progress without loading full TaskInfo.
|
|
|
|
Args:
|
|
tasks_dir: Path to the tasks directory.
|
|
|
|
Returns:
|
|
Dict mapping directory names to status strings.
|
|
"""
|
|
return {t.dir_name: t.status for t in iter_active_tasks(tasks_dir)}
|
|
|
|
|
|
def children_progress(
|
|
children: tuple[str, ...] | list[str],
|
|
all_statuses: dict[str, str],
|
|
) -> str:
|
|
"""Format children progress string like " [2/3 done]".
|
|
|
|
Args:
|
|
children: List of child directory names.
|
|
all_statuses: Status map from get_all_statuses().
|
|
|
|
Returns:
|
|
Formatted string, or "" if no children.
|
|
"""
|
|
if not children:
|
|
return ""
|
|
# A child missing from active statuses has been archived (cmd_archive
|
|
# sets status=completed before moving the dir). Count it as done so
|
|
# parent progress doesn't regress when children are archived.
|
|
done = sum(
|
|
1 for c in children
|
|
if c not in all_statuses or all_statuses.get(c) in ("completed", "done")
|
|
)
|
|
return f" [{done}/{len(children)} done]"
|