Merge dev: family canon, milestone 463 steps 1-6 #206
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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})
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>, ...])"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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"] == []
|
||||
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"] == []
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user