feat(systems): a System names its files — path patterns stored, validated and matched (milestone 444 step 3, #4756)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m6s
CI & Build / Python tests (push) Successful in 1m50s
CI & Build / Build & push image (push) Successful in 43s

A System gains path_patterns: globs relative to the repo root (* within one
directory, ** across any depth, a plain directory covering everything under
it). One service validates them for every door, so the web UI and MCP refuse
the same bad pattern with the same message. systems_for_paths resolves paths
to every active System that covers them, which step 4 (#4757) uses to deliver
an area's rulings when its files are touched.

- schema: systems.path_patterns JSONB NOT NULL default [] (migration 0113)
- service: normalize_path_patterns, path_matches, systems_for_paths
- routes + MCP create_system/update_system accept it; [] clears
- web UI: a Files field in the create and edit forms, patterns on the card
- backup carries it through export and restore
- using-scribe reflex 7: tagging work keeps a System's files current

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 22:55:26 -04:00
co-authored by Claude Opus 5.5
parent 01d8a0b9f1
commit a113c72b4f
14 changed files with 435 additions and 17 deletions
+3
View File
@@ -387,6 +387,7 @@ def _system_rows(rows, canonical_slugs: dict[int, str]) -> list[dict]:
"id": r.id, "user_id": r.user_id, "project_id": r.project_id,
"name": r.name, "description": r.description, "color": r.color,
"status": r.status, "order_index": r.order_index,
"path_patterns": list(r.path_patterns or []),
"canonical_slug": canonical_slugs.get(r.canonical_id or 0),
}
for r in rows
@@ -1460,6 +1461,8 @@ def _build_system(row: dict, maps: _Maps) -> System | None:
color=row.get("color"),
status=row.get("status", "active"),
order_index=row.get("order_index", 0),
# Absent in a backup taken before milestone 444: no paths, not an error.
path_patterns=list(row.get("path_patterns") or []),
# An unknown slug restores UNMAPPED rather than failing: the System and
# its records are the payload, the mapping is an aid.
canonical_id=maps.canonical_by_slug.get(row.get("canonical_slug") or ""),
+116 -1
View File
@@ -7,6 +7,7 @@ many-to-many through record_systems, mutable over time.
"""
import logging
from datetime import datetime, timezone
from pathlib import PurePosixPath
from sqlalchemy import delete, func, select
@@ -31,6 +32,87 @@ def local_name_key(name: str) -> str:
return " ".join(name.split()).lower()
# Bounds on what one System may claim. Generous for a real area — a handful of
# directories and the odd stray file — and small enough that a pasted file
# listing is refused rather than stored as the area's definition.
MAX_PATH_PATTERNS = 50
MAX_PATH_PATTERN_LENGTH = 300
def normalize_repo_path(path: str) -> str:
"""A path as the patterns see it: relative to the repo root, `/`-separated.
Shared by the patterns and the paths matched against them, so the two can
never disagree about whether `./src/x.py` and `src/x.py` are one file.
"""
out = (path or "").strip().replace("\\", "/")
while out.startswith("./"):
out = out[2:]
out = out.lstrip("/")
while len(out) > 1 and out.endswith("/"):
out = out[:-1]
return out
def normalize_path_patterns(patterns) -> list[str]:
"""Validate and tidy a System's path patterns; ValueError says what is wrong.
Blank entries are dropped and repeats collapse, keeping the first-written
order. A pattern that climbs out of the repo (`..`) is refused rather than
dropped: it can never match, and storing it would read as coverage.
"""
if patterns is None:
return []
if isinstance(patterns, str) or not isinstance(patterns, (list, tuple)):
raise ValueError("path_patterns must be a list of glob strings")
out: list[str] = []
for raw in patterns:
if not isinstance(raw, str):
raise ValueError("path_patterns must be a list of glob strings")
pattern = normalize_repo_path(raw)
if not pattern:
continue
if len(pattern) > MAX_PATH_PATTERN_LENGTH:
raise ValueError(
f"path pattern longer than {MAX_PATH_PATTERN_LENGTH} characters: "
f"{pattern[:60]}…"
)
if ".." in pattern.split("/"):
raise ValueError(
f"path pattern {pattern!r} leaves the repo — patterns are "
"relative to the repo root"
)
if pattern not in out:
out.append(pattern)
if len(out) > MAX_PATH_PATTERNS:
raise ValueError(
f"{len(out)} path patterns; a System takes at most "
f"{MAX_PATH_PATTERNS} — name directories with `**` rather than "
"listing their files"
)
return out
def path_matches(pattern: str, path: str) -> bool:
"""Whether a repo-relative path falls under one pattern.
Glob semantics are `PurePosixPath.full_match`: `*` stays inside one path
segment, `**` spans any number of them, and case counts. A pattern with
no wildcard that names a directory covers everything under it, so
`src/billing` means the directory the way a person writing it means it.
"""
path = normalize_repo_path(path)
if not path or not pattern:
return False
candidate = PurePosixPath(path)
return candidate.full_match(pattern) or candidate.full_match(f"{pattern}/**")
def matching_patterns(patterns, path: str) -> list[str]:
"""The patterns among `patterns` that `path` falls under, in their order."""
return [p for p in (patterns or []) if path_matches(p, path)]
async def assess_system_name(user_id: int, project_id: int, name: str) -> dict:
"""What BOTH doors must know before minting a System name (milestone 307).
@@ -154,13 +236,18 @@ async def create_system(
color: str | None = None,
order_index: int = 0,
canonical_id: int | None = None,
path_patterns: list[str] | None = None,
) -> System | None:
"""Create a System. None if the user can't write the project.
`canonical_id` maps the new System onto the global catalog; leaving it None
is fine — an unmapped System is fully usable, and the mapping can be
proposed later (services/canonical_systems.propose_mappings).
`path_patterns` are validated here, so every door refuses the same bad
pattern with the same message (ValueError).
"""
patterns = normalize_path_patterns(path_patterns)
if not await access.can_write_project(user_id, project_id):
return None
async with async_session() as session:
@@ -172,6 +259,7 @@ async def create_system(
color=color,
order_index=order_index,
canonical_id=canonical_id,
path_patterns=patterns,
)
session.add(system)
await session.commit()
@@ -209,13 +297,40 @@ async def list_systems(
return list(result.scalars().all())
async def systems_for_paths(
user_id: int, project_id: int, paths: list[str],
) -> list[tuple[System, list[str]]]:
"""The project's active Systems whose patterns cover any of `paths`.
Each comes with the paths it matched, in the order given. A path can match
several Systems — areas overlap, and every one it belongs to answers for
it — so nothing here picks a winner. Systems with no patterns never match:
an area that has not named its files is not claiming all of them.
"""
wanted = [p for p in (normalize_repo_path(x) for x in paths or []) if p]
if not wanted:
return []
out: list[tuple[System, list[str]]] = []
for system in await list_systems(user_id, project_id):
patterns = system.path_patterns or []
if not patterns:
continue
hit = [p for p in wanted if matching_patterns(patterns, p)]
if hit:
out.append((system, hit))
return out
async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
"""Update a System if the user can write its project."""
# canonical_id is deliberately NOT settable here: canonical_systems.
# set_system_canonical is its single writer, because it also validates the
# catalog entry is live. Two entry points onto one column is the drift this
# table exists to end.
allowed = {"name", "description", "color", "status", "order_index"}
allowed = {"name", "description", "color", "status", "order_index", "path_patterns"}
# Validated before anything is read, so a refused pattern changes nothing.
# `[]` is a value here, not "leave unchanged": it clears the paths.
if fields.get("path_patterns") is not None:
fields["path_patterns"] = normalize_path_patterns(fields["path_patterns"])
async with async_session() as session:
system = await session.get(System, system_id)
if system is None or system.deleted_at is not None: