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:
@@ -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,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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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"
|
||||
},
|
||||
|
||||
@@ -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"`,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",)),
|
||||
|
||||
@@ -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), \
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
Reference in New Issue
Block a user