feat(family): platforms declared at inception and detected from bound repos (milestone 463 step 2, #4988)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 21s
CI & Build / TypeScript typecheck (push) Successful in 1m10s
CI & Build / integration (push) Successful in 1m48s
CI & Build / Python tests (push) Successful in 2m40s
CI & Build / Build & push image (push) Failing after 44s

A project's platforms decide which family ideas reach it. This step makes membership answerable from every door:

- services/platforms.py: the global catalog (writes are admin-only and duplicate-gated by slug); pure marker detection; and membership reads and writes. Detection only ADDS, and only where nobody has answered. It never overrides a declared or rejected row and never removes one.
- coverage: the archive scan now carries every path, and the refresh runs detection fail-open.
- inception: a platforms choice (slugs, or null for unanswered). The list is the whole answer: members left out of it become rejected.
- MCP: list_platforms and set_project_platforms; enter_project and get_project carry the project's platforms.
- REST: /api/platforms (admin writes) and /api/projects/<id>/platforms.
- UI: a platforms checklist on the inception card, a Family tab on ProjectView, and a Platforms admin tab in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 09:51:28 -04:00
co-authored by Claude Opus 5.5
parent e68ccc5884
commit 07d2542479
27 changed files with 1638 additions and 51 deletions
+15 -2
View File
@@ -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) =>
+77
View File
@@ -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<Platform[]> {
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<Platform> {
return apiPost("/api/platforms", data);
}
export async function updatePlatform(
id: number,
data: Partial<{ name: string; description: string; order_index: number; markers: string[] }>,
): Promise<Platform> {
return apiPatch(`/api/platforms/${id}`, data);
}
export async function fetchProjectPlatforms(projectId: number): Promise<ProjectPlatform[]> {
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<string, SettablePlatformState>,
): Promise<ProjectPlatform[]> {
const data = await apiPut<{ project_platforms: ProjectPlatform[] }>(
`/api/projects/${projectId}/platforms`, { platforms },
);
return data.project_platforms;
}
+71 -2
View File
@@ -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<InceptionChoices>(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<Set<string>>(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);
</span>
</label>
</div>
<div v-if="platforms.length" class="inception-group">
<h4>Platforms</h4>
<p class="inception-muted">
What it is built on or ships as. Ideas the family has proven for a
platform reach every project that is one.
<template v-if="local.platforms === null"> Not answered yet.</template>
<template v-else>
Anything left unticked is recorded as "not this project".
<button type="button" class="btn-ghost btn-sm" @click="clearPlatforms">Leave unanswered</button>
</template>
</p>
<div class="inception-platforms">
<label v-for="p in platforms" :key="p.slug" class="inception-choice">
<input
type="checkbox"
:checked="isChecked(p.slug)"
@change="togglePlatform(p.slug, ($event.target as HTMLInputElement).checked)"
/>
<span>
{{ p.name }}
<em v-if="detected.has(p.slug)" class="inception-muted"> — detected</em>
</span>
</label>
</div>
</div>
<p v-if="nothingToDecide" class="inception-muted">
No design systems on this install yet — recording still settles the question.
</p>
@@ -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);
@@ -0,0 +1,200 @@
<script setup lang="ts">
/**
* A project's place in the family (milestone 463): which platforms it is.
*
* Membership is what makes "does this family idea reach this project?" a
* lookup rather than a judgment — an idea is for some platforms, and it
* reaches every project that is one of them. So the answers here matter, and
* each kind of answer is shown as what it is:
*
* - Yes — a person said so.
* - Detected — a marker file in a bound repo said so. Detection only adds,
* and only where nobody has answered; it never overrides a Yes or a No.
* - No — a person said this project is NOT that platform. Kept as a row, so
* the next coverage refresh cannot detect it straight back.
* - No answer — detection may decide on the next refresh.
*
* Each change is saved as it is made: one platform, one request, applied
* whole or refused whole.
*/
import { computed, onMounted, ref, watch } from "vue";
import { apiErrorMessage } from "@/api/client";
import {
fetchProjectPlatforms, setProjectPlatforms,
type PlatformState, type ProjectPlatform, type SettablePlatformState,
} from "@/api/platforms";
import { usePlatformsStore } from "@/stores/platforms";
const props = defineProps<{ projectId: number; canWrite: boolean }>();
const platformsStore = usePlatformsStore();
const answers = ref<ProjectPlatform[]>([]);
const loading = ref(false);
const failed = ref(false);
const saving = ref<string | null>(null);
const error = ref("");
const LABELS: Record<PlatformState, string> = {
declared: "Yes",
detected: "Detected",
rejected: "No",
};
async function load() {
loading.value = true;
failed.value = false;
try {
const [, rows] = await Promise.all([
platformsStore.fetchCatalog(),
fetchProjectPlatforms(props.projectId),
]);
answers.value = rows;
} catch {
// Said out loud: "couldn't load" must not read as "no platforms".
failed.value = true;
} finally {
loading.value = false;
}
}
const stateBySlug = computed(() => {
const out: Record<string, PlatformState> = {};
for (const a of answers.value) out[a.slug] = a.state;
return out;
});
const rows = computed(() =>
platformsStore.catalog.map((p) => ({
...p,
state: stateBySlug.value[p.slug] ?? null,
})),
);
const memberCount = computed(
() => answers.value.filter((a) => a.state !== "rejected").length,
);
async function answer(slug: string, value: string) {
const state: SettablePlatformState = value === "" ? null : (value as SettablePlatformState);
saving.value = slug;
error.value = "";
try {
answers.value = await setProjectPlatforms(props.projectId, { [slug]: state });
} catch (e: unknown) {
error.value = apiErrorMessage(e, "Could not save that answer");
} finally {
saving.value = null;
}
}
onMounted(load);
watch(() => props.projectId, load);
</script>
<template>
<div class="pft">
<header class="pft-head">
<h3 class="pft-title">Platforms</h3>
<p class="pft-muted">
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.
</p>
</header>
<p v-if="loading" class="pft-muted">Loading…</p>
<div v-else-if="failed" class="pft-note">
<strong>The platforms couldn't load.</strong>
<p>Nothing is known either way — this is a failure, not an empty answer.</p>
<button class="btn-secondary btn-sm" @click="load">Try again</button>
</div>
<p v-else-if="!rows.length" class="pft-muted">
This install has no platforms in its catalog yet. An administrator adds
them in Settings.
</p>
<template v-else>
<p class="pft-summary">
<template v-if="memberCount">
A member of {{ memberCount }} platform{{ memberCount === 1 ? "" : "s" }}.
</template>
<template v-else>
Not a member of any platform yet — bind a repo and refresh coverage
to detect them, or answer below.
</template>
</p>
<p v-if="error" class="error-msg">{{ error }}</p>
<ul class="pft-list">
<li v-for="p in rows" :key="p.id" class="pft-row">
<div class="pft-name">
<span>{{ p.name }}</span>
<span v-if="p.state" :class="['pft-tag', `pft-${p.state}`]">{{ LABELS[p.state] }}</span>
<span v-if="p.description" class="pft-desc">{{ p.description }}</span>
</div>
<select
v-if="canWrite"
class="pft-select"
:value="p.state === 'detected' ? 'detected' : (p.state ?? '')"
:disabled="saving === p.slug"
:aria-label="`Is this project ${p.name}?`"
@change="answer(p.slug, ($event.target as HTMLSelectElement).value)"
>
<option v-if="p.state === 'detected'" value="detected" disabled>Detected</option>
<option value="declared">Yes</option>
<option value="rejected">No</option>
<option value="">No answer</option>
</select>
</li>
</ul>
</template>
</div>
</template>
<style scoped>
.pft { padding: 0.25rem 0; }
.pft-head { margin-bottom: 1rem; }
.pft-title { margin: 0 0 0.35rem; font-size: 1rem; }
.pft-muted { color: var(--fs-text-tertiary); font-size: 0.875rem; margin: 0; }
.pft-summary { color: var(--fs-text-secondary); font-size: 0.9rem; margin: 0 0 0.75rem; }
.pft-note {
background: var(--fs-surface-raised);
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-md);
padding: 0.85rem 1rem;
}
.pft-note p { margin: 0.35rem 0 0.6rem; color: var(--fs-text-secondary); font-size: 0.875rem; }
.pft-list { list-style: none; margin: 0; padding: 0; }
.pft-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.6rem 0;
border-bottom: 1px solid var(--fs-border-color);
}
.pft-name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.5rem; min-width: 0; }
.pft-desc { flex-basis: 100%; color: var(--fs-text-tertiary); font-size: 0.8rem; }
.pft-tag {
font-size: 0.75rem;
padding: 0.05rem 0.45rem;
border-radius: var(--fs-radius-sm);
border: 1px solid var(--fs-border-color);
color: var(--fs-text-secondary);
}
.pft-declared { color: var(--fs-text-primary); }
.pft-rejected { color: var(--fs-text-tertiary); text-decoration: line-through; }
.pft-select {
padding: 0.35rem 0.6rem;
border: 1px solid var(--fs-border-color);
border-radius: var(--fs-radius-sm);
background: var(--fs-surface-page);
color: var(--fs-text-primary);
font-size: 0.85rem;
flex-shrink: 0;
}
@media (max-width: 640px) {
.pft-row { flex-direction: column; align-items: stretch; }
}
</style>
+48
View File
@@ -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<Platform[]>([]);
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 };
});
+17 -1
View File
@@ -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<string | null>(null);
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design">("tasks");
const activeTab = ref<"tasks" | "notes" | "systems" | "rules" | "design" | "family">("tasks");
const tasks = ref<NoteItem[]>([]);
const notes = ref<NoteItem[]>([]);
@@ -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" }}
<template v-if="project.inception.choices.seed_systems"> · Systems seeded</template>
<!-- What was said AT inception; the live answers are on the Family tab. -->
<template v-if="project.inception.choices.platforms?.length">
· platforms {{ project.inception.choices.platforms.join(", ") }}
</template>
</p>
<!-- Summary stat chips -->
@@ -945,6 +951,9 @@ async function confirmDelete() {
<button :class="['tab-btn', { active: activeTab === 'design' }]" @click="activeTab = 'design'">
Design
</button>
<button :class="['tab-btn', { active: activeTab === 'family' }]" @click="activeTab = 'family'">
Family
</button>
</div>
<!-- Tasks tab — milestone-grouped kanban -->
@@ -1190,6 +1199,13 @@ async function confirmDelete() {
:project-id="projectId"
:design-system-id="project.design_system_id ?? null"
/>
<!-- Family tab (milestone 463): which platforms this project is. -->
<ProjectFamilyTab
v-if="activeTab === 'family'"
:project-id="projectId"
:can-write="canWriteRecord(project.permission)"
/>
</div>
</div>
</template>
+168 -2
View File
@@ -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<number | null>(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<HTMLInputElement | null>(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) {
<div v-if="authStore.isAdmin" class="sidebar-group">
<div class="sidebar-group-label">Admin</div>
<button
v-for="tab in ['config', 'areas', 'users', 'groups', 'logs']"
v-for="tab in ['config', 'areas', 'platforms', 'users', 'groups', 'logs']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -3179,6 +3250,99 @@ async function deleteUser(userId: number) {
</section>
</div>
<!-- ── Platforms ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'platforms'" class="settings-grid">
<section class="settings-section full-width">
<h2>Platforms</h2>
<p class="field-hint">
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
(<code>go.mod</code>) matches that file anywhere in a bound repo, and a pattern with a
slash (<code>.github/workflows/*</code>) matches the whole path. A platform with no
markers is never detected — projects declare it themselves.
</p>
<ul class="area-admin-list">
<li v-for="entry in platformsStore.catalog" :key="entry.id" class="area-admin-row">
<template v-if="editingPlatformId === entry.id">
<form class="area-admin-form" @submit.prevent="savePlatform">
<input v-model="editPlatformName" class="fs-input" aria-label="Platform name" />
<textarea
v-model="editPlatformDescription"
class="fs-input"
rows="2"
aria-label="Platform description"
></textarea>
<textarea
v-model="editPlatformMarkers"
class="fs-input area-admin-markers"
rows="3"
aria-label="Detection markers, one per line"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!editPlatformName.trim() || savingPlatform">
{{ savingPlatform ? "Saving…" : "Save" }}
</button>
<button type="button" class="btn-ghost btn-compact" @click="editingPlatformId = null">Cancel</button>
</div>
</form>
</template>
<template v-else>
<div class="area-admin-body">
<div class="area-admin-name-row">
<span class="area-admin-name">{{ entry.name }}</span>
<code class="area-admin-slug" title="The match key, and the name tools and backups use.">{{ entry.slug }}</code>
</div>
<p v-if="entry.description" class="area-admin-desc">{{ entry.description }}</p>
<p class="area-admin-desc">
<template v-if="entry.markers.length">
Detected by
<code v-for="m in entry.markers" :key="m" class="area-admin-slug">{{ m }}</code>
</template>
<template v-else>Declare-only — never detected.</template>
</p>
</div>
<button
class="btn-ghost btn-compact"
@click="startEditPlatform(entry.id, entry.name, entry.description, entry.markers)"
>Edit</button>
</template>
</li>
</ul>
<p v-if="!platformsStore.catalog.length && !platformsStore.loading" class="settings-empty">
No platforms yet.
</p>
<form class="area-admin-form area-admin-create" @submit.prevent="createPlatform">
<input
v-model="newPlatformName"
class="fs-input"
placeholder="New platform name (e.g. Flutter app)"
aria-label="New platform name"
/>
<textarea
v-model="newPlatformDescription"
class="fs-input"
rows="2"
placeholder="What makes a project this platform? One sentence."
aria-label="New platform description"
></textarea>
<textarea
v-model="newPlatformMarkers"
class="fs-input area-admin-markers"
rows="2"
placeholder="Markers, one per line (e.g. pubspec.yaml) — leave empty for declare-only"
aria-label="New platform markers, one per line"
></textarea>
<div class="area-admin-actions">
<button type="submit" class="btn-primary btn-compact" :disabled="!newPlatformName.trim() || creatingPlatform">
{{ creatingPlatform ? "Adding…" : "Add platform" }}
</button>
</div>
</form>
</section>
</div>
<div v-if="authStore.isAdmin" v-show="activeTab === 'users'" class="settings-grid">
<section class="settings-section full-width">
@@ -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
+2
View File
@@ -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)
+4
View File
@@ -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",
+2 -1
View File
@@ -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)
+78
View File
@@ -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)
+32 -9
View File
@@ -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}
+96
View File
@@ -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/<id>/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/<int:platform_id>", 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/<int:project_id>/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/<int:project_id>/platforms", methods=["PUT"])
@login_required
async def set_project_platforms_route(project_id: int):
"""Body: {"platforms": {<slug>: "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})
+9
View File
@@ -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
# ---------------------------------------------------------------------------
+1 -4
View File
@@ -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]:
+22 -1
View File
@@ -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)
+84 -12
View File
@@ -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": <id> | null,
"seed_systems": bool
"seed_systems": bool,
"platforms": [<slug>, ...] | 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: <count>}.
{design_system_id, design_systems: [{id,title}], systems: <count>,
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": <record>, "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=<id | -1 for none>, seed_systems=<true|false>)"
"design_system_id=<id | -1 for none>, seed_systems=<true|false>, "
"platforms=[<slug>, ...])"
),
}
+335
View File
@@ -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
+15
View File
@@ -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()
+21 -3
View File
@@ -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():
+7 -3
View File
@@ -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"] == []
+134
View File
@@ -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"])
+12
View File
@@ -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
+13
View File
@@ -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"
+2 -11
View File
@@ -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 = {
+145
View File
@@ -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"<manifest/>",
"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"] == []
+28
View File
@@ -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/<int:platform_id>", "PATCH"),
("/api/projects/<int:project_id>/platforms", "GET"),
("/api/projects/<int:project_id>/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