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
@@ -0,0 +1,34 @@
"""system_path_patterns — a System names the files that are its area
(milestone 444 step 3, #4756)
Revision ID: 0113
Revises: 0112
Create Date: 2026-10-02
A JSONB list of globs relative to the repo root. NOT NULL with a `[]`
default, so an existing System reads as "no paths yet" rather than as NULL. No
backfill: which files are which area is a judgment, and the charters that name
directories in prose are not a mapping anyone confirmed.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision = "0113"
down_revision = "0112"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"systems",
sa.Column(
"path_patterns", postgresql.JSONB(), nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
)
def downgrade() -> None:
op.drop_column("systems", "path_patterns")
+14 -1
View File
@@ -14,6 +14,12 @@ export interface System {
color: string | null;
status: "active" | "archived";
order_index: number;
/**
* The files that are this area, as globs relative to the repo root: `*`
* within one directory, `**` across any depth, a plain directory covering
* everything under it. Empty means the area has not named its files.
*/
path_patterns: string[];
open_issue_count: number;
created_at: string | null;
updated_at: string | null;
@@ -39,7 +45,13 @@ export interface CreatedSystem extends System {
export async function createSystem(
projectId: number,
data: { name: string; description?: string; color?: string; canonical_id?: number },
data: {
name: string;
description?: string;
color?: string;
canonical_id?: number;
path_patterns?: string[];
},
): Promise<CreatedSystem> {
return apiPost(`/api/projects/${projectId}/systems`, data);
}
@@ -53,6 +65,7 @@ export async function updateSystem(
color: string | null;
status: "active" | "archived";
order_index: number;
path_patterns: string[];
}>,
): Promise<System> {
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data);
+70 -2
View File
@@ -28,6 +28,7 @@ const newDescription = ref("");
// feature two matchers to keep in step, which is the exact drift the catalog
// exists to end. The server still applies an exact hit on submit.
const newCanonicalId = ref<number | null>(null);
const newPaths = ref("");
const creating = ref(false);
// An `overlap` the server offered after a create — an offer, never applied.
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
@@ -37,6 +38,7 @@ const editingId = ref<number | null>(null);
const editName = ref("");
const editDescription = ref("");
const editCanonicalId = ref<number | null>(null);
const editPaths = ref("");
const savingEdit = ref(false);
// Mapping review
@@ -55,6 +57,16 @@ const visibleSystems = computed(() =>
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []);
// The files field is one pattern per line. The server is the one that
// validates and tidies them, so this only splits — a second validator here
// would be a second answer to "is this pattern acceptable".
function parsePaths(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
}
function areaName(system: System): string | null {
return canon.byId(system.canonical_id)?.name ?? null;
}
@@ -89,6 +101,7 @@ function openCreate() {
newName.value = "";
newDescription.value = "";
newCanonicalId.value = null;
newPaths.value = "";
}
function cancelCreate() {
@@ -96,6 +109,7 @@ function cancelCreate() {
newName.value = "";
newDescription.value = "";
newCanonicalId.value = null;
newPaths.value = "";
}
async function submitCreate() {
@@ -107,6 +121,7 @@ async function submitCreate() {
name,
description: newDescription.value.trim() || undefined,
canonical_id: newCanonicalId.value ?? undefined,
path_patterns: parsePaths(newPaths.value),
});
cancelCreate();
if (created.canonical_suggestion) {
@@ -157,6 +172,7 @@ function startEdit(system: System) {
editName.value = system.name;
editDescription.value = system.description;
editCanonicalId.value = system.canonical_id;
editPaths.value = system.path_patterns.join("\n");
}
function cancelEdit() {
@@ -171,6 +187,7 @@ async function submitEdit(system: System) {
await store.updateSystem(props.projectId, system.id, {
name,
description: editDescription.value.trim(),
path_patterns: parsePaths(editPaths.value),
});
// The mapping is a separate write with its own validation — one column,
// one writer (services/canonical_systems.set_system_canonical).
@@ -180,8 +197,9 @@ async function submitEdit(system: System) {
}
editingId.value = null;
toast.show("System updated");
} catch {
toast.show("Failed to update system", "error");
} catch (e) {
// A refused pattern comes back as a 400 naming it — say which.
toast.show(apiErrorMessage(e, "Failed to update system"), "error");
} finally {
savingEdit.value = false;
}
@@ -337,6 +355,20 @@ async function confirmDelete() {
Files this system under an area shared by every project. Your name stays as you typed it.
</span>
</label>
<label class="area-field">
<span class="area-label">Files</span>
<textarea
v-model="newPaths"
class="fs-input system-textarea system-paths-input"
rows="2"
placeholder="e.g. src/billing — one pattern per line"
aria-label="System files"
spellcheck="false"
></textarea>
<span class="field-hint">
One pattern per line, from the repo root. <code>*</code> stays in one folder, <code>**</code> reaches any depth, and a folder covers everything in it.
</span>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
{{ creating ? "Creating…" : "Create" }}
@@ -397,6 +429,17 @@ async function confirmDelete() {
</option>
</select>
</label>
<label class="area-field">
<span class="area-label">Files</span>
<textarea
v-model="editPaths"
class="fs-input system-textarea system-paths-input"
rows="2"
placeholder="One pattern per line, from the repo root"
aria-label="System files"
spellcheck="false"
></textarea>
</label>
<div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
{{ savingEdit ? "Saving…" : "Save" }}
@@ -430,6 +473,9 @@ async function confirmDelete() {
>{{ areaName(system) }}</span>
</div>
<p v-if="system.description" class="system-description">{{ system.description }}</p>
<ul v-if="system.path_patterns.length" class="system-paths" aria-label="Files">
<li v-for="pattern in system.path_patterns" :key="pattern" class="system-path">{{ pattern }}</li>
</ul>
</div>
<div class="system-actions">
<button class="action-btn" title="Edit" aria-label="Edit system" @click="startEdit(system)">
@@ -627,6 +673,28 @@ async function confirmDelete() {
.system-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; }
.system-paths-input { font-family: var(--fs-font-mono); font-size: 0.8rem; }
/* The area's files (milestone 444). Mono because they are paths to be read
character for character; small because they qualify the card, not lead it. */
.system-paths {
list-style: none;
margin: var(--fs-space-2) 0 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--fs-space-1);
}
.system-path {
font-family: var(--fs-font-mono);
font-size: 0.7rem;
color: var(--fs-text-secondary);
background: var(--fs-surface-page);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
padding: 0.05rem 0.4rem;
word-break: break-all;
}
/* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
`--archived` modifier and the `:hover .system-actions` reveal survived.
+2 -2
View File
@@ -22,7 +22,7 @@ export const useSystemsStore = defineStore("systems", () => {
async function createSystem(
projectId: number,
data: { name: string; description?: string; color?: string; canonical_id?: number },
data: Parameters<typeof api.createSystem>[1],
) {
const system = await api.createSystem(projectId, data);
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
@@ -33,7 +33,7 @@ export const useSystemsStore = defineStore("systems", () => {
async function updateSystem(
projectId: number,
systemId: number,
data: Partial<Pick<System, "name" | "description" | "color" | "status" | "order_index">>,
data: Parameters<typeof api.updateSystem>[2],
) {
const system = await api.updateSystem(projectId, systemId, data);
const list = systemsByProject.value[projectId];
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "scribe",
"description": "Scribe for Claude Code: connects the scribe MCP server, adds the hooks that deliver live project state and relevant records at the right moment, ships the shared client-neutral Scribe skills (using-scribe, writing-plans, reporting-back, systematic-debugging, verification, brainstorming, reusing-code, shape-accounting), and syncs your saved Scribe Processes as skills (/scribe:sync).",
"version": "2026.10.02.2259",
"version": "2026.10.03.0255",
"author": {
"name": "Bryan Van Deusen"
},
+5
View File
@@ -198,6 +198,11 @@ Two constraints on *how* that's achieved:
work-logs alike. Treat it as the tagging question asked at the moment of
work: tag the record, create the missing System, or deliberately leave it.
A System also names its files: `path_patterns`, globs from the repo root.
When the work you are tagging touched files its System's patterns don't
cover, and they plainly belong to that area, add them with `update_system`
— which files are which area is your call, as naming the area was.
8. **Name the record, never just its number.** Whenever you refer to a Scribe
record — in a message to the operator, a commit message, a task body, a
work-log — write the id *and* its title: `#3244 "the staleness signal"`,
+19 -1
View File
@@ -9,6 +9,7 @@ tools are thin wrappers.
Sentinels (match the milestone/task tool conventions):
- name="" / description="" / color="" / status="" → "leave unchanged" on update
- order_index=-1 → "leave unchanged" on update (0 is a valid order_index)
- path_patterns=None → "leave unchanged" on update ([] clears them)
"""
from __future__ import annotations
@@ -167,6 +168,7 @@ async def create_system(
name: str,
description: str = "",
color: str = "",
path_patterns: list[str] | None = None,
) -> dict:
"""Create a System (a reusable, self-describing subsystem/area) in a project.
@@ -195,11 +197,20 @@ async def create_system(
under the System, so a ruling there reaches each session working in the
area; a quote in a work log reaches one only if a search matches it.
`path_patterns` name the files that ARE the area: globs relative to the
repo root, `*` within one directory, `**` across any depth, and a plain
directory covering everything under it (`src/billing`,
`frontend/src/components/Billing*.vue`). The description says what the
area is for; the patterns say where it lives, so work touching those files
can be traced to the System without a search. Give them when the area's
files are known; a System without them still works for tagging.
Args:
project_id: The project this system belongs to (required).
name: Short label (required).
description: What the system is and how it's used — a name is rarely enough.
color: Optional UI accent (hex), or empty.
path_patterns: The area's files as repo-relative globs, or omit.
Duplicate-gated like the other creates: if a System with the same
normalized name already exists in this project (archived included), the
@@ -237,7 +248,7 @@ async def create_system(
system = await systems_svc.create_system(
uid, project_id=project_id, name=name,
description=description or None, color=color or None,
canonical_id=applied,
canonical_id=applied, path_patterns=path_patterns,
)
if system is None:
raise ValueError(f"cannot create system in project {project_id} (no write access)")
@@ -307,6 +318,7 @@ async def update_system(
color: str = "",
status: str = "",
order_index: int = -1,
path_patterns: list[str] | None = None,
) -> dict:
"""Update a System. Only explicitly provided fields change.
@@ -319,10 +331,14 @@ async def update_system(
history. If the charter above it contradicts a ruling, fix that sentence
in the same edit.
`path_patterns` also REPLACES the whole list, and `[]` clears it.
Args:
status: 'active' or 'archived'. Archive a system to retire it without
losing history; archived systems hide from default lists.
order_index: display position (0-based); -1 = leave unchanged.
path_patterns: The area's files as repo-relative globs (see
create_system). Omit to leave unchanged; [] clears them.
"""
uid = current_user_id()
fields: dict = {}
@@ -336,6 +352,8 @@ async def update_system(
fields["status"] = status
if order_index >= 0:
fields["order_index"] = order_index
if path_patterns is not None:
fields["path_patterns"] = path_patterns
system = await systems_svc.update_system(uid, system_id, **fields)
if system is None:
raise ValueError(f"system {system_id} not found or no write access")
+12 -1
View File
@@ -1,4 +1,5 @@
from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy import ForeignKey, Index, Integer, Text, UniqueConstraint, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base
@@ -38,6 +39,15 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
# active | archived — systems accumulate; archive rather than delete.
status: Mapped[str] = mapped_column(Text, default="active", server_default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
# The files that ARE this area, as globs relative to the repo root
# (milestone 444). The description says what the area is FOR; these say
# where it lives, so a command or edit touching those files can be
# resolved to the System — and its rulings — without a similarity search.
# NOT NULL with a `[]` default for the reason DesignToken.supersedes gives:
# a nullable list has two empties, and every reader must handle both.
path_patterns: Mapped[list] = mapped_column(
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
)
__table_args__ = (
Index("ix_systems_project_id", "project_id"),
@@ -54,6 +64,7 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color,
"status": self.status,
"order_index": self.order_index,
"path_patterns": list(self.path_patterns or []),
"created_at": iso(self.created_at),
"updated_at": iso(self.updated_at),
}
+15 -8
View File
@@ -82,12 +82,16 @@ async def create_system_route(project_id: int):
# Exact is mechanical and applied; overlap is a judgment call and is only
# offered back for the form to present.
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
system = await systems_svc.create_system(
uid, project_id=project_id, name=data["name"],
description=data.get("description"), color=data.get("color"),
order_index=data.get("order_index", 0),
canonical_id=data.get("canonical_id") or applied,
)
try:
system = await systems_svc.create_system(
uid, project_id=project_id, name=data["name"],
description=data.get("description"), color=data.get("color"),
order_index=data.get("order_index", 0),
canonical_id=data.get("canonical_id") or applied,
path_patterns=data.get("path_patterns"),
)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if system is None:
return jsonify({"error": "Permission denied"}), 403
out = system.to_dict()
@@ -125,11 +129,14 @@ async def update_system_route(project_id: int, system_id: int):
if system is None or system.project_id != project_id:
return not_found("System")
data = await request.get_json() or {}
allowed = {"name", "description", "color", "status", "order_index"}
allowed = {"name", "description", "color", "status", "order_index", "path_patterns"}
fields = {k: v for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "archived"):
return jsonify({"error": "status must be 'active' or 'archived'"}), 400
updated = await systems_svc.update_system(uid, system_id, **fields)
try:
updated = await systems_svc.update_system(uid, system_id, **fields)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if updated is None:
return not_found("System")
return jsonify(updated.to_dict())
+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:
+3
View File
@@ -205,6 +205,9 @@ TOPICS: tuple[Topic, ...] = (
Topic("code says what a thing does, not what was wanted", U,
("rulings", "unconfirmed"),
"code tells you what a thing does, not what was wanted"),
Topic("a System names its files, and tagging work keeps them current", U,
("path_patterns", "update_system"),
"which files are which area is your call"),
# ── process arcs — owned by their skills ──
Topic("plan in a milestone, steps created together", "skill:writing-plans", ("start_planning", "{{ref:"),
"a milestone earns its place when the work has an arc", index=("start_planning",)),
+17
View File
@@ -101,6 +101,23 @@ async def test_get_system_splits_records_by_kind():
assert [r["id"] for r in result["notes"]] == [12]
@pytest.mark.asyncio
async def test_path_patterns_reach_the_service_from_both_tools():
"""Omitted means unchanged; an empty list is a value — it clears them."""
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
patch("scribe.mcp.tools.systems.systems_svc") as svc:
svc.assess_system_name = AsyncMock(return_value=_NO_MATCH)
svc.create_system = AsyncMock(return_value=fake_system(name="Billing"))
svc.update_system = AsyncMock(return_value=fake_system(name="Billing"))
from scribe.mcp.tools.systems import create_system, update_system
await create_system(project_id=5, name="Billing", path_patterns=["src/billing"])
assert svc.create_system.await_args.kwargs["path_patterns"] == ["src/billing"]
await update_system(system_id=1, name="Billing")
assert "path_patterns" not in svc.update_system.await_args.kwargs
await update_system(system_id=1, path_patterns=[])
assert svc.update_system.await_args.kwargs["path_patterns"] == []
@pytest.mark.asyncio
async def test_update_system_not_found_raises():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \
+124
View File
@@ -126,3 +126,127 @@ async def test_assess_fails_open_so_a_naming_aid_cannot_block_a_create():
async def test_assess_says_nothing_about_a_nameless_system():
from scribe.services.systems import assess_system_name
assert await assess_system_name(1, 5, " ") == {"duplicate": None, "canonical": None}
# ── path patterns — the files that ARE the area (milestone 444, #4756) ──
def test_patterns_are_tidied_to_repo_relative_and_deduplicated():
from scribe.services.systems import normalize_path_patterns
out = normalize_path_patterns([
" ./src/billing/ ", "/src/billing", "", "src\\api\\*.py", " ",
"frontend/src/**/Billing*.vue",
])
assert out == ["src/billing", "src/api/*.py", "frontend/src/**/Billing*.vue"]
assert normalize_path_patterns(None) == []
assert normalize_path_patterns([]) == []
@pytest.mark.parametrize("bad", [
"src/billing", # a bare string, not a list
["../other-repo/src"], # leaves the repo
["src/../../etc"],
[42],
["x" * 301],
[f"src/f{i}.py" for i in range(51)],
])
def test_patterns_that_cannot_mean_an_area_are_refused(bad):
from scribe.services.systems import normalize_path_patterns
with pytest.raises(ValueError):
normalize_path_patterns(bad)
@pytest.mark.parametrize("pattern,path,expected", [
# A plain directory covers everything under it, at any depth.
("src/billing", "src/billing/invoice.py", True),
("src/billing", "src/billing/deep/er/x.py", True),
("src/billing", "src/billing", True),
("src/billing", "src/billingual/x.py", False),
# `*` stays inside one segment; `**` spans any number, including none.
("src/*.py", "src/app.py", True),
("src/*.py", "src/pkg/app.py", False),
("src/**/*.py", "src/pkg/sub/app.py", True),
("src/**/*.py", "src/app.py", True),
("**/Billing*.vue", "frontend/src/components/BillingCard.vue", True),
# Case counts, and the path is read the way the pattern was written.
("src/billing", "Src/billing/x.py", False),
("src/billing", "./src/billing/x.py", True),
("src/billing", "", False),
])
def test_path_matches(pattern, path, expected):
from scribe.services.systems import path_matches
assert path_matches(pattern, path) is expected
@pytest.mark.asyncio
async def test_systems_for_paths_returns_every_area_a_path_belongs_to():
"""Areas overlap, and each one a path belongs to answers for it — the
lookup never picks a winner. A System that named no files claims none."""
api = MagicMock(id=1, path_patterns=["src/scribe/routes", "src/scribe/mcp/tools"])
data = MagicMock(id=2, path_patterns=["src/scribe/models", "src/scribe/**/systems.py"])
unnamed = MagicMock(id=3, path_patterns=[])
with patch("scribe.services.systems.list_systems",
AsyncMock(return_value=[api, data, unnamed])):
from scribe.services.systems import systems_for_paths
out = await systems_for_paths(1, 5, [
"src/scribe/routes/systems.py", "./README.md", "",
])
assert [(s.id, paths) for s, paths in out] == [
(1, ["src/scribe/routes/systems.py"]),
(2, ["src/scribe/routes/systems.py"]),
]
@pytest.mark.asyncio
async def test_systems_for_paths_with_no_paths_reads_nothing():
lister = AsyncMock(return_value=[])
with patch("scribe.services.systems.list_systems", lister):
from scribe.services.systems import systems_for_paths
assert await systems_for_paths(1, 5, ["", " "]) == []
lister.assert_not_awaited()
@pytest.mark.asyncio
async def test_update_refuses_a_bad_pattern_before_touching_the_row():
with patch("scribe.services.systems.async_session") as mock_cls:
from scribe.services.systems import update_system
with pytest.raises(ValueError):
await update_system(1, 9, path_patterns=["../elsewhere"])
mock_cls.assert_not_called()
@pytest.mark.asyncio
async def test_update_with_an_empty_list_clears_the_patterns():
"""`[]` is a value, not "leave unchanged" — the service skips only None."""
system = MagicMock(deleted_at=None, project_id=5, path_patterns=["src/old"])
session = make_mock_session()
session.get = AsyncMock(return_value=system)
with patch("scribe.services.systems.async_session", return_value=session), \
patch("scribe.services.systems.access") as acc, \
patch("scribe.services.systems.embed_system"):
acc.can_write_project = AsyncMock(return_value=True)
from scribe.services.systems import update_system
await update_system(1, 9, path_patterns=[])
assert system.path_patterns == []
@pytest.mark.asyncio
async def test_create_stores_tidied_patterns():
mock_session = make_mock_session()
captured = {}
mock_session.add = MagicMock(side_effect=lambda obj: captured.update(
patterns=getattr(obj, "path_patterns", "MISSING")))
with patch("scribe.services.systems.async_session", return_value=mock_session), \
patch("scribe.services.systems.access") as acc, \
patch("scribe.services.systems.embed_system"):
acc.can_write_project = AsyncMock(return_value=True)
from scribe.services.systems import create_system
await create_system(1, 5, "Billing", path_patterns=["./src/billing/"])
assert captured["patterns"] == ["src/billing"]
def test_system_to_dict_carries_its_patterns():
from scribe.models.system import System
system = System(id=1, user_id=1, project_id=5, name="Billing",
path_patterns=["src/billing"])
assert system.to_dict()["path_patterns"] == ["src/billing"]
assert System(id=2, user_id=1, project_id=5, name="X").to_dict()["path_patterns"] == []