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; color: string | null;
status: "active" | "archived"; status: "active" | "archived";
order_index: number; 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; open_issue_count: number;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
@@ -39,7 +45,13 @@ export interface CreatedSystem extends System {
export async function createSystem( export async function createSystem(
projectId: number, 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> { ): Promise<CreatedSystem> {
return apiPost(`/api/projects/${projectId}/systems`, data); return apiPost(`/api/projects/${projectId}/systems`, data);
} }
@@ -53,6 +65,7 @@ export async function updateSystem(
color: string | null; color: string | null;
status: "active" | "archived"; status: "active" | "archived";
order_index: number; order_index: number;
path_patterns: string[];
}>, }>,
): Promise<System> { ): Promise<System> {
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data); 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 // 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. // exists to end. The server still applies an exact hit on submit.
const newCanonicalId = ref<number | null>(null); const newCanonicalId = ref<number | null>(null);
const newPaths = ref("");
const creating = ref(false); const creating = ref(false);
// An `overlap` the server offered after a create — an offer, never applied. // An `overlap` the server offered after a create — an offer, never applied.
const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null); const suggestion = ref<{ systemId: number; match: CanonicalMatch } | null>(null);
@@ -37,6 +38,7 @@ const editingId = ref<number | null>(null);
const editName = ref(""); const editName = ref("");
const editDescription = ref(""); const editDescription = ref("");
const editCanonicalId = ref<number | null>(null); const editCanonicalId = ref<number | null>(null);
const editPaths = ref("");
const savingEdit = ref(false); const savingEdit = ref(false);
// Mapping review // Mapping review
@@ -55,6 +57,16 @@ const visibleSystems = computed(() =>
const proposals = computed(() => canon.proposalsByProject[props.projectId] ?? []); 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 { function areaName(system: System): string | null {
return canon.byId(system.canonical_id)?.name ?? null; return canon.byId(system.canonical_id)?.name ?? null;
} }
@@ -89,6 +101,7 @@ function openCreate() {
newName.value = ""; newName.value = "";
newDescription.value = ""; newDescription.value = "";
newCanonicalId.value = null; newCanonicalId.value = null;
newPaths.value = "";
} }
function cancelCreate() { function cancelCreate() {
@@ -96,6 +109,7 @@ function cancelCreate() {
newName.value = ""; newName.value = "";
newDescription.value = ""; newDescription.value = "";
newCanonicalId.value = null; newCanonicalId.value = null;
newPaths.value = "";
} }
async function submitCreate() { async function submitCreate() {
@@ -107,6 +121,7 @@ async function submitCreate() {
name, name,
description: newDescription.value.trim() || undefined, description: newDescription.value.trim() || undefined,
canonical_id: newCanonicalId.value ?? undefined, canonical_id: newCanonicalId.value ?? undefined,
path_patterns: parsePaths(newPaths.value),
}); });
cancelCreate(); cancelCreate();
if (created.canonical_suggestion) { if (created.canonical_suggestion) {
@@ -157,6 +172,7 @@ function startEdit(system: System) {
editName.value = system.name; editName.value = system.name;
editDescription.value = system.description; editDescription.value = system.description;
editCanonicalId.value = system.canonical_id; editCanonicalId.value = system.canonical_id;
editPaths.value = system.path_patterns.join("\n");
} }
function cancelEdit() { function cancelEdit() {
@@ -171,6 +187,7 @@ async function submitEdit(system: System) {
await store.updateSystem(props.projectId, system.id, { await store.updateSystem(props.projectId, system.id, {
name, name,
description: editDescription.value.trim(), description: editDescription.value.trim(),
path_patterns: parsePaths(editPaths.value),
}); });
// The mapping is a separate write with its own validation — one column, // The mapping is a separate write with its own validation — one column,
// one writer (services/canonical_systems.set_system_canonical). // one writer (services/canonical_systems.set_system_canonical).
@@ -180,8 +197,9 @@ async function submitEdit(system: System) {
} }
editingId.value = null; editingId.value = null;
toast.show("System updated"); toast.show("System updated");
} catch { } catch (e) {
toast.show("Failed to update system", "error"); // A refused pattern comes back as a 400 naming it — say which.
toast.show(apiErrorMessage(e, "Failed to update system"), "error");
} finally { } finally {
savingEdit.value = false; 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. Files this system under an area shared by every project. Your name stays as you typed it.
</span> </span>
</label> </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"> <div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating"> <button type="submit" class="btn-primary btn-compact" :disabled="!newName.trim() || creating">
{{ creating ? "Creating…" : "Create" }} {{ creating ? "Creating…" : "Create" }}
@@ -397,6 +429,17 @@ async function confirmDelete() {
</option> </option>
</select> </select>
</label> </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"> <div class="system-form-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit"> <button type="submit" class="btn-primary btn-compact" :disabled="!editName.trim() || savingEdit">
{{ savingEdit ? "Saving…" : "Save" }} {{ savingEdit ? "Saving…" : "Save" }}
@@ -430,6 +473,9 @@ async function confirmDelete() {
>{{ areaName(system) }}</span> >{{ areaName(system) }}</span>
</div> </div>
<p v-if="system.description" class="system-description">{{ system.description }}</p> <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>
<div class="system-actions"> <div class="system-actions">
<button class="action-btn" title="Edit" aria-label="Edit system" @click="startEdit(system)"> <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-textarea { resize: vertical; }
.system-form-actions { display: flex; gap: 0.4rem; } .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 /* RESTORED (#2444). Both lost their base rule to a CSS sweep; only the
`--archived` modifier and the `:hover .system-actions` reveal survived. `--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( async function createSystem(
projectId: number, 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); const system = await api.createSystem(projectId, data);
if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = []; if (!systemsByProject.value[projectId]) systemsByProject.value[projectId] = [];
@@ -33,7 +33,7 @@ export const useSystemsStore = defineStore("systems", () => {
async function updateSystem( async function updateSystem(
projectId: number, projectId: number,
systemId: 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 system = await api.updateSystem(projectId, systemId, data);
const list = systemsByProject.value[projectId]; const list = systemsByProject.value[projectId];
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "scribe", "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).", "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": { "author": {
"name": "Bryan Van Deusen" "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-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. 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 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 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"`, 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): Sentinels (match the milestone/task tool conventions):
- name="" / description="" / color="" / status="" → "leave unchanged" on update - name="" / description="" / color="" / status="" → "leave unchanged" on update
- order_index=-1 → "leave unchanged" on update (0 is a valid order_index) - 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 from __future__ import annotations
@@ -167,6 +168,7 @@ async def create_system(
name: str, name: str,
description: str = "", description: str = "",
color: str = "", color: str = "",
path_patterns: list[str] | None = None,
) -> dict: ) -> dict:
"""Create a System (a reusable, self-describing subsystem/area) in a project. """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 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. 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: Args:
project_id: The project this system belongs to (required). project_id: The project this system belongs to (required).
name: Short label (required). name: Short label (required).
description: What the system is and how it's used — a name is rarely enough. description: What the system is and how it's used — a name is rarely enough.
color: Optional UI accent (hex), or empty. 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 Duplicate-gated like the other creates: if a System with the same
normalized name already exists in this project (archived included), the normalized name already exists in this project (archived included), the
@@ -237,7 +248,7 @@ async def create_system(
system = await systems_svc.create_system( system = await systems_svc.create_system(
uid, project_id=project_id, name=name, uid, project_id=project_id, name=name,
description=description or None, color=color or None, description=description or None, color=color or None,
canonical_id=applied, canonical_id=applied, path_patterns=path_patterns,
) )
if system is None: if system is None:
raise ValueError(f"cannot create system in project {project_id} (no write access)") raise ValueError(f"cannot create system in project {project_id} (no write access)")
@@ -307,6 +318,7 @@ async def update_system(
color: str = "", color: str = "",
status: str = "", status: str = "",
order_index: int = -1, order_index: int = -1,
path_patterns: list[str] | None = None,
) -> dict: ) -> dict:
"""Update a System. Only explicitly provided fields change. """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 history. If the charter above it contradicts a ruling, fix that sentence
in the same edit. in the same edit.
`path_patterns` also REPLACES the whole list, and `[]` clears it.
Args: Args:
status: 'active' or 'archived'. Archive a system to retire it without status: 'active' or 'archived'. Archive a system to retire it without
losing history; archived systems hide from default lists. losing history; archived systems hide from default lists.
order_index: display position (0-based); -1 = leave unchanged. 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() uid = current_user_id()
fields: dict = {} fields: dict = {}
@@ -336,6 +352,8 @@ async def update_system(
fields["status"] = status fields["status"] = status
if order_index >= 0: if order_index >= 0:
fields["order_index"] = order_index 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) system = await systems_svc.update_system(uid, system_id, **fields)
if system is None: if system is None:
raise ValueError(f"system {system_id} not found or no write access") 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 sqlalchemy.orm import Mapped, mapped_column
from scribe.models import Base from scribe.models import Base
@@ -38,6 +39,15 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
# active | archived — systems accumulate; archive rather than delete. # active | archived — systems accumulate; archive rather than delete.
status: Mapped[str] = mapped_column(Text, default="active", server_default="active") status: Mapped[str] = mapped_column(Text, default="active", server_default="active")
order_index: Mapped[int] = mapped_column(Integer, default=0, server_default="0") 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__ = ( __table_args__ = (
Index("ix_systems_project_id", "project_id"), Index("ix_systems_project_id", "project_id"),
@@ -54,6 +64,7 @@ class System(Base, TimestampMixin, SoftDeleteMixin):
"color": self.color, "color": self.color,
"status": self.status, "status": self.status,
"order_index": self.order_index, "order_index": self.order_index,
"path_patterns": list(self.path_patterns or []),
"created_at": iso(self.created_at), "created_at": iso(self.created_at),
"updated_at": iso(self.updated_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 # Exact is mechanical and applied; overlap is a judgment call and is only
# offered back for the form to present. # offered back for the form to present.
applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None applied = canonical["id"] if canonical and canonical["basis"] == "exact" else None
system = await systems_svc.create_system( try:
uid, project_id=project_id, name=data["name"], system = await systems_svc.create_system(
description=data.get("description"), color=data.get("color"), uid, project_id=project_id, name=data["name"],
order_index=data.get("order_index", 0), description=data.get("description"), color=data.get("color"),
canonical_id=data.get("canonical_id") or applied, 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: if system is None:
return jsonify({"error": "Permission denied"}), 403 return jsonify({"error": "Permission denied"}), 403
out = system.to_dict() 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: if system is None or system.project_id != project_id:
return not_found("System") return not_found("System")
data = await request.get_json() or {} 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} fields = {k: v for k, v in data.items() if k in allowed}
if "status" in fields and fields["status"] not in ("active", "archived"): if "status" in fields and fields["status"] not in ("active", "archived"):
return jsonify({"error": "status must be 'active' or 'archived'"}), 400 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: if updated is None:
return not_found("System") return not_found("System")
return jsonify(updated.to_dict()) 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, "id": r.id, "user_id": r.user_id, "project_id": r.project_id,
"name": r.name, "description": r.description, "color": r.color, "name": r.name, "description": r.description, "color": r.color,
"status": r.status, "order_index": r.order_index, "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), "canonical_slug": canonical_slugs.get(r.canonical_id or 0),
} }
for r in rows for r in rows
@@ -1460,6 +1461,8 @@ def _build_system(row: dict, maps: _Maps) -> System | None:
color=row.get("color"), color=row.get("color"),
status=row.get("status", "active"), status=row.get("status", "active"),
order_index=row.get("order_index", 0), 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 # An unknown slug restores UNMAPPED rather than failing: the System and
# its records are the payload, the mapping is an aid. # its records are the payload, the mapping is an aid.
canonical_id=maps.canonical_by_slug.get(row.get("canonical_slug") or ""), 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 import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import PurePosixPath
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
@@ -31,6 +32,87 @@ def local_name_key(name: str) -> str:
return " ".join(name.split()).lower() 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: 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). """What BOTH doors must know before minting a System name (milestone 307).
@@ -154,13 +236,18 @@ async def create_system(
color: str | None = None, color: str | None = None,
order_index: int = 0, order_index: int = 0,
canonical_id: int | None = None, canonical_id: int | None = None,
path_patterns: list[str] | None = None,
) -> System | None: ) -> System | None:
"""Create a System. None if the user can't write the project. """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 `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 is fine — an unmapped System is fully usable, and the mapping can be
proposed later (services/canonical_systems.propose_mappings). 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): if not await access.can_write_project(user_id, project_id):
return None return None
async with async_session() as session: async with async_session() as session:
@@ -172,6 +259,7 @@ async def create_system(
color=color, color=color,
order_index=order_index, order_index=order_index,
canonical_id=canonical_id, canonical_id=canonical_id,
path_patterns=patterns,
) )
session.add(system) session.add(system)
await session.commit() await session.commit()
@@ -209,13 +297,40 @@ async def list_systems(
return list(result.scalars().all()) 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: async def update_system(user_id: int, system_id: int, **fields: object) -> System | None:
"""Update a System if the user can write its project.""" """Update a System if the user can write its project."""
# canonical_id is deliberately NOT settable here: canonical_systems. # canonical_id is deliberately NOT settable here: canonical_systems.
# set_system_canonical is its single writer, because it also validates the # 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 # catalog entry is live. Two entry points onto one column is the drift this
# table exists to end. # 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: async with async_session() as session:
system = await session.get(System, system_id) system = await session.get(System, system_id)
if system is None or system.deleted_at is not None: 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, Topic("code says what a thing does, not what was wanted", U,
("rulings", "unconfirmed"), ("rulings", "unconfirmed"),
"code tells you what a thing does, not what was wanted"), "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 ── # ── process arcs — owned by their skills ──
Topic("plan in a milestone, steps created together", "skill:writing-plans", ("start_planning", "{{ref:"), 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",)), "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] 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 @pytest.mark.asyncio
async def test_update_system_not_found_raises(): async def test_update_system_not_found_raises():
with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ 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(): async def test_assess_says_nothing_about_a_nameless_system():
from scribe.services.systems import assess_system_name from scribe.services.systems import assess_system_name
assert await assess_system_name(1, 5, " ") == {"duplicate": None, "canonical": None} 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"] == []