diff --git a/frontend/src/api/inception.ts b/frontend/src/api/inception.ts index ece42631..f520cc21 100644 --- a/frontend/src/api/inception.ts +++ b/frontend/src/api/inception.ts @@ -1,9 +1,14 @@ /** Project inception (milestone 297): what a project was decided to inherit. */ import { apiGet, apiPost } from "@/api/client"; +import type { ProjectPlatform } from "@/api/platforms"; export interface InceptionChoices { design_system_id: number | null; seed_systems: boolean; + /** Platform slugs (milestone 463). null = not answered; a list is the + * whole answer — members left out of it are recorded as "not this". + * Absent on records decided before the question existed. */ + platforms?: string[] | null; } export interface InceptionRecord { @@ -17,16 +22,24 @@ export interface InceptionDefaults { design_system_id: number | null; design_systems: { id: number; title: string }[]; systems: number; + platforms: { slug: string; name: string }[]; + /** What detection (or an earlier answer) already says about the project. */ + project_platforms: ProjectPlatform[]; } export interface InceptionDecision { project_id: number; inception: InceptionRecord; - effects: { design_system_id: number | null; systems_seeded: string[] }; + effects: { + design_system_id: number | null; + systems_seeded: string[]; + /** The project's answers after the decision; null when left unstated. */ + platforms: ProjectPlatform[] | null; + }; } export const emptyChoices = (): InceptionChoices => ({ - design_system_id: null, seed_systems: false, + design_system_id: null, seed_systems: false, platforms: null, }); export const fetchInceptionDefaults = (projectId: number) => diff --git a/frontend/src/api/platforms.ts b/frontend/src/api/platforms.ts new file mode 100644 index 00000000..8e27cd58 --- /dev/null +++ b/frontend/src/api/platforms.ts @@ -0,0 +1,77 @@ +/** + * Platforms — what a project is built on or ships as (milestone 463). + * + * The catalog is GLOBAL, like the canonical areas, and admin-written. A + * project's answer per platform is `declared` (a person said so), `detected` + * (a marker file in a bound repo said so) or `rejected` (a person said no — + * kept, so the next refresh cannot detect it back). Only declared and + * detected make a project a member, and membership is what decides which + * family ideas reach it. + */ +import { apiGet, apiPatch, apiPost, apiPut } from "@/api/client"; + +export interface Platform { + id: number; + name: string; + /** The match key, and the name every door takes (stable across restores). */ + slug: string; + description: string | null; + /** Repo-relative globs. A bare name matches a basename anywhere; one with + * a slash matches the whole path. Empty = declare-only. */ + markers: string[]; + order_index: number; + created_at: string | null; + updated_at: string | null; +} + +export type PlatformState = "declared" | "detected" | "rejected"; +/** What a person may set. `null` withdraws the answer. */ +export type SettablePlatformState = "declared" | "rejected" | null; + +export interface ProjectPlatform { + id: number; + slug: string; + name: string; + state: PlatformState; +} + +export const MEMBER_STATES: PlatformState[] = ["declared", "detected"]; + +export async function listPlatforms(): Promise { + const data = await apiGet<{ platforms: Platform[] }>("/api/platforms"); + return data.platforms; +} + +/** Admin only. A name that reduces to an existing slug answers 409. */ +export async function createPlatform(data: { + name: string; + description?: string; + markers?: string[]; +}): Promise { + return apiPost("/api/platforms", data); +} + +export async function updatePlatform( + id: number, + data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>, +): Promise { + return apiPatch(`/api/platforms/${id}`, data); +} + +export async function fetchProjectPlatforms(projectId: number): Promise { + const data = await apiGet<{ project_platforms: ProjectPlatform[] }>( + `/api/projects/${projectId}/platforms`, + ); + return data.project_platforms; +} + +/** Only the slugs named change; the update applies whole or not at all. */ +export async function setProjectPlatforms( + projectId: number, + platforms: Record, +): Promise { + const data = await apiPut<{ project_platforms: ProjectPlatform[] }>( + `/api/projects/${projectId}/platforms`, { platforms }, + ); + return data.project_platforms; +} diff --git a/frontend/src/components/InceptionCard.vue b/frontend/src/components/InceptionCard.vue index 11929960..0d7b8ffa 100644 --- a/frontend/src/components/InceptionCard.vue +++ b/frontend/src/components/InceptionCard.vue @@ -6,10 +6,16 @@ * second step and only emits the choices (the project does not exist yet); * mode="decide" sits on ProjectView for an undecided project, loads that * project's current defaults, and records the decision itself. + * + * Platforms (milestone 463) start UNANSWERED (null). Ticking any box makes + * the list the whole answer; in decide mode the boxes start from what + * detection already found, so recording confirms it. */ import { computed, onMounted, ref, watch } from "vue"; import { apiErrorMessage } from "@/api/client"; import { fetchDesignSystems } from "@/api/designSystems"; +import { MEMBER_STATES } from "@/api/platforms"; +import { usePlatformsStore } from "@/stores/platforms"; import { decideInception, emptyChoices, fetchInceptionDefaults, type InceptionChoices, type InceptionDecision, type InceptionDefaults, @@ -29,6 +35,9 @@ const emit = defineEmits<{ const local = ref(props.choices ? { ...props.choices } : emptyChoices()); const designSystems = ref<{ id: number; title: string }[]>([]); const systemsCount = ref(0); +const platforms = ref<{ slug: string; name: string }[]>([]); +const detected = ref>(new Set()); +const platformsStore = usePlatformsStore(); const loading = ref(true); const saving = ref(false); const error = ref(""); @@ -46,11 +55,25 @@ async function load() { const d: InceptionDefaults = await fetchInceptionDefaults(props.projectId); designSystems.value = d.design_systems; systemsCount.value = d.systems; + platforms.value = d.platforms; + const members = d.project_platforms + .filter((p) => MEMBER_STATES.includes(p.state)) + .map((p) => p.slug); + detected.value = new Set( + d.project_platforms.filter((p) => p.state === "detected").map((p) => p.slug), + ); // Start from what stands today, so "record" without changes keeps it. - local.value = { design_system_id: d.design_system_id, seed_systems: false }; + local.value = { + design_system_id: d.design_system_id, + seed_systems: false, + platforms: members.length ? members : null, + }; } else { - const ds = await fetchDesignSystems(); + const [ds, catalog] = await Promise.all([ + fetchDesignSystems(), platformsStore.fetchCatalog(), + ]); designSystems.value = ds.design_systems.map((d) => ({ id: d.id, title: d.title })); + platforms.value = catalog.map((p) => ({ slug: p.slug, name: p.name })); } } catch (e: unknown) { error.value = apiErrorMessage(e, "Could not load what this project could inherit"); @@ -61,6 +84,22 @@ async function load() { const nothingToDecide = computed(() => !designSystems.value.length); +function isChecked(slug: string): boolean { + return (local.value.platforms ?? []).includes(slug); +} + +function togglePlatform(slug: string, on: boolean) { + const current = new Set(local.value.platforms ?? []); + if (on) current.add(slug); + else current.delete(slug); + local.value.platforms = [...current].sort(); +} + +/** Back to "not answered" — distinct from an empty list, which says "none". */ +function clearPlatforms() { + local.value.platforms = null; +} + async function record() { if (!props.projectId) return; saving.value = true; @@ -105,6 +144,31 @@ onMounted(load); +
+

Platforms

+

+ What it is built on or ships as. Ideas the family has proven for a + platform reach every project that is one. + + +

+
+ +
+

No design systems on this install yet — recording still settles the question.

@@ -132,6 +196,11 @@ onMounted(load); .inception-group h4 { margin: 0 0 0.35rem; font-size: 0.9rem; font-weight: 500; } .inception-choice { display: flex; align-items: flex-start; gap: 0.5rem; font-size: 0.9rem; margin: 0.25rem 0; } .inception-choice input { margin-top: 0.2rem; accent-color: var(--fs-accent); } +.inception-platforms { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); + gap: 0 1rem; +} .inception-select { padding: 0.45rem 0.7rem; border: 1px solid var(--fs-border-color); diff --git a/frontend/src/components/ProjectFamilyTab.vue b/frontend/src/components/ProjectFamilyTab.vue new file mode 100644 index 00000000..c76aef41 --- /dev/null +++ b/frontend/src/components/ProjectFamilyTab.vue @@ -0,0 +1,200 @@ + + + + + diff --git a/frontend/src/stores/platforms.ts b/frontend/src/stores/platforms.ts new file mode 100644 index 00000000..531427f3 --- /dev/null +++ b/frontend/src/stores/platforms.ts @@ -0,0 +1,48 @@ +import { ref } from "vue"; +import { defineStore } from "pinia"; +import * as api from "@/api/platforms"; +import type { Platform } from "@/api/platforms"; + +/** + * The global platform catalog (milestone 463). Shared by every project, so + * it is fetched once per session — as the canonical-area catalog is. + */ +export const usePlatformsStore = defineStore("platforms", () => { + const catalog = ref([]); + const loaded = ref(false); + const loading = ref(false); + + async function fetchCatalog(force = false) { + if (loaded.value && !force) return catalog.value; + loading.value = true; + try { + catalog.value = await api.listPlatforms(); + loaded.value = true; + } catch { + // The catalog is a vocabulary for a form; an empty one degrades the + // form rather than failing the screen it sits on. + catalog.value = []; + } finally { + loading.value = false; + } + return catalog.value; + } + + async function createEntry(data: { name: string; description?: string; markers?: string[] }) { + const entry = await api.createPlatform(data); + catalog.value.push(entry); + return entry; + } + + async function updateEntry( + id: number, + data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>, + ) { + const entry = await api.updatePlatform(id, data); + const idx = catalog.value.findIndex((p) => p.id === id); + if (idx >= 0) catalog.value[idx] = entry; + return entry; + } + + return { catalog, loaded, loading, fetchCatalog, createEntry, updateEntry }; +}); diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index ae9bdafc..48e0ef90 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -12,6 +12,8 @@ import KindBadge from "@/components/KindBadge.vue"; import ProjectStatusBadge from "@/components/ProjectStatusBadge.vue"; import type { TaskKind } from "@/types/note"; import ProjectDesignTab from "@/components/ProjectDesignTab.vue"; +import ProjectFamilyTab from "@/components/ProjectFamilyTab.vue"; +import { canWriteRecord } from "@/utils/permission"; import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue"; import SystemsSection from "@/components/SystemsSection.vue"; import InceptionCard from "@/components/InceptionCard.vue"; @@ -125,7 +127,7 @@ async function confirmStartPlanning() { const saving = ref(false); const error = ref(null); -const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks"); +const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design" | "family">("tasks"); const tasks = ref([]); const notes = ref([]); @@ -747,6 +749,10 @@ async function confirmDelete() { Inheritance decided {{ fmtDate(project.inception.decided_at) }} via {{ project.inception.via }} · design system {{ project.inception.choices.design_system_id ? "#" + project.inception.choices.design_system_id : "none" }} + +

@@ -945,6 +951,9 @@ async function confirmDelete() { + @@ -1190,6 +1199,13 @@ async function confirmDelete() { :project-id="projectId" :design-system-id="project.design_system_id ?? null" /> + + + diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 94875ae2..e994238b 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -4,6 +4,7 @@ import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; import { useCanonicalSystemsStore } from "@/stores/canonicalSystems"; +import { usePlatformsStore } from "@/stores/platforms"; import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, apiErrorMessage } from "@/api/client"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; @@ -71,6 +72,75 @@ async function saveArea() { savingArea.value = false; } } +// ── Platforms (milestone 463) ─────────────────────────────────────────── +// The global list of what a project can be built on or ship as. Same split +// as the areas: admin-only to write, readable by everyone. Markers are the +// repo-relative globs that let a coverage refresh DETECT a platform; they +// are edited one per line, since a glob may itself contain a comma. +const platformsStore = usePlatformsStore(); +const newPlatformName = ref(""); +const newPlatformDescription = ref(""); +const newPlatformMarkers = ref(""); +const creatingPlatform = ref(false); +const editingPlatformId = ref(null); +const editPlatformName = ref(""); +const editPlatformDescription = ref(""); +const editPlatformMarkers = ref(""); +const savingPlatform = ref(false); + +function markerLines(text: string): string[] { + return text.split("\n").map((m) => m.trim()).filter(Boolean); +} + +async function createPlatform() { + const name = newPlatformName.value.trim(); + if (!name || creatingPlatform.value) return; + creatingPlatform.value = true; + try { + await platformsStore.createEntry({ + name, + description: newPlatformDescription.value.trim() || undefined, + markers: markerLines(newPlatformMarkers.value), + }); + newPlatformName.value = ""; + newPlatformDescription.value = ""; + newPlatformMarkers.value = ""; + toastStore.show("Platform added"); + } catch (e) { + // A 409 names the existing platform the new name reduces to. + toastStore.show(apiErrorMessage(e, "Failed to add platform"), "error"); + } finally { + creatingPlatform.value = false; + } +} + +function startEditPlatform(id: number, name: string, description: string | null, markers: string[]) { + editingPlatformId.value = id; + editPlatformName.value = name; + editPlatformDescription.value = description ?? ""; + editPlatformMarkers.value = markers.join("\n"); +} + +async function savePlatform() { + const id = editingPlatformId.value; + const name = editPlatformName.value.trim(); + if (id == null || !name || savingPlatform.value) return; + savingPlatform.value = true; + try { + await platformsStore.updateEntry(id, { + name, + description: editPlatformDescription.value.trim(), + markers: markerLines(editPlatformMarkers.value), + }); + editingPlatformId.value = null; + toastStore.show("Platform updated"); + } catch (e) { + toastStore.show(apiErrorMessage(e, "Failed to update platform"), "error"); + } finally { + savingPlatform.value = false; + } +} + const userTimezone = ref(""); const savingTimezone = ref(false); const timezoneSaved = ref(false); @@ -479,7 +549,7 @@ async function copyCommit() { const restoreFileInput = ref(null); // Migrate stored "admin" → "config"; unknown tabs fall back to "general" -const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas"]); +const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups", "areas", "platforms"]); const _stored = localStorage.getItem("settings_tab") ?? "general"; const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general"); @@ -489,6 +559,7 @@ function _loadTabContent(tab: string) { else if (tab === "logs") loadLogsPanel(); else if (tab === "groups") loadGroupsPanel(); else if (tab === "areas") canonStore.fetchCatalog(true); + else if (tab === "platforms") platformsStore.fetchCatalog(true); else if (tab === "config" && !versionInfo.value) loadVersionPanel(); } if (tab === "apikeys") { fetchApiKeys(); } @@ -1611,7 +1682,7 @@ async function deleteUser(userId: number) { + +
+
+

Platforms

+

+ What a project can be built on or ship as. A project's platforms decide which family + ideas reach it. Markers are repo-relative file patterns, one per line: a bare name + (go.mod) matches that file anywhere in a bound repo, and a pattern with a + slash (.github/workflows/*) matches the whole path. A platform with no + markers is never detected — projects declare it themselves. +

+ +
    +
  • + + +
  • +
+

+ No platforms yet. +

+ +
+ + + +
+ +
+
+
+
+
@@ -4386,6 +4550,8 @@ async function deleteUser(userId: number) { .area-admin-form { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; } .area-admin-create { margin-top: var(--fs-space-4); } .area-admin-actions { display: flex; gap: 0.4rem; } +.area-admin-markers { font-family: var(--fs-font-mono); font-size: 0.82rem; } +.area-admin-desc .area-admin-slug { margin-right: 0.25rem; } /* The retrieval tuning trail (#4102). Reads as a record, not a control panel: the operator is reviewing what was done, and the reason is the part worth diff --git a/src/scribe/app.py b/src/scribe/app.py index 0ebfa7da..a0d4c031 100644 --- a/src/scribe/app.py +++ b/src/scribe/app.py @@ -32,6 +32,7 @@ from scribe.routes.trash import trash_bp from scribe.routes.dashboard import dashboard_bp from scribe.routes.systems import systems_bp from scribe.routes.canonical_systems import canonical_systems_bp +from scribe.routes.platforms import platforms_bp from scribe.routes.lessons import lessons_bp from scribe.routes.snippets import snippets_bp from scribe.routes.webhooks import webhooks_bp @@ -101,6 +102,7 @@ def create_app() -> Quart: app.register_blueprint(dashboard_bp) app.register_blueprint(systems_bp) app.register_blueprint(canonical_systems_bp) + app.register_blueprint(platforms_bp) app.register_blueprint(snippets_bp) app.register_blueprint(webhooks_bp) diff --git a/src/scribe/mcp/server.py b/src/scribe/mcp/server.py index 7e4f628c..18520615 100644 --- a/src/scribe/mcp/server.py +++ b/src/scribe/mcp/server.py @@ -160,6 +160,9 @@ _READ_ONLY_TOOLS = frozenset({ # retrieval_telemetry's reason, and needed by a read key so that a line # naming a moment can be understood by whoever was shown it. "list_moments", + # The platform catalog and a project's answers (milestone 463). A pure + # read; set_project_platforms is the write. + "list_platforms", # The pass over the corpus and its queue (milestone 458 step 7): which # rules are unjudged and which proposals wait. Reads of the caller's own # rules, as list_rules is. @@ -183,6 +186,7 @@ _WRITE_TOOLS = frozenset({ # projects, Systems, repos "create_project", "update_project", "delete_project", "decide_project_inception", "create_system", "update_system", "delete_system", "map_system_to_canonical", + "set_project_platforms", "bind_repo", "unbind_repo", # snippets, processes, the shape ledger "create_snippet", "update_snippet", "delete_snippet", "verify_snippet", diff --git a/src/scribe/mcp/tools/__init__.py b/src/scribe/mcp/tools/__init__.py index 78bb9671..2bc5704b 100644 --- a/src/scribe/mcp/tools/__init__.py +++ b/src/scribe/mcp/tools/__init__.py @@ -6,7 +6,7 @@ from `mcp.server.build_mcp_server`. """ from scribe.mcp.tools import ( design_systems, lessons, milestones, notes, processes, projects, recent, repos, - moments, retrieval_review, retrieval_tuning, + moments, platforms, retrieval_review, retrieval_tuning, wide_net, rulebooks, search, shapes, snippets, systems, tags, tasks, trash, ) @@ -24,6 +24,7 @@ def register_all(mcp) -> None: projects.register(mcp) milestones.register(mcp) systems.register(mcp) + platforms.register(mcp) design_systems.register(mcp) tags.register(mcp) recent.register(mcp) diff --git a/src/scribe/mcp/tools/platforms.py b/src/scribe/mcp/tools/platforms.py new file mode 100644 index 00000000..ddc23fae --- /dev/null +++ b/src/scribe/mcp/tools/platforms.py @@ -0,0 +1,78 @@ +"""Platform MCP tools — what a project is built on or ships as (milestone 463). + +Thin wrappers over services/platforms.py. The catalog is global; a project's +platforms decide which family ideas reach it. +""" +from __future__ import annotations + +from scribe.mcp._context import current_user_id +from scribe.services import platforms as platforms_svc + + +async def list_platforms(project_id: int = 0) -> dict: + """The GLOBAL platform catalog — runtimes, delivery channels and + toolchains a project can be built on or ship as (Android app, container + image, Go, …) — and, with a project_id, that project's answer for each. + + A project's platforms decide which family ideas reach it: an idea is for + some platforms, and every project that is one of them answers it. Pass + slugs from here to set_project_platforms and decide_project_inception. + + Each platform's `markers` are the file patterns that let the coverage + refresh DETECT it in a bound repo. A project's `state` per platform is + `declared` (a person said so), `detected` (a marker said so) or `rejected` + (a person said no — kept so detection cannot add it back). Only declared + and detected are membership. + + Args: + project_id: also return this project's answers. 0 = catalog only. + """ + catalog = await platforms_svc.list_platforms() + out: dict = {"platforms": [p.to_dict() for p in catalog]} + if project_id: + rows = await platforms_svc.project_platforms(current_user_id(), project_id) + if rows is None: + raise ValueError(f"project {project_id} not found") + out["project_platforms"] = rows + return out + + +async def set_project_platforms( + project_id: int, + declared: list[str] | None = None, + rejected: list[str] | None = None, + withdrawn: list[str] | None = None, +) -> dict: + """Say which platforms a project is — or is not. Only the platforms named + change; everything else is left exactly as it is. + + Use it when the operator corrects what detection found, or when a project + starts or stops shipping something (it gains an Android client; it drops + its container image). At project creation, decide_project_inception's + `platforms` is the place instead — it records the answer with the rest of + what the project inherits. + + Args: + project_id: the project. + declared: slugs the project IS (list_platforms). + rejected: slugs it is NOT — recorded as a "no", so the coverage + refresh never detects it back. + withdrawn: slugs whose answer to drop entirely, so detection may + decide again on the next refresh. + """ + updates: dict[str, str | None] = {} + for slug in withdrawn or []: + updates[slug] = None + for slug in rejected or []: + updates[slug] = "rejected" + for slug in declared or []: + updates[slug] = "declared" + if not updates: + raise ValueError("name at least one platform to declare, reject or withdraw") + rows = await platforms_svc.set_project_platforms(current_user_id(), project_id, updates) + return {"project_id": project_id, "project_platforms": rows} + + +def register(mcp) -> None: + for fn in (list_platforms, set_project_platforms): + mcp.tool(name=fn.__name__)(fn) diff --git a/src/scribe/mcp/tools/projects.py b/src/scribe/mcp/tools/projects.py index 56491c21..087feecf 100644 --- a/src/scribe/mcp/tools/projects.py +++ b/src/scribe/mcp/tools/projects.py @@ -23,6 +23,7 @@ from scribe.services import design_systems as design_systems_svc from scribe.services import inception as inception_svc from scribe.services import milestones as milestones_svc from scribe.services import notes as notes_svc +from scribe.services import platforms as platforms_svc from scribe.services import projects as projects_svc from scribe.services import rulebooks as rulebooks_svc from scribe.services import systems as systems_svc @@ -125,9 +126,15 @@ async def enter_project(project_id: int) -> dict: create it with create_system rather than leaving the area unmodelled. Read a subsystem's accumulated records with list_system_records. Each is id and name; get_system has the charter. + `platforms` (milestone 463) is what the project is built on or ships as + — Android app, container image, Go, … — each with how it is known + (`declared` by a person, `detected` from a bound repo). They decide which + family ideas reach this project. An empty list on a project that plainly + ships something is worth correcting with set_project_platforms. + `inception` (milestone 297) appears ONLY when the project is yours and nobody has decided what it inherits: it carries the current defaults - (design system, Systems), what to ask the + (design system, Systems, platforms), what to ask the operator — once — and the decide_project_inception call that answers it; it repeats on every enter until a decision is recorded. @@ -188,6 +195,9 @@ async def enter_project(project_id: int) -> dict: # three days of the feature landing (#2546's audit). Untagged writes now # also ask with the vocabulary listed; this copy lets the first write tag. systems = await systems_svc.list_systems(uid, project_id) + platforms = platforms_svc.members( + await platforms_svc.project_platforms(uid, project_id) or [] + ) # The arrival-moment half of the bootstrap ask (#2683): session start is # when the agent has just read the project map and is not yet deep in a @@ -258,6 +268,7 @@ async def enter_project(project_id: int) -> dict: }, "pattern_coverage": coverage_svc.coverage_line(coverage) if coverage else None, "systems": [{"id": s.id, "name": s.name} for s in systems], + "platforms": platforms, "design_system": design_system, "milestone_summary": milestone_summary, **rulebooks_svc.rules_payload( @@ -302,12 +313,15 @@ async def get_project(project_id: int) -> dict: rules (project_rules), and applicable_rules: the global rules tagged to an area this project works in. Every other global rule applies too and arrives by retrieval when the work matches it. + `platforms` is every platform the project has an answer for, with its + state — rejected ones included, unlike enter_project's brief list. """ uid = current_user_id() project = await projects_svc.get_project(uid, project_id) if project is None: raise ValueError(f"project {project_id} not found") data = project.to_dict() + data["platforms"] = await platforms_svc.project_platforms(uid, project_id) or [] rows = await milestones_svc.get_project_milestone_summary(uid, project_id) data["milestone_summary"], _ = milestones_svc.brief_milestone_summary(rows) applicable = await rulebooks_svc.get_applicable_rules( @@ -317,17 +331,21 @@ async def get_project(project_id: int) -> dict: return data -def _inception_choices(design_system_id, seed_systems) -> dict | None: +def _inception_choices(design_system_id, seed_systems, platforms=None) -> dict | None: """The tool args → an inception choices object, or None when no inception arg was given at all (a bare create stays undecided and enter_project asks). design_system_id: 0 = not stated, -1 = explicitly none, n = that - system.""" - if not design_system_id and seed_systems is None: + system. platforms: None = not stated (memberships untouched), a list = + the whole answer.""" + if not design_system_id and seed_systems is None and platforms is None: return None - return { + choices = { "design_system_id": None if design_system_id in (0, -1) else design_system_id, "seed_systems": bool(seed_systems), } + if platforms is not None: + choices["platforms"] = list(platforms) + return choices async def create_project( @@ -338,11 +356,12 @@ async def create_project( color: str = "", design_system_id: int = 0, seed_systems: bool | None = None, + platforms: list[str] | None = None, ) -> dict: """Create a new project in Scribe — and decide what it inherits. A project's inheritance is a decision, not a default (milestone 297): - before calling, ask the operator the two inception questions and pass + before calling, ask the operator the three inception questions and pass the answers; a project created without either is UNDECIDED and enter_project will ask until decide_project_inception records it. Defaults if nobody decides: no design system, no Systems. Rules are not @@ -359,6 +378,9 @@ async def create_project( (list_design_systems); -1 = explicitly none; 0 = not stated. seed_systems: true mints the standard starter Systems (CI & Release, Auth & Access, …) so records can be tagged from day one. + platforms: slugs of what the project is built on or ships as + (list_platforms) — they decide which family ideas reach it. The + list is the whole answer; omit it to leave the question open. """ uid = current_user_id() project = await projects_svc.create_project( @@ -370,7 +392,7 @@ async def create_project( color=color or None, ) data = project.to_dict() - choices = _inception_choices(design_system_id, seed_systems) + choices = _inception_choices(design_system_id, seed_systems, platforms) if choices is not None: decided = await inception_svc.decide(uid, project.id, choices=choices, via="mcp") data["inception"] = decided["inception"] @@ -388,6 +410,7 @@ async def decide_project_inception( project_id: int, design_system_id: int = 0, seed_systems: bool | None = None, + platforms: list[str] | None = None, ) -> dict: """Record what a project inherits — answer enter_project's `inception` ask, or re-decide later (milestone 297). @@ -400,10 +423,10 @@ async def decide_project_inception( Args: as create_project's inception args. Passing nothing records a decision to take nothing (no design system, no seed) — a valid answer, - stated. + stated — and leaves the project's platforms as they are. """ uid = current_user_id() - choices = _inception_choices(design_system_id, seed_systems) or {} + choices = _inception_choices(design_system_id, seed_systems, platforms) or {} decided = await inception_svc.decide(uid, project_id, choices=choices, via="mcp") return {"project_id": project_id, **decided} diff --git a/src/scribe/routes/platforms.py b/src/scribe/routes/platforms.py new file mode 100644 index 00000000..0c040aad --- /dev/null +++ b/src/scribe/routes/platforms.py @@ -0,0 +1,96 @@ +"""Platform routes — the GLOBAL platform catalog, and which platforms a +project is (milestone 463 step 2). + +Two shapes, as with the canonical areas: + +- `/api/platforms` — the catalog. Readable by any signed-in user; writable + only by an admin, since a vocabulary anyone extends stops being shared. +- `/api/projects//platforms` — a project's answers, authorised by the + PROJECT (read to see them, write to change them). The service enforces + both; these are thin wrappers. +""" +import logging + +from quart import Blueprint, jsonify, request + +from scribe.auth import admin_required, get_current_user_id, login_required +from scribe.routes.utils import not_found +from scribe.services import platforms as platforms_svc + +logger = logging.getLogger(__name__) + +platforms_bp = Blueprint("platforms", __name__, url_prefix="/api") + +_EDITABLE = ("name", "description", "order_index", "markers") + + +@platforms_bp.route("/platforms", methods=["GET"]) +@login_required +async def list_platforms_route(): + entries = await platforms_svc.list_platforms() + return jsonify({"platforms": [e.to_dict() for e in entries]}) + + +@platforms_bp.route("/platforms", methods=["POST"]) +@admin_required +async def create_platform_route(): + uid = get_current_user_id() + data = await request.get_json() or {} + if not (data.get("name") or "").strip(): + return jsonify({"error": "name is required"}), 400 + try: + entry = await platforms_svc.create_platform( + uid, data["name"], description=data.get("description"), + markers=data.get("markers"), + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if entry is None: + return jsonify({"error": "Permission denied"}), 403 + # Duplicate-gated on the slug: the entry that already is this platform + # comes back as a 409 rather than a second spelling of it. + if isinstance(entry, dict): + return jsonify(entry), 409 + return jsonify(entry.to_dict()), 201 + + +@platforms_bp.route("/platforms/", methods=["PATCH"]) +@admin_required +async def update_platform_route(platform_id: int): + uid = get_current_user_id() + data = await request.get_json() or {} + fields = {k: v for k, v in data.items() if k in _EDITABLE} + try: + entry = await platforms_svc.update_platform(uid, platform_id, **fields) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if entry is None: + return not_found("Platform") + return jsonify(entry.to_dict()) + + +@platforms_bp.route("/projects//platforms", methods=["GET"]) +@login_required +async def project_platforms_route(project_id: int): + """Every platform the project has an answer for — rejected included, so + a screen can show "no" as well as "yes".""" + rows = await platforms_svc.project_platforms(get_current_user_id(), project_id) + if rows is None: + return not_found("Project") + return jsonify({"project_platforms": rows}) + + +@platforms_bp.route("/projects//platforms", methods=["PUT"]) +@login_required +async def set_project_platforms_route(project_id: int): + """Body: {"platforms": {: "declared" | "rejected" | null}}. Only + the slugs named change; null withdraws the answer. Applies whole or not + at all.""" + data = await request.get_json() or {} + try: + rows = await platforms_svc.set_project_platforms( + get_current_user_id(), project_id, data.get("platforms"), + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify({"project_platforms": rows}) diff --git a/src/scribe/services/access.py b/src/scribe/services/access.py index 2a433741..31f8cc87 100644 --- a/src/scribe/services/access.py +++ b/src/scribe/services/access.py @@ -103,6 +103,15 @@ async def can_admin_project(user_id: int, project_id: int) -> bool: return perm in ("admin", "owner") +async def is_instance_admin(user_id: int) -> bool: + """Whether the user administers the INSTANCE (users.role == "admin") — + the gate on the global catalogs (canonical areas, platforms), which belong + to no user and no project, so no share can grant a write to them.""" + async with async_session() as session: + role = await session.scalar(select(User.role).where(User.id == user_id)) + return role == "admin" + + # --------------------------------------------------------------------------- # Note / task permissions # --------------------------------------------------------------------------- diff --git a/src/scribe/services/canonical_systems.py b/src/scribe/services/canonical_systems.py index d1c56dd7..d6c43136 100644 --- a/src/scribe/services/canonical_systems.py +++ b/src/scribe/services/canonical_systems.py @@ -30,7 +30,6 @@ from sqlalchemy import select from scribe.models import async_session from scribe.models.canonical_system import CanonicalSystem from scribe.models.system import System -from scribe.models.user import User from scribe.services import access logger = logging.getLogger(__name__) @@ -60,9 +59,7 @@ def _tokens(slug: str) -> frozenset[str]: async def _is_admin(user_id: int) -> bool: - async with async_session() as session: - role = await session.scalar(select(User.role).where(User.id == user_id)) - return role == "admin" + return await access.is_instance_admin(user_id) async def list_canonical_systems() -> list[CanonicalSystem]: diff --git a/src/scribe/services/coverage.py b/src/scribe/services/coverage.py index b8f6bf99..7f8e3b28 100644 --- a/src/scribe/services/coverage.py +++ b/src/scribe/services/coverage.py @@ -660,6 +660,11 @@ class ArchiveScan(NamedTuple): definitions: list[ArchiveShape] references: dict[str, dict[str, int]] # path → class token → count + # EVERY file's repo-relative path, scannable or not (milestone 463). The + # platform markers are files this scan otherwise skips — go.mod, + # AndroidManifest.xml, a Dockerfile — so detection reads the names here + # rather than re-walking the tarball. + paths: tuple[str, ...] = () def definitions_from_archive(blob: bytes) -> list[ArchiveShape]: @@ -678,11 +683,14 @@ def scan_archive(blob: bytes) -> ArchiveScan: """ shapes: list[ArchiveShape] = [] references: dict[str, dict[str, int]] = {} + paths: list[str] = [] with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as tar: for member in tar: if not member.isfile() or "/" not in member.name: continue path = member.name.split("/", 1)[1] + if path: + paths.append(path) if not path or not scannable(path) or member.size > _MAX_FILE_BYTES: continue handle = tar.extractfile(member) @@ -708,7 +716,7 @@ def scan_archive(blob: bytes) -> ArchiveScan: ) if refs: references[path] = refs - return ArchiveScan(shapes, references) + return ArchiveScan(shapes, references, tuple(paths)) # --- matching shapes against recorded locations ------------------------------ @@ -806,6 +814,8 @@ async def compute_coverage( return None served: list[tuple[str, str]] = [] + # Every file path across the project's repos, for platform detection. + tree_paths: list[str] = [] recorded = await _recorded_locations(user_id, project_id) # The proposer's canon catalog, read once per refresh and shared across # the project's repos (#2792). @@ -822,6 +832,7 @@ async def compute_coverage( ref = binding.ref or await forge.default_branch(api_repo) scan = scan_archive(await forge.archive(api_repo, ref)) definitions = scan.definitions + tree_paths.extend(scan.paths) # The head commit is provenance sugar on the ledger rows; failing to # learn it must not fail the sync — the ref names the point well # enough and the row timestamps carry the when. @@ -857,6 +868,16 @@ async def compute_coverage( if not served: return None + # Platform detection (milestone 463) rides the same walk: the paths are in + # hand once. It only ever ADDS membership the project has no answer for, + # and it must not be able to fail the refresh it rides on. + try: + from scribe.services import platforms as platforms_svc + + await platforms_svc.detect_for_project(project_id, tree_paths) + except Exception: + logger.warning("platform detection failed for project %s", project_id, exc_info=True) + await shape_ledger.mark_canonicals(project_id, recorded) try: await shape_ledger.apply_derive_groups(project_id) diff --git a/src/scribe/services/inception.py b/src/scribe/services/inception.py index 52f838e2..4e9d48bd 100644 --- a/src/scribe/services/inception.py +++ b/src/scribe/services/inception.py @@ -8,7 +8,8 @@ A project's inheritance is a decision, not a default. The record lives on "via": "mcp" | "ui" | "legacy", "choices": { "design_system_id": | null, - "seed_systems": bool + "seed_systems": bool, + "platforms": [, ...] | null } } @@ -29,6 +30,15 @@ applies the effects (each idempotent), and writes the record LAST, so a half-applied decision is re-runnable rather than recorded as done. ``current_defaults`` is what the enter_project ask shows: what binds today if nobody decides. + +``platforms`` (milestone 463) is which platforms the project IS — the answer +that decides which family ideas reach it. A list of catalog SLUGS, not ids: +the record is JSON, and a slug survives a backup restore onto an install whose +ids differ, where an id inside JSON would come back naming another platform. +NULL means the question was not answered here, and the project's memberships +are left exactly as they are; a list is the full answer — every platform in it +is declared, and any platform detection had added that is NOT in it is +recorded as a "no", so the next refresh cannot put it back. """ from __future__ import annotations @@ -38,7 +48,7 @@ from scribe.models import async_session from scribe.models.project import Project INCEPTION_VIAS = ("mcp", "ui", "legacy") -CHOICE_KEYS = ("design_system_id", "seed_systems") +CHOICE_KEYS = ("design_system_id", "seed_systems", "platforms") def validate_inception(choices) -> str | None: @@ -46,8 +56,9 @@ def validate_inception(choices) -> str | None: None. Pure and checked BEFORE any effect is applied: a decision either applies whole or errors whole (the StrictArgs lesson, #2709). - Accepts two keys, each optional: ``design_system_id`` an int or None, - ``seed_systems`` a bool. Unknown keys are an error — a typo, or a choice + Accepts three keys, each optional: ``design_system_id`` an int or None, + ``seed_systems`` a bool, ``platforms`` a list of slugs or None. Unknown + keys are an error — a typo, or a choice the product no longer offers, must not become a silently ignored one.""" if not isinstance(choices, dict): return "choices must be an object" @@ -60,16 +71,26 @@ def validate_inception(choices) -> str | None: seed = choices.get("seed_systems", False) if not isinstance(seed, bool): return "seed_systems must be true or false" + platforms = choices.get("platforms") + if platforms is not None and ( + not isinstance(platforms, list) + or not all(isinstance(p, str) and p.strip() for p in platforms) + ): + return "platforms must be a list of platform slugs (list_platforms), or null" return None def normalize_choices(choices: dict | None) -> dict: - """Both keys, always present, in canonical form — what gets stored + """Every key, always present, in canonical form — what gets stored and what the UI/agent reads back. Call after validate_inception.""" choices = choices or {} + platforms = choices.get("platforms") return { "design_system_id": choices.get("design_system_id"), "seed_systems": bool(choices.get("seed_systems", False)), + "platforms": ( + sorted({p.strip() for p in platforms}) if platforms is not None else None + ), } @@ -81,11 +102,15 @@ def is_decided(project) -> bool: async def current_defaults(user_id: int, project_id: int) -> dict: """What the project inherits if nobody decides — the ask's payload. - {design_system_id, design_systems: [{id,title}], systems: }. + {design_system_id, design_systems: [{id,title}], systems: , + platforms: [{slug,name}], project_platforms: [{slug,name,state}]}. Instance-agnostic: an install with no design systems shows an empty list, - and the ask says so rather than inventing a default. + and the ask says so rather than inventing a default. `project_platforms` + is what detection has already found (and anything already answered), so + the form can start from it. """ from scribe.services import design_systems as design_systems_svc + from scribe.services import platforms as platforms_svc from scribe.services import projects as projects_svc from scribe.services import systems as systems_svc @@ -94,10 +119,13 @@ async def current_defaults(user_id: int, project_id: int) -> dict: raise ValueError(f"project {project_id} not found") designs = await design_systems_svc.list_design_systems(user_id) systems = await systems_svc.list_systems(user_id, project_id, include_archived=True) + catalog = await platforms_svc.list_platforms() return { "design_system_id": project.design_system_id, "design_systems": [{"id": d.id, "title": d.title} for d in designs], "systems": len(systems), + "platforms": [{"slug": p.slug, "name": p.name} for p in catalog], + "project_platforms": await platforms_svc.project_platforms(user_id, project_id) or [], } @@ -106,9 +134,15 @@ async def _check_targets(user_id: int, choices: dict) -> None: effect lands — a decision applies whole or errors whole.""" from scribe.services import access + from scribe.services import platforms as platforms_svc + ds = choices["design_system_id"] if ds is not None and not await access.can_read_design_system(user_id, ds): raise ValueError(f"design system {ds} not found (or not readable)") + if choices["platforms"]: + _, unknown = await platforms_svc.resolve_slugs(choices["platforms"]) + if unknown: + raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)") async def decide( @@ -127,7 +161,8 @@ async def decide( replaces the design system and re-seeds nothing a project already has. Returns {"inception": , "effects": {design_system_id, - systems_seeded}}. + systems_seeded, platforms}} — `platforms` is the project's answers after + the decision, or None when the choice was left unstated. """ from scribe.services import design_systems as design_systems_svc from scribe.services import projects as projects_svc @@ -152,6 +187,9 @@ async def decide( await systems_svc.seed_standard_systems(user_id, project_id) if choices["seed_systems"] else [] ) + platforms = None + if choices["platforms"] is not None: + platforms = await _apply_platforms(user_id, project_id, choices["platforms"]) record = { "decided_at": datetime.now(timezone.utc).isoformat(), @@ -169,10 +207,41 @@ async def decide( "effects": { "design_system_id": choices["design_system_id"], "systems_seeded": [sy.name for sy in seeded], + "platforms": platforms, }, } +async def _apply_platforms(user_id: int, project_id: int, slugs: list[str]) -> list[dict]: + """The platforms answer as membership. The list is the WHOLE answer: + every slug in it is declared, and every platform the project was a member + of (declared or detected) that is left out becomes rejected — so the next + refresh cannot detect back what the person just said the project isn't. + Platforms already rejected stay rejected; platforms never answered stay + unanswered.""" + from scribe.services import platforms as platforms_svc + + named = set(slugs) + current = await platforms_svc.project_platforms(user_id, project_id) or [] + updates: dict[str, str | None] = {slug: "declared" for slug in named} + for row in current: + if row["slug"] not in named and row["state"] in platforms_svc.MEMBER_STATES: + updates[row["slug"]] = "rejected" + return await platforms_svc.set_project_platforms(user_id, project_id, updates) + + +def _platform_line(defaults: dict) -> str: + """What the ask says about platforms: what detection already found, and + the catalog to choose from.""" + found = [ + p["slug"] for p in defaults.get("project_platforms", []) + if p["state"] in ("declared", "detected") + ] + catalog = ", ".join(p["slug"] for p in defaults.get("platforms", [])) or "none" + lead = f"detected so far: {', '.join(found)}; " if found else "" + return f"{lead}catalog: {catalog}" + + async def inception_ask(user_id: int, project_id: int) -> dict: """The enter_project ask for an undecided project (milestone 297) — the sibling of the systems-bootstrap ask (#2683): the project's OWN current @@ -190,13 +259,16 @@ async def inception_ask(user_id: int, project_id: int) -> dict: "inherits. Design system — " f"{'#' + str(defaults['design_system_id']) if defaults['design_system_id'] else 'none'} " f"(available: {designs}); Systems — {defaults['systems']}. Ask the operator, " - "once: which design system (or none), and whether to seed " - "the standard starter Systems — then record the answers. This ask repeats on " - "every enter_project until a decision is recorded." + "once: which design system (or none), whether to seed " + "the standard starter Systems, and which platforms the project is " + f"built on or ships as ({_platform_line(defaults)}) — then record the " + "answers. This ask repeats on every enter_project until a decision " + "is recorded." ), "call": ( f"decide_project_inception(project_id={project_id}, " - "design_system_id=, seed_systems=)" + "design_system_id=, seed_systems=, " + "platforms=[, ...])" ), } diff --git a/src/scribe/services/platforms.py b/src/scribe/services/platforms.py new file mode 100644 index 00000000..675c72a5 --- /dev/null +++ b/src/scribe/services/platforms.py @@ -0,0 +1,335 @@ +"""Platforms — what a project is built on or ships as, and which projects are +which (milestone 463 step 2). + +Membership is what makes "is this idea in family for that project?" a lookup +rather than a judgment: a family idea is for some platforms, and it reaches +every project that is a member of one of them. + +Three ways a project becomes, or refuses to become, a member: + +- **declared** — a person said so: at inception, or in the project's settings. +- **detected** — a marker file in a bound repo said so, found by the coverage + refresh that already walks the repo archive. +- **rejected** — a person said NO. Kept as a row, so the next refresh does not + detect it straight back. + +The one invariant everything here protects: **detection only ever ADDS, and +only where nobody has answered.** It never overwrites a declared or rejected +row, and it never removes anything — not even a detected row whose marker has +since disappeared, because membership is what adoption rows hang off and a +platform flickering in and out with a repo's file tree would churn a ledger +of decisions nobody re-made. + +The catalog itself is global, like the canonical area catalog, and for the +same reason writes to it are admin-only: a shared vocabulary anyone can extend +stops being shared. Reads are open to any signed-in user. +""" +from __future__ import annotations + +import fnmatch +import logging +from datetime import datetime, timezone + +from sqlalchemy import select + +from scribe.models import async_session +from scribe.models.family import Platform, ProjectPlatform +from scribe.services import access +from scribe.services.canonical_systems import canonical_slug + +logger = logging.getLogger(__name__) + +# The states that make a project a member. `rejected` is an answer, not +# membership. +MEMBER_STATES = ("declared", "detected") +# What a person may set from a door. `detected` is the refresh's to write. +SETTABLE_STATES = ("declared", "rejected") + + +# --- the catalog ------------------------------------------------------------- + +async def list_platforms() -> list[Platform]: + """The whole live catalog, in display order. Global — no owner filter.""" + async with async_session() as session: + result = await session.execute( + select(Platform) + .where(Platform.deleted_at.is_(None)) + .order_by(Platform.order_index.asc(), Platform.name.asc()) + ) + return list(result.scalars().all()) + + +def _clean_markers(markers) -> list[str]: + """Markers as stored: stripped, non-empty strings, de-duplicated in order. + Anything else is refused by the caller before it gets here.""" + seen: list[str] = [] + for m in markers or []: + m = str(m).strip() + if m and m not in seen: + seen.append(m) + return seen + + +def validate_markers(markers) -> str | None: + """The error a markers value would earn, or None. Pure.""" + if markers is None: + return None + if not isinstance(markers, list) or not all(isinstance(m, str) for m in markers): + return "markers must be a list of glob patterns" + if any(m.strip().startswith("/") for m in markers): + return "markers are repo-relative — no leading slash" + return None + + +async def create_platform( + user_id: int, name: str, *, description: str | None = None, + markers: list[str] | None = None, +) -> Platform | dict | None: + """Add a platform to the global catalog. Admin only. + + Duplicate-gated on the slug, so "Android App" cannot be added beside + "Android app": the existing entry comes back instead of a second spelling + of it. None means not permitted, or no usable name. + """ + if not await access.is_instance_admin(user_id): + return None + slug = canonical_slug(name) + if not slug: + return None + error = validate_markers(markers) + if error: + raise ValueError(error) + async with async_session() as session: + existing = await session.scalar( + select(Platform).where(Platform.slug == slug, Platform.deleted_at.is_(None)) + ) + if existing is not None: + return { + "duplicate": True, + "existing_id": existing.id, + "message": ( + f"'{existing.name}' (#{existing.id}) is already this platform — " + f"both names reduce to '{slug}'." + ), + } + highest = await session.scalar( + select(Platform.order_index).order_by(Platform.order_index.desc()).limit(1) + ) + entry = Platform( + name=" ".join(name.split()), + slug=slug, + description=description, + markers=_clean_markers(markers), + order_index=(highest or 0) + 1, + ) + session.add(entry) + await session.commit() + await session.refresh(entry) + return entry + + +async def update_platform(user_id: int, platform_id: int, **fields: object) -> Platform | None: + """Rename, re-describe, re-order or re-mark a catalog entry. Admin only. + + A rename recomputes the slug — the display name and the match key must not + disagree. Changing the slug of a platform projects already belong to is + safe: membership is by id; only a backup carries the slug. + """ + if not await access.is_instance_admin(user_id): + return None + if "markers" in fields: + error = validate_markers(fields["markers"]) + if error: + raise ValueError(error) + async with async_session() as session: + entry = await session.get(Platform, platform_id) + if entry is None or entry.deleted_at is not None: + return None + if fields.get("name"): + entry.name = " ".join(str(fields["name"]).split()) + entry.slug = canonical_slug(entry.name) + if fields.get("description") is not None: + entry.description = fields["description"] or None + if fields.get("order_index") is not None: + entry.order_index = int(fields["order_index"]) + if fields.get("markers") is not None: + entry.markers = _clean_markers(fields["markers"]) + entry.updated_at = datetime.now(timezone.utc) + await session.commit() + await session.refresh(entry) + return entry + + +async def resolve_slugs(slugs: list[str]) -> tuple[dict[str, int], list[str]]: + """{slug: id} for the slugs the live catalog knows, and the ones it does + not. Doors take slugs — readable, stable across installs, and the form an + inception record and a backup both carry.""" + wanted = [s for s in dict.fromkeys(slugs or [])] + if not wanted: + return {}, [] + async with async_session() as session: + rows = (await session.execute( + select(Platform.slug, Platform.id).where( + Platform.slug.in_(wanted), Platform.deleted_at.is_(None), + ) + )).all() + known = {slug: pid for slug, pid in rows} + return known, [s for s in wanted if s not in known] + + +# --- detection (pure) -------------------------------------------------------- + +def marker_matches(marker: str, path: str) -> bool: + """Whether one marker matches one repo-relative path. + + A marker with no slash matches a file's BASENAME anywhere in the tree + (`go.mod`, `AndroidManifest.xml`, `vite.config.*`). A marker with a slash + matches the whole repo-relative path (`.github/workflows/*`), so a + directory-shaped marker cannot be satisfied by a same-named file + somewhere else. + """ + marker = marker.strip() + if not marker: + return False + if "/" in marker: + return fnmatch.fnmatchcase(path, marker) + return fnmatch.fnmatchcase(path.rsplit("/", 1)[-1], marker) + + +def detect(catalog: list, paths: list[str]) -> list[int]: + """The ids of every catalog platform at least one of whose markers matches + at least one path. Pure, so it is testable against a fixture tree with no + forge and no database. A platform with no markers is never detected — + that is what declare-only means.""" + hits: list[int] = [] + for platform in catalog: + markers = list(getattr(platform, "markers", None) or []) + if markers and any(marker_matches(m, p) for m in markers for p in paths): + hits.append(platform.id) + return hits + + +# --- membership ---------------------------------------------------------------- + +async def project_platforms(user_id: int, project_id: int) -> list[dict] | None: + """Every platform the project has an answer for, joined to the catalog. + None when the caller cannot read the project. + + Rejected rows are included — a settings screen has to show "no" as well + as "yes", or it cannot let anyone change their mind. + """ + if not await access.can_read_project(user_id, project_id): + return None + async with async_session() as session: + rows = (await session.execute( + select(ProjectPlatform, Platform) + .join(Platform, Platform.id == ProjectPlatform.platform_id) + .where(ProjectPlatform.project_id == project_id, Platform.deleted_at.is_(None)) + .order_by(Platform.order_index.asc(), Platform.name.asc()) + )).all() + return [ + {"id": p.id, "slug": p.slug, "name": p.name, "state": m.state} + for m, p in rows + ] + + +def members(platforms: list[dict]) -> list[dict]: + """The rows of `project_platforms` that are membership — the brief form + enter_project and get_project carry.""" + return [ + {"slug": p["slug"], "name": p["name"], "state": p["state"]} + for p in platforms if p["state"] in MEMBER_STATES + ] + + +def validate_updates(updates) -> str | None: + """The error a set of membership answers would earn, or None. Pure. + + `updates` is {slug: "declared" | "rejected" | None}. None withdraws the + answer — the row goes, and detection may add the platform again later. + `detected` is not settable: it is what the refresh writes, and a person + claiming it would erase the difference between the two.""" + if not isinstance(updates, dict): + return "platforms must be an object of {slug: state}" + for slug, state in updates.items(): + if not isinstance(slug, str) or not slug: + return "each platform is named by its slug" + if state is not None and state not in SETTABLE_STATES: + return ( + f"'{state}' is not a state a person sets — use one of " + f"{', '.join(SETTABLE_STATES)}, or null to withdraw the answer" + ) + return None + + +async def set_project_platforms( + user_id: int, project_id: int, updates: dict[str, str | None], +) -> list[dict]: + """Apply a person's answers about a project's platforms. Write-gated on + the project (rule 78). Platforms not named are left exactly as they are. + + Raises ValueError, naming the problem, on a malformed update, an unknown + slug, or no write access — before anything is written, so the update + applies whole or not at all. + """ + error = validate_updates(updates) + if error: + raise ValueError(error) + if not await access.can_write_project(user_id, project_id): + raise ValueError(f"project {project_id} not found or no write access") + known, unknown = await resolve_slugs(list(updates)) + if unknown: + raise ValueError(f"unknown platform(s): {', '.join(unknown)} (list_platforms)") + async with async_session() as session: + existing = { + row.platform_id: row for row in (await session.execute( + select(ProjectPlatform).where(ProjectPlatform.project_id == project_id) + )).scalars().all() + } + for slug, state in updates.items(): + pid = known[slug] + row = existing.get(pid) + if state is None: + if row is not None: + await session.delete(row) + elif row is None: + session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state=state)) + else: + row.state = state + await session.commit() + return await project_platforms(user_id, project_id) or [] + + +async def record_detected(project_id: int, platform_ids: list[int]) -> list[int]: + """Add `detected` membership for each platform the project has NO answer + for. Returns the ids actually added. + + The refresh's writer, so it takes no user: it runs on the owner's behalf + inside a sync the owner's keyring authorised. It never updates or deletes + a row — see the module docstring for why that is the whole contract. + """ + if not platform_ids: + return [] + async with async_session() as session: + answered = set((await session.execute( + select(ProjectPlatform.platform_id).where( + ProjectPlatform.project_id == project_id, + ) + )).scalars().all()) + added = [pid for pid in dict.fromkeys(platform_ids) if pid not in answered] + for pid in added: + session.add(ProjectPlatform(project_id=project_id, platform_id=pid, state="detected")) + await session.commit() + return added + + +async def detect_for_project(project_id: int, paths: list[str]) -> list[int]: + """Run detection over a refresh's paths and record what is new. The + coverage refresh's single call — it must not be able to fail that + refresh, so the caller wraps it.""" + hits = detect(await list_platforms(), paths) + added = await record_detected(project_id, hits) + if added: + logger.info("project %s: detected platform(s) %s", project_id, added) + return added + diff --git a/tests/helpers.py b/tests/helpers.py index 54473ab9..21ace608 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -512,3 +512,18 @@ def skill_text(name: str) -> str: folder = pathlib.Path(__file__).resolve().parents[1] / "plugin" / "skills" / name refs = sorted(p for p in folder.glob("*.md") if p.name != "SKILL.md") return "\n\n".join(p.read_text() for p in [folder / "SKILL.md", *refs]) + + +def forge_tarball(files: dict[str, bytes], top: str = "widget") -> bytes: + """A gzipped tar shaped like a forge archive: every file under one + top-level directory (repo-ref/), which the scan strips.""" + import io + import tarfile + + buf = io.BytesIO() + with tarfile.open(fileobj=buf, mode="w:gz") as tar: + for path, data in files.items(): + info = tarfile.TarInfo(f"{top}/{path}") + info.size = len(data) + tar.addfile(info, io.BytesIO(data)) + return buf.getvalue() diff --git a/tests/test_inception.py b/tests/test_inception.py index 8e316c5e..c85dc43a 100644 --- a/tests/test_inception.py +++ b/tests/test_inception.py @@ -27,7 +27,7 @@ def test_project_carries_an_inception_record_and_to_dict_shows_it(): def test_validate_inception_pins_the_choice_vocabulary(): - assert CHOICE_KEYS == ("design_system_id", "seed_systems") + assert CHOICE_KEYS == ("design_system_id", "seed_systems", "platforms") assert validate_inception({}) is None assert validate_inception({"design_system_id": 3, "seed_systems": True}) is None assert validate_inception({"design_system_id": None}) is None @@ -46,10 +46,28 @@ def test_validate_inception_pins_the_choice_vocabulary(): assert "true or false" in validate_inception({"seed_systems": "yes"}) +def test_validate_inception_takes_platforms_as_slugs_or_unstated(): + """Slugs, not ids: an inception record outlives a restore, and a slug is + what both sides of a restore agree on. None is "not answered", which is + different from [] — "none of them".""" + assert validate_inception({"platforms": ["android-app", "go"]}) is None + assert validate_inception({"platforms": []}) is None + assert validate_inception({"platforms": None}) is None + assert "platform slugs" in validate_inception({"platforms": "android-app"}) + assert "platform slugs" in validate_inception({"platforms": [3]}) + assert "platform slugs" in validate_inception({"platforms": [" "]}) + + def test_normalize_choices_is_canonical_and_complete(): out = normalize_choices({"design_system_id": 4}) - assert out == {"design_system_id": 4, "seed_systems": False} - assert normalize_choices(None) == {"design_system_id": None, "seed_systems": False} + assert out == {"design_system_id": 4, "seed_systems": False, "platforms": None} + assert normalize_choices(None) == { + "design_system_id": None, "seed_systems": False, "platforms": None, + } + # Sorted and de-duplicated, so two equal answers record identically. + assert normalize_choices({"platforms": ["go", " android-app", "go"]})["platforms"] == [ + "android-app", "go", + ] def test_standard_systems_vocabulary_reads_the_catalog_not_a_constant(): diff --git a/tests/test_integration_inception.py b/tests/test_integration_inception.py index 8a92078e..abfd372a 100644 --- a/tests/test_integration_inception.py +++ b/tests/test_integration_inception.py @@ -37,14 +37,16 @@ async def seeded(): async def test_decide_applies_every_effect_and_records_last(seeded): owner, pid = seeded["owner"], seeded["pid"] defaults = await inception_svc.current_defaults(owner, pid) - assert set(defaults) == {"design_system_id", "design_systems", "systems"} + assert set(defaults) == { + "design_system_id", "design_systems", "systems", "platforms", "project_platforms", + } assert defaults["systems"] == 0 and defaults["design_system_id"] is None out = await inception_svc.decide(owner, pid, via="mcp", choices={ "design_system_id": None, "seed_systems": True, }) - assert set(out["effects"]) == {"design_system_id", "systems_seeded"} + assert set(out["effects"]) == {"design_system_id", "systems_seeded", "platforms"} catalog = await canonical_svc.list_canonical_systems() assert len(out["effects"]["systems_seeded"]) == len(catalog) # Seeded Systems come out mapped, not needing a later reconciliation. @@ -56,7 +58,9 @@ async def test_decide_applies_every_effect_and_records_last(seeded): project = await s.get(Project, pid) assert inception_svc.is_decided(project) assert project.inception["via"] == "mcp" and project.inception["decided_by"] == owner - assert project.inception["choices"] == {"design_system_id": None, "seed_systems": True} + assert project.inception["choices"] == { + "design_system_id": None, "seed_systems": True, "platforms": None, + } # Re-deciding with seed again mints nothing twice. again = await inception_svc.decide(owner, pid, via="ui", choices={"seed_systems": True}) assert again["effects"]["systems_seeded"] == [] diff --git a/tests/test_integration_platforms.py b/tests/test_integration_platforms.py new file mode 100644 index 00000000..2b152ef2 --- /dev/null +++ b/tests/test_integration_platforms.py @@ -0,0 +1,134 @@ +"""Real-Postgres checks for platform membership (milestone 463 step 2). + +The one invariant: detection only ever ADDS, and only where nobody has +answered. A refresh that overwrote a "no" would put a project back into a +family it was taken out of; one that overwrote a "yes" would erase who said +it. Each is shown here against the real writer, beside the person-facing +writes it must defer to. +""" +import pytest +import pytest_asyncio +from sqlalchemy import select + +from scribe.models import async_session +from scribe.models.family import Platform +from scribe.models.project import Project +from scribe.services import inception as inception_svc +from scribe.services import platforms as platforms_svc +from tests.helpers import ensure_user + +pytestmark = [pytest.mark.integration, pytest.mark.usefixtures("_dispose_engine")] + +OWNER = "platforms_owner" + + +async def _id(slug: str) -> int: + async with async_session() as s: + return await s.scalar( + select(Platform.id).where(Platform.slug == slug, Platform.deleted_at.is_(None)) + ) + + +@pytest_asyncio.fixture +async def seeded(): + """An owner, an outsider and a fresh project. Each test gets its own + project, so answers never leak between tests through a shared row.""" + async with async_session() as s: + owner = await ensure_user(s, OWNER) + outsider = await ensure_user(s, "platforms_outsider") + project = Project(user_id=owner.id, title="an app with a server") + s.add(project) + await s.flush() + ids = {"owner": owner.id, "outsider": outsider.id, "pid": project.id} + await s.commit() + return ids + + +def _states(rows: list[dict]) -> dict[str, str]: + return {r["slug"]: r["state"] for r in rows} + + +async def test_detection_adds_only_where_nobody_has_answered(seeded): + owner, pid = seeded["owner"], seeded["pid"] + await platforms_svc.set_project_platforms(owner, pid, { + "go": "declared", "rust": "rejected", + }) + added = await platforms_svc.detect_for_project(pid, [ + "server/go.mod", "core/Cargo.toml", "app/src/main/AndroidManifest.xml", + ]) + # Only android was unanswered; go and rust keep the person's answers. + assert added == [await _id("android-app")] + states = _states(await platforms_svc.project_platforms(owner, pid)) + assert states == {"go": "declared", "rust": "rejected", "android-app": "detected"} + + +async def test_detection_never_removes_a_platform_whose_marker_is_gone(seeded): + owner, pid = seeded["owner"], seeded["pid"] + await platforms_svc.detect_for_project(pid, ["server/go.mod"]) + await platforms_svc.detect_for_project(pid, ["README.md"]) + assert _states(await platforms_svc.project_platforms(owner, pid)) == {"go": "detected"} + + +async def test_a_withdrawn_answer_lets_detection_decide_again(seeded): + owner, pid = seeded["owner"], seeded["pid"] + await platforms_svc.set_project_platforms(owner, pid, {"go": "rejected"}) + assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == [] + await platforms_svc.set_project_platforms(owner, pid, {"go": None}) + assert await platforms_svc.detect_for_project(pid, ["go.mod"]) == [await _id("go")] + + +async def test_a_bad_update_writes_nothing(seeded): + owner, pid = seeded["owner"], seeded["pid"] + with pytest.raises(ValueError, match="unknown platform"): + await platforms_svc.set_project_platforms(owner, pid, { + "go": "declared", "not-a-platform": "declared", + }) + assert await platforms_svc.project_platforms(owner, pid) == [] + with pytest.raises(ValueError, match="no write access"): + await platforms_svc.set_project_platforms(seeded["outsider"], pid, {"go": "declared"}) + assert await platforms_svc.project_platforms(seeded["outsider"], pid) is None + + +async def test_inception_records_slugs_and_rejects_what_it_leaves_out(seeded): + """The inception list is the WHOLE answer. Detection found Android and + Go; the person says the project is Go and Python — so Android becomes a + recorded "no", and the next refresh cannot bring it back.""" + owner, pid = seeded["owner"], seeded["pid"] + await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml", "go.mod"]) + defaults = await inception_svc.current_defaults(owner, pid) + assert {"android-app", "go"} <= {p["slug"] for p in defaults["project_platforms"]} + + out = await inception_svc.decide(owner, pid, via="ui", choices={ + "seed_systems": False, "platforms": ["python", "go"], + }) + assert _states(out["effects"]["platforms"]) == { + "android-app": "rejected", "go": "declared", "python": "declared", + } + async with async_session() as s: + project = await s.get(Project, pid) + assert project.inception["choices"]["platforms"] == ["go", "python"] + assert await platforms_svc.detect_for_project(pid, ["app/AndroidManifest.xml"]) == [] + + +async def test_an_unknown_inception_platform_applies_nothing(seeded): + owner, pid = seeded["owner"], seeded["pid"] + with pytest.raises(ValueError, match="unknown platform"): + await inception_svc.decide(owner, pid, via="mcp", choices={ + "seed_systems": True, "platforms": ["go", "cobol-mainframe"], + }) + assert await platforms_svc.project_platforms(owner, pid) == [] + + +async def test_the_catalog_is_admin_written_and_duplicate_gated(): + async with async_session() as s: + admin = await ensure_user(s, "platforms_admin", role="admin") + user = await ensure_user(s, OWNER) + await s.commit() + admin_id, user_id = admin.id, user.id + assert await platforms_svc.create_platform(user_id, "Kotlin Multiplatform") is None + # "Android App" reduces to the seeded android-app: the existing one comes + # back rather than a second spelling of it. + dup = await platforms_svc.create_platform(admin_id, "Android App") + assert dup["duplicate"] and dup["existing_id"] == await _id("android-app") + with pytest.raises(ValueError, match="repo-relative"): + await platforms_svc.create_platform(admin_id, "Odd", markers=["/abs"]) diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py index 5e93f689..e40dd61e 100644 --- a/tests/test_mcp_tool_projects.py +++ b/tests/test_mcp_tool_projects.py @@ -25,6 +25,18 @@ def _no_systems(): yield +@pytest.fixture(autouse=True) +def _no_platforms(): + """enter_project and get_project carry the project's platforms (milestone + 463). No database in this lane, so the lookup is stubbed to the common + case — a project with none yet. The populated shape is asserted in its + own test. + """ + with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms", + AsyncMock(return_value=[])): + yield + + @pytest.fixture(autouse=True) def _no_coverage(): """enter_project also reads the pattern-coverage cache (#2692) — same diff --git a/tests/test_milestone_summary_brief.py b/tests/test_milestone_summary_brief.py index febea93a..eabdb007 100644 --- a/tests/test_milestone_summary_brief.py +++ b/tests/test_milestone_summary_brief.py @@ -24,6 +24,19 @@ PLAN = "A plan paragraph long enough to matter. " * 125 # ~5k chars GOAL = "What the project is for. " * 50 # ~1.2k chars +@pytest.fixture(autouse=True) +def _no_platforms(): + """enter_project and get_project carry the project's platforms (milestone + 463). No database in this lane, so the lookup is stubbed to the common + case — a project with none yet. The populated shape is asserted in its + own test. + """ + with patch("scribe.mcp.tools.projects.platforms_svc.project_platforms", + AsyncMock(return_value=[])): + yield + + + def _milestone(mid: int, status: str, touched_day: int) -> dict: """A summary row as get_project_milestone_summary returns it.""" touched = f"2026-08-{touched_day:02d}T00:00:00+00:00" diff --git a/tests/test_pattern_coverage.py b/tests/test_pattern_coverage.py index ca67f79d..19c1edef 100644 --- a/tests/test_pattern_coverage.py +++ b/tests/test_pattern_coverage.py @@ -8,11 +8,9 @@ deliberately reuse the definitions test_write_path_trigger stages for the hook; extending one detector means extending both, and this comment is the tripwire. """ -import io import json import shutil import subprocess -import tarfile from pathlib import Path import pytest @@ -29,7 +27,7 @@ from scribe.services.coverage import ( shapes_from_archive, ) from scribe.services.shape_ledger import location_covers -from tests.helpers import ensure_user +from tests.helpers import ensure_user, forge_tarball PLUGIN = Path(__file__).resolve().parents[1] / "plugin" @@ -173,14 +171,7 @@ def test_scannable_gates_prose_vendored_and_sourcemaps(): # --- unit: reading shapes out of a forge tarball ----------------------------- -def _tarball(files: dict[str, bytes], top: str = "widget") -> bytes: - buf = io.BytesIO() - with tarfile.open(fileobj=buf, mode="w:gz") as tar: - for path, data in files.items(): - info = tarfile.TarInfo(f"{top}/{path}") - info.size = len(data) - tar.addfile(info, io.BytesIO(data)) - return buf.getvalue() +_tarball = forge_tarball # shared with tests/test_platforms.py TREE = { diff --git a/tests/test_platforms.py b/tests/test_platforms.py new file mode 100644 index 00000000..71ecafc2 --- /dev/null +++ b/tests/test_platforms.py @@ -0,0 +1,145 @@ +"""Platforms without a database (milestone 463 step 2). + +Detection is pure — a catalog and a list of repo paths in, platform ids out — +so it is pinned here against fixture trees. What it writes, and what it must +never overwrite, is in tests/test_integration_platforms.py. +""" +from __future__ import annotations + +from types import SimpleNamespace + +from scribe.mcp.server import _READ_ONLY_TOOLS, _WRITE_TOOLS +from scribe.mcp.tools.projects import _inception_choices +from scribe.services.coverage import scan_archive +from tests.helpers import forge_tarball +from scribe.services.platforms import ( + MEMBER_STATES, SETTABLE_STATES, detect, marker_matches, members, + validate_markers, validate_updates, +) + + +def _platform(pid: int, *markers: str) -> SimpleNamespace: + return SimpleNamespace(id=pid, markers=list(markers)) + + +# --- a single marker ---------------------------------------------------------- + +def test_a_bare_marker_matches_the_basename_anywhere_in_the_tree(): + assert marker_matches("go.mod", "go.mod") + assert marker_matches("go.mod", "services/api/go.mod") + assert marker_matches("AndroidManifest.xml", "app/src/main/AndroidManifest.xml") + assert marker_matches("vite.config.*", "frontend/vite.config.ts") + assert not marker_matches("go.mod", "go.mod.bak") + assert not marker_matches("go.mod", "docs/go.mod.md") + + +def test_a_marker_with_a_slash_matches_the_whole_path_only(): + """A directory-shaped marker cannot be satisfied by a same-named file + somewhere else — `.github/workflows/*` is about the repo root's CI, not + a vendored copy of some other project's.""" + assert marker_matches(".github/workflows/*", ".github/workflows/ci.yml") + assert not marker_matches(".github/workflows/*", "vendor/x/.github/workflows/ci.yml") + assert not marker_matches(".github/workflows/*", "ci.yml") + + +def test_a_blank_marker_matches_nothing(): + assert not marker_matches("", "anything") + assert not marker_matches(" ", "anything") + + +# --- detection over a tree ------------------------------------------------------- + +ANDROID_AND_GO = [ + "app/build.gradle.kts", + "app/src/main/AndroidManifest.xml", + "server/go.mod", + "server/main.go", + "README.md", +] + + +def test_detect_finds_every_platform_a_tree_carries_and_nothing_else(): + catalog = [ + _platform(1, "AndroidManifest.xml"), + _platform(2, "go.mod"), + _platform(3, "Cargo.toml"), + _platform(4, ".github/workflows/*"), + ] + assert detect(catalog, ANDROID_AND_GO) == [1, 2] + + +def test_a_platform_with_no_markers_is_declare_only(): + """An empty marker list is how a platform says "a person must tell you" — + it must not match everything, and it must not match nothing by error.""" + catalog = [_platform(1), _platform(2, "go.mod")] + assert detect(catalog, ANDROID_AND_GO) == [2] + assert detect([SimpleNamespace(id=9, markers=None)], ANDROID_AND_GO) == [] + + +def test_detect_on_an_empty_tree_finds_nothing(): + assert detect([_platform(1, "go.mod")], []) == [] + + +def test_the_archive_scan_carries_every_path_not_only_the_scannable_ones(): + """The markers that say what a project IS are mostly files the shape scan + skips — a manifest, a go.mod, a gradle script. Detection reads the scan's + paths, so a scan that listed only code files would detect nothing.""" + scan = scan_archive(forge_tarball({ + "app/src/main/AndroidManifest.xml": b"", + "server/go.mod": b"module x\n", + "server/main.py": b"def main():\n pass\n", + })) + assert set(scan.paths) == { + "app/src/main/AndroidManifest.xml", "server/go.mod", "server/main.py", + } + # The forge's wrapping directory is stripped, as it is for shapes. + assert not any(p.startswith("widget/") for p in scan.paths) + + +# --- the answers a person may give ------------------------------------------------- + +def test_detected_is_the_refreshs_to_write_never_a_persons(): + assert "detected" in MEMBER_STATES + assert "detected" not in SETTABLE_STATES + assert "rejected" not in MEMBER_STATES + + +def test_validate_updates(): + assert validate_updates({"go": "declared", "rust": "rejected", "python": None}) is None + assert validate_updates({}) is None + assert "slug: state" in validate_updates(["go"]) + assert "not a state a person sets" in validate_updates({"go": "detected"}) + assert "not a state a person sets" in validate_updates({"go": "maybe"}) + assert "named by its slug" in validate_updates({"": "declared"}) + + +def test_validate_markers(): + assert validate_markers(None) is None + assert validate_markers(["go.mod", ".github/workflows/*"]) is None + assert "list of glob patterns" in validate_markers("go.mod") + assert "list of glob patterns" in validate_markers([1]) + assert "repo-relative" in validate_markers(["/etc/passwd"]) + + +def test_members_drops_the_rejected_answers(): + rows = [ + {"id": 1, "slug": "go", "name": "Go", "state": "declared"}, + {"id": 2, "slug": "rust", "name": "Rust", "state": "rejected"}, + {"id": 3, "slug": "python", "name": "Python", "state": "detected"}, + ] + assert [m["slug"] for m in members(rows)] == ["go", "python"] + + +# --- the doors ------------------------------------------------------------------------ + +def test_the_platform_tools_are_classified(): + assert "list_platforms" in _READ_ONLY_TOOLS + assert "set_project_platforms" in _WRITE_TOOLS + + +def test_inception_choices_carry_platforms_only_when_given(): + """Left out, platforms stays UNSTATED (None after normalising) — which is + not the same answer as an empty list, "none of them".""" + assert "platforms" not in _inception_choices(0, True) + assert _inception_choices(0, True, ["go"])["platforms"] == ["go"] + assert _inception_choices(0, True, [])["platforms"] == [] diff --git a/tests/test_routes_platforms.py b/tests/test_routes_platforms.py new file mode 100644 index 00000000..c7c9ad05 --- /dev/null +++ b/tests/test_routes_platforms.py @@ -0,0 +1,28 @@ +"""Structural tests for the platforms blueprint (milestone 463 step 2) — +registration and the route-to-service contract. What the writes do is in +tests/test_integration_platforms.py.""" +import inspect + + +def test_platforms_blueprint_registered_in_app(): + from scribe.app import create_app + app = create_app() + assert "platforms" in app.blueprints + rules = {(r.rule, m) for r in app.url_map.iter_rules() for m in r.methods} + for rule, method in ( + ("/api/platforms", "GET"), + ("/api/platforms", "POST"), + ("/api/platforms/", "PATCH"), + ("/api/projects//platforms", "GET"), + ("/api/projects//platforms", "PUT"), + ): + assert (rule, method) in rules, f"{method} {rule} is not routed" + + +def test_writes_to_the_catalog_and_to_a_project_take_the_caller(): + """The catalog's admin gate and the project's write gate both live in the + service, so every write must be handed the caller to check.""" + from scribe.services import platforms as svc + for name in ("create_platform", "update_platform", "set_project_platforms", + "project_platforms"): + assert "user_id" in inspect.signature(getattr(svc, name)).parameters