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;
|
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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"`,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
try:
|
||||||
system = await systems_svc.create_system(
|
system = await systems_svc.create_system(
|
||||||
uid, project_id=project_id, name=data["name"],
|
uid, project_id=project_id, name=data["name"],
|
||||||
description=data.get("description"), color=data.get("color"),
|
description=data.get("description"), color=data.get("color"),
|
||||||
order_index=data.get("order_index", 0),
|
order_index=data.get("order_index", 0),
|
||||||
canonical_id=data.get("canonical_id") or applied,
|
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
|
||||||
|
try:
|
||||||
updated = await systems_svc.update_system(uid, system_id, **fields)
|
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())
|
||||||
|
|||||||
@@ -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 ""),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",)),
|
||||||
|
|||||||
@@ -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), \
|
||||||
|
|||||||
@@ -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"] == []
|
||||||
|
|||||||
Reference in New Issue
Block a user