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.
+ Not answered yet.
+
+ Anything left unticked is recorded as "not this project".
+
+
+
+
+
+
+
No design systems on this install yet — recording still settles the question.
+ What this project is built on or ships as. Ideas the family has proven
+ for a platform reach every project that is one, and each project
+ answers them — adopts, varies with a reason, or owes the work.
+
+
+
+
Loading…
+
+
+ The platforms couldn't load.
+
Nothing is known either way — this is a failure, not an empty answer.
+
+
+
+
+ This install has no platforms in its catalog yet. An administrator adds
+ them in Settings.
+
+
+
+
+
+ A member of {{ memberCount }} platform{{ memberCount === 1 ? "" : "s" }}.
+
+
+ Not a member of any platform yet — bind a repo and refresh coverage
+ to detect them, or answer below.
+
+
+
+
+
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" }}
· Systems seeded
+
+
+ · platforms {{ project.inception.choices.platforms.join(", ") }}
+
@@ -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) {
Admin
+
+
+
+
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.
+
+
+
+
+
+
+
+
+
+
+ {{ entry.name }}
+ {{ entry.slug }}
+
+
{{ entry.description }}
+
+
+ Detected by
+ {{ m }}
+
+ Declare-only — never detected.
+
+
+
+
+
+
+
+ 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