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
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:
@@ -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 ""),
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user